├── .github └── workflows │ └── validate.yml ├── .gitignore ├── .npmrc ├── LICENSE.md ├── README.md ├── epicshop ├── .diffignore ├── .npmrc ├── Dockerfile ├── fly.toml ├── in-browser-tests.spec.js ├── package-lock.json ├── package.json ├── playwright.config.js ├── post-set-playground.js ├── setup-custom.js ├── setup.js ├── tsconfig.json └── update-deps.sh ├── eslint.config.js ├── exercises ├── 01.use-reducer │ ├── 01.problem.new-state │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.new-state │ │ ├── README.mdx │ │ ├── counter.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 02.problem.previous-state │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.previous-state │ │ ├── README.mdx │ │ ├── counter.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 03.problem.object │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 03.solution.object │ │ ├── README.mdx │ │ ├── counter.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 04.problem.function │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 04.solution.function │ │ ├── README.mdx │ │ ├── counter.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 05.problem.traditional │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 05.solution.traditional │ │ ├── README.mdx │ │ ├── counter.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 06.problem.tic-tac-toe │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 06.solution.tic-tac-toe │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ └── tic-tac-toe.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── 02.state-optimization │ ├── 01.problem.optimize │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.optimize │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ └── optimize.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── 03.custom-hooks │ ├── 01.problem.function │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.function │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ └── search-params.test.ts │ ├── 02.problem.callback │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.callback │ │ ├── README.mdx │ │ ├── callback.test.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ └── search-params.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── 04.context │ ├── 01.problem.provider │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.provider │ │ ├── README.mdx │ │ ├── filtering.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 02.problem.hook │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.hook │ │ ├── README.mdx │ │ ├── filtering.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 05.portals │ ├── 01.problem.create │ │ ├── README.mdx │ │ ├── form.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── params.tsx │ │ ├── posts.tsx │ │ └── tooltip.tsx │ ├── 01.solution.create │ │ ├── README.mdx │ │ ├── form.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── params.tsx │ │ ├── portal.test.ts │ │ ├── posts.tsx │ │ └── tooltip.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 06.layout-computation │ ├── 01.problem.layout-effect │ │ ├── README.mdx │ │ ├── form.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── params.tsx │ │ ├── posts.tsx │ │ └── tooltip.tsx │ ├── 01.solution.layout-effect │ │ ├── README.mdx │ │ ├── form.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── layout-effect.test.ts │ │ ├── params.tsx │ │ ├── posts.tsx │ │ └── tooltip.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 07.imperative-handle │ ├── 01.problem.ref │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ └── messages.tsx │ ├── 01.solution.ref │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── messages.tsx │ │ └── scroll.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── 08.focus │ ├── 01.problem.flush-sync │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.flush-sync │ │ ├── README.mdx │ │ ├── focus.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 09.sync-external │ ├── 01.problem.sub │ │ ├── README.mdx │ │ └── index.tsx │ ├── 01.solution.sub │ │ ├── README.mdx │ │ ├── index.tsx │ │ └── sync-media-query.test.ts │ ├── 02.problem.util │ │ ├── README.mdx │ │ └── index.tsx │ ├── 02.solution.util │ │ ├── README.mdx │ │ ├── index.tsx │ │ └── sync-media-query.test.ts │ ├── 03.problem.ssr │ │ ├── README.mdx │ │ └── index.tsx │ ├── 03.solution.ssr │ │ ├── README.mdx │ │ ├── index.tsx │ │ └── sync-media-query.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── FINISHED.mdx └── README.mdx ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── favicon.svg ├── hook-flow.png ├── images │ └── instructor.png ├── logo.svg └── og │ ├── background.png │ └── logo.svg ├── reset.d.ts ├── shared ├── blog-posts.tsx ├── tic-tac-toe-utils.tsx └── utils.tsx └── tsconfig.json /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'main' 11 | pull_request: 12 | branches: 13 | - 'main' 14 | jobs: 15 | setup: 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, windows-latest, macos-latest] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - name: ⬇️ Checkout repo 22 | uses: actions/checkout@v4 23 | 24 | - name: ⎔ Setup node 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 20 28 | 29 | - name: ▶️ Run setup script 30 | run: npm run setup 31 | 32 | - name: ʦ TypeScript 33 | run: npm run typecheck 34 | 35 | - name: ⬣ ESLint 36 | run: npm run lint 37 | 38 | # TODO: get this working again 39 | # - name: ⬇️ Install Playwright 40 | # run: npm --prefix epicshop run test:setup 41 | 42 | # - name: 🧪 In-browser tests 43 | # run: npm --prefix epicshop test 44 | 45 | deploy: 46 | name: 🚀 Deploy 47 | runs-on: ubuntu-latest 48 | # only deploy main branch on pushes 49 | if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} 50 | 51 | steps: 52 | - name: ⬇️ Checkout repo 53 | uses: actions/checkout@v4 54 | 55 | - name: 🎈 Setup Fly 56 | uses: superfly/flyctl-actions/setup-flyctl@1.5 57 | 58 | - name: 🚀 Deploy 59 | run: flyctl deploy --remote-only 60 | working-directory: ./epicshop 61 | env: 62 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | workspace/ 4 | **/.cache/ 5 | **/build/ 6 | **/public/build 7 | **/playwright-report 8 | **/test-results 9 | data.db 10 | /playground 11 | **/tsconfig.tsbuildinfo 12 | 13 | # in a real app you'd want to not commit the .env 14 | # file as well, but since this is for a workshop 15 | # we're going to keep them around. 16 | # .env 17 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This material is available for private, non-commercial use under the 2 | [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html). If you 3 | would like to use this material to conduct your own workshop, please contact us 4 | at team@epicweb.dev 5 | -------------------------------------------------------------------------------- /epicshop/.diffignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | *.test.* -------------------------------------------------------------------------------- /epicshop/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /epicshop/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-bookworm-slim as base 2 | 3 | RUN apt-get update && apt-get install -y git 4 | 5 | ENV EPICSHOP_CONTEXT_CWD="/myapp/workshop-content" 6 | ENV EPICSHOP_DEPLOYED="true" 7 | ENV EPICSHOP_DISABLE_WATCHER="true" 8 | ENV FLY="true" 9 | ENV PORT="8080" 10 | ENV NODE_ENV="production" 11 | 12 | WORKDIR /myapp 13 | 14 | ADD . . 15 | 16 | RUN npm install --omit=dev 17 | 18 | CMD rm -rf ${EPICSHOP_CONTEXT_CWD} && \ 19 | git clone https://github.com/epicweb-dev/advanced-react-apis ${EPICSHOP_CONTEXT_CWD} && \ 20 | cd ${EPICSHOP_CONTEXT_CWD} && \ 21 | npx epicshop start -------------------------------------------------------------------------------- /epicshop/fly.toml: -------------------------------------------------------------------------------- 1 | app = "epicweb-dev-advanced-react-apis" 2 | primary_region = "sjc" 3 | kill_signal = "SIGINT" 4 | kill_timeout = 5 5 | processes = [ ] 6 | swap_size_mb = 512 7 | 8 | [experimental] 9 | allowed_public_ports = [ ] 10 | auto_rollback = true 11 | 12 | [[services]] 13 | internal_port = 8080 14 | processes = [ "app" ] 15 | protocol = "tcp" 16 | script_checks = [ ] 17 | 18 | [services.concurrency] 19 | hard_limit = 100 20 | soft_limit = 80 21 | type = "connections" 22 | 23 | [[services.ports]] 24 | handlers = [ "http" ] 25 | port = 80 26 | force_https = true 27 | 28 | [[services.ports]] 29 | handlers = [ "tls", "http" ] 30 | port = 443 31 | 32 | [[services.tcp_checks]] 33 | grace_period = "1s" 34 | interval = "15s" 35 | restart_limit = 0 36 | timeout = "2s" 37 | 38 | [[services.http_checks]] 39 | interval = "10s" 40 | grace_period = "5s" 41 | method = "get" 42 | path = "/" 43 | protocol = "http" 44 | timeout = "2s" 45 | tls_skip_verify = false 46 | headers = { } -------------------------------------------------------------------------------- /epicshop/in-browser-tests.spec.js: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'path' 2 | import { fileURLToPath } from 'url' 3 | import { setupInBrowserTests } from '@epic-web/workshop-utils/playwright.server' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | process.env.EPICSHOP_CONTEXT_CWD = resolve(__dirname, '..') 7 | 8 | setupInBrowserTests() 9 | -------------------------------------------------------------------------------- /epicshop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "test:setup": "playwright install chromium --with-deps", 5 | "test": "playwright test" 6 | }, 7 | "dependencies": { 8 | "@epic-web/config": "^1.16.3", 9 | "@epic-web/workshop-app": "^5.20.1", 10 | "@epic-web/workshop-utils": "^5.20.1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /epicshop/playwright.config.js: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import path from 'path' 3 | import { defineConfig, devices } from '@playwright/test' 4 | 5 | const PORT = process.env.PORT || '5639' 6 | const tmpDir = path.join( 7 | os.tmpdir(), 8 | 'epicshop-playwright', 9 | path.basename(new URL('../', import.meta.url).pathname), 10 | ) 11 | 12 | export default defineConfig({ 13 | outputDir: path.join(tmpDir, 'playwright-test-output'), 14 | reporter: [ 15 | [ 16 | 'html', 17 | { open: 'never', outputFolder: path.join(tmpDir, 'playwright-report') }, 18 | ], 19 | ], 20 | use: { 21 | baseURL: `http://localhost:${PORT}/`, 22 | trace: 'retain-on-failure', 23 | }, 24 | 25 | projects: [ 26 | { 27 | name: 'chromium', 28 | use: { ...devices['Desktop Chrome'] }, 29 | }, 30 | ], 31 | 32 | webServer: { 33 | command: 'cd .. && npm start', 34 | port: Number(PORT), 35 | reuseExistingServer: !process.env.CI, 36 | stdout: 'pipe', 37 | stderr: 'pipe', 38 | env: { PORT }, 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /epicshop/post-set-playground.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | 4 | fs.writeFileSync( 5 | path.join(process.env.EPICSHOP_PLAYGROUND_DEST_DIR, 'tsconfig.json'), 6 | JSON.stringify({ extends: '../tsconfig' }, null, 2), 7 | ) 8 | -------------------------------------------------------------------------------- /epicshop/setup-custom.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { 3 | getApps, 4 | isProblemApp, 5 | setPlayground, 6 | } from '@epic-web/workshop-utils/apps.server' 7 | import fsExtra from 'fs-extra' 8 | 9 | const allApps = await getApps() 10 | const problemApps = allApps.filter(isProblemApp) 11 | 12 | if (!process.env.SKIP_PLAYGROUND) { 13 | const firstProblemApp = problemApps[0] 14 | if (firstProblemApp) { 15 | console.log('🛝 setting up the first problem app...') 16 | const playgroundPath = path.join(process.cwd(), 'playground') 17 | if (await fsExtra.exists(playgroundPath)) { 18 | console.log('🗑 deleting existing playground app') 19 | await fsExtra.remove(playgroundPath) 20 | } 21 | await setPlayground(firstProblemApp.fullPath).then( 22 | () => { 23 | console.log('✅ first problem app set up') 24 | }, 25 | (error) => { 26 | console.error(error) 27 | throw new Error('❌ first problem app setup failed') 28 | }, 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /epicshop/setup.js: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process' 2 | 3 | const styles = { 4 | // got these from playing around with what I found from: 5 | // https://github.com/istanbuljs/istanbuljs/blob/0f328fd0896417ccb2085f4b7888dd8e167ba3fa/packages/istanbul-lib-report/lib/file-writer.js#L84-L96 6 | // they're the best I could find that works well for light or dark terminals 7 | success: { open: '\u001b[32;1m', close: '\u001b[0m' }, 8 | danger: { open: '\u001b[31;1m', close: '\u001b[0m' }, 9 | info: { open: '\u001b[36;1m', close: '\u001b[0m' }, 10 | subtitle: { open: '\u001b[2;1m', close: '\u001b[0m' }, 11 | } 12 | 13 | function color(modifier, string) { 14 | return styles[modifier].open + string + styles[modifier].close 15 | } 16 | 17 | console.log(color('info', '▶️ Starting workshop setup...')) 18 | 19 | const output = spawnSync('npm --version', { shell: true }) 20 | .stdout.toString() 21 | .trim() 22 | const outputParts = output.split('.') 23 | const major = Number(outputParts[0]) 24 | const minor = Number(outputParts[1]) 25 | if (major < 8 || (major === 8 && minor < 16)) { 26 | console.error( 27 | color( 28 | 'danger', 29 | '🚨 npm version is ' + 30 | output + 31 | ' which is out of date. Please install npm@8.16.0 or greater', 32 | ), 33 | ) 34 | throw new Error('npm version is out of date') 35 | } 36 | 37 | const command = 38 | 'npx --yes "https://gist.github.com/kentcdodds/bb452ffe53a5caa3600197e1d8005733" -q' 39 | console.log( 40 | color('subtitle', ' Running the following command: ' + command), 41 | ) 42 | 43 | const result = spawnSync(command, { stdio: 'inherit', shell: true }) 44 | 45 | if (result.status === 0) { 46 | console.log(color('success', '✅ Workshop setup complete...')) 47 | } else { 48 | process.exit(result.status) 49 | } 50 | 51 | /* 52 | eslint 53 | "no-undef": "off", 54 | "vars-on-top": "off", 55 | */ 56 | -------------------------------------------------------------------------------- /epicshop/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["@epic-web/config/reset.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"], 3 | "extends": ["@epic-web/config/typescript"] 4 | } 5 | -------------------------------------------------------------------------------- /epicshop/update-deps.sh: -------------------------------------------------------------------------------- 1 | npx npm-check-updates --dep prod,dev --upgrade --root 2 | cd epicshop && npx npm-check-updates --dep prod,dev --upgrade --root 3 | cd .. 4 | rm -rf node_modules package-lock.json ./epicshop/package-lock.json ./epicshop/node_modules ./exercises/**/node_modules 5 | npm install 6 | npm run setup 7 | npm run typecheck 8 | npm run lint --fix 9 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import defaultConfig from '@epic-web/config/eslint' 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | export default [ 5 | ...defaultConfig, 6 | { 7 | rules: { 8 | // we leave unused vars around for the exercises 9 | 'no-unused-vars': 'off', 10 | '@typescript-eslint/no-unused-vars': 'off', 11 | }, 12 | }, 13 | ] 14 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/01.problem.new-state/README.mdx: -------------------------------------------------------------------------------- 1 | # New state 2 | 3 | 4 | 5 | 👨‍💼 Our users want a counter component and it's working fine, but Kellie 🧝‍♂️ has 6 | said we can improve the implementation using `useReducer` (actually, 7 | `useReducer` is absolutely overkill for a counter component like ours, but we'll 8 | be using it to learn how to use it). 9 | 10 | The emoji will guide you through this! 11 | 12 | 📜 Here are some handy references: 13 | 14 | - [`useReducer` docs](https://react.dev/reference/react/useReducer) 15 | - [How to implement useState with useReducer](https://kentcdodds.com/blog/how-to-implement-usestate-with-usereducer) 16 | - [Should I useState or useReducer?](https://kentcdodds.com/blog/should-i-usestate-or-usereducer) 17 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/01.problem.new-state/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/01.problem.new-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | // 🐨 here's where you'll implement your countReducer function. 5 | 6 | function Counter({ initialCount = 0, step = 1 }) { 7 | // 🐨 replace useState with useReducer. 8 | // 💰 useReducer(countReducer, initialCount) 9 | const [count, setCount] = useState(initialCount) 10 | 11 | // 💰 you can write the countReducer function above so you don't have to make 12 | // any changes to the next two lines of code! Remember: 13 | // The 1st argument is called "state" - the current value of count 14 | // The 2nd argument is called "newState" - the value passed to setCount 15 | const increment = () => setCount(count + step) 16 | const decrement = () => setCount(count - step) 17 | return ( 18 |
19 | {count} 20 |
21 | 22 | 23 |
24 |
25 | ) 26 | } 27 | 28 | function App() { 29 | const [step, setStep] = useState(1) 30 | 31 | return ( 32 |
33 |

Counter:

34 |
35 |
36 | 37 | setStep(Number(e.currentTarget.value))} 42 | /> 43 |
44 |
45 | 46 |
47 | ) 48 | } 49 | 50 | const rootEl = document.createElement('div') 51 | document.body.append(rootEl) 52 | ReactDOM.createRoot(rootEl).render() 53 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/01.solution.new-state/README.mdx: -------------------------------------------------------------------------------- 1 | # New state 2 | 3 | 4 | 5 | 👨‍💼 Great first step. Let's keep iterating. 6 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/01.solution.new-state/counter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('The user can see the counter', async () => { 7 | const output = await screen.findByRole('status') 8 | expect(output).toHaveTextContent('0') 9 | }) 10 | 11 | const incrementButton = await testStep( 12 | 'The user can see the increment button', 13 | async () => { 14 | const result = await screen.findByRole('button', { name: '➡️' }) 15 | return result 16 | }, 17 | ) 18 | 19 | const decrementButton = await testStep( 20 | 'The user can see the decrement button', 21 | async () => { 22 | const result = await screen.findByRole('button', { name: '⬅️' }) 23 | return result 24 | }, 25 | ) 26 | 27 | await testStep('The user can increment the counter', async () => { 28 | fireEvent.click(incrementButton) 29 | const output = await screen.findByRole('status') 30 | expect(output).toHaveTextContent('1') 31 | }) 32 | 33 | await testStep('The user can decrement the counter', async () => { 34 | fireEvent.click(decrementButton) 35 | const output = await screen.findByRole('status') 36 | expect(output).toHaveTextContent('0') 37 | }) 38 | 39 | const stepInput = await testStep( 40 | 'The user can see the step input', 41 | async () => { 42 | const result = await screen.findByLabelText(/step/i) 43 | expect(result).toHaveValue(1) 44 | return result 45 | }, 46 | ) 47 | 48 | await testStep('The user can change the step value', async () => { 49 | fireEvent.change(stepInput, { target: { value: '5' } }) 50 | expect(stepInput).toHaveValue(5) 51 | }) 52 | 53 | await testStep( 54 | 'Changing step value affects increment and decrement', 55 | async () => { 56 | fireEvent.click(incrementButton) 57 | let output = await screen.findByRole('status') 58 | expect(output).toHaveTextContent('5') 59 | 60 | fireEvent.click(decrementButton) 61 | output = await screen.findByRole('status') 62 | expect(output).toHaveTextContent('0') 63 | }, 64 | ) 65 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/01.solution.new-state/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/01.solution.new-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | const countReducer = (state: unknown, newState: number) => newState 5 | 6 | function Counter({ initialCount = 0, step = 1 }) { 7 | const [count, setCount] = useReducer(countReducer, initialCount) 8 | const increment = () => setCount(count + step) 9 | const decrement = () => setCount(count - step) 10 | return ( 11 |
12 | {count} 13 |
14 | 15 | 16 |
17 |
18 | ) 19 | } 20 | 21 | function App() { 22 | const [step, setStep] = useState(1) 23 | 24 | return ( 25 |
26 |

Counter:

27 |
28 |
29 | 30 | setStep(Number(e.currentTarget.value))} 35 | /> 36 |
37 |
38 | 39 |
40 | ) 41 | } 42 | 43 | const rootEl = document.createElement('div') 44 | document.body.append(rootEl) 45 | ReactDOM.createRoot(rootEl).render() 46 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/02.problem.previous-state/README.mdx: -------------------------------------------------------------------------------- 1 | # Previous State 2 | 3 | 4 | 5 | 👨‍💼 We want to change things a bit to have this API: 6 | 7 | ```tsx 8 | const [count, changeCount] = useReducer(countReducer, initialCount) 9 | const increment = () => changeCount(step) 10 | const decrement = () => changeCount(-step) 11 | ``` 12 | 13 | How would you need to change your reducer to make this work? 14 | 15 | 🦉 This step is just to show that you can pass anything as the action. 16 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/02.problem.previous-state/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/02.problem.previous-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | // 🐨 rename the "state" variable "count" and the "newState" should be "change" 5 | // 🐨 then the function should return the sum of "count" and "change" 6 | const countReducer = (state: unknown, newState: number) => newState 7 | 8 | function Counter({ initialCount = 0, step = 1 }) { 9 | // 🐨 change the dispatch function "setCount" to "changeCount" here 10 | const [count, setCount] = useReducer(countReducer, initialCount) 11 | // 🐨 update these to simply pass the change we want to make to the state rather 12 | // than the new state itself. 13 | const increment = () => setCount(count + step) 14 | const decrement = () => setCount(count - step) 15 | return ( 16 |
17 | {count} 18 |
19 | 20 | 21 |
22 |
23 | ) 24 | } 25 | 26 | function App() { 27 | const [step, setStep] = useState(1) 28 | 29 | return ( 30 |
31 |

Counter:

32 |
33 |
34 | 35 | setStep(Number(e.currentTarget.value))} 40 | /> 41 |
42 |
43 | 44 |
45 | ) 46 | } 47 | 48 | const rootEl = document.createElement('div') 49 | document.body.append(rootEl) 50 | ReactDOM.createRoot(rootEl).render() 51 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/02.solution.previous-state/README.mdx: -------------------------------------------------------------------------------- 1 | # Previous State 2 | 3 | 4 | 5 | 👨‍💼 Great, now you should have a good handle on how calling the `dispatch` 6 | function with an argument sends that argument to your reducer. You have control! 7 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/02.solution.previous-state/counter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('The user can see the counter', async () => { 7 | const output = await screen.findByRole('status') 8 | expect(output).toHaveTextContent('0') 9 | }) 10 | 11 | const incrementButton = await testStep( 12 | 'The user can see the increment button', 13 | async () => { 14 | const result = await screen.findByRole('button', { name: '➡️' }) 15 | return result 16 | }, 17 | ) 18 | 19 | const decrementButton = await testStep( 20 | 'The user can see the decrement button', 21 | async () => { 22 | const result = await screen.findByRole('button', { name: '⬅️' }) 23 | return result 24 | }, 25 | ) 26 | 27 | await testStep('The user can increment the counter', async () => { 28 | fireEvent.click(incrementButton) 29 | const output = await screen.findByRole('status') 30 | expect(output).toHaveTextContent('1') 31 | }) 32 | 33 | await testStep('The user can decrement the counter', async () => { 34 | fireEvent.click(decrementButton) 35 | const output = await screen.findByRole('status') 36 | expect(output).toHaveTextContent('0') 37 | }) 38 | 39 | const stepInput = await testStep( 40 | 'The user can see the step input', 41 | async () => { 42 | const result = await screen.findByLabelText(/step/i) 43 | expect(result).toHaveValue(1) 44 | return result 45 | }, 46 | ) 47 | 48 | await testStep('The user can change the step value', async () => { 49 | fireEvent.change(stepInput, { target: { value: '5' } }) 50 | expect(stepInput).toHaveValue(5) 51 | }) 52 | 53 | await testStep( 54 | 'Changing step value affects increment and decrement', 55 | async () => { 56 | fireEvent.click(incrementButton) 57 | let output = await screen.findByRole('status') 58 | expect(output).toHaveTextContent('5') 59 | 60 | fireEvent.click(decrementButton) 61 | output = await screen.findByRole('status') 62 | expect(output).toHaveTextContent('0') 63 | }, 64 | ) 65 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/02.solution.previous-state/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/02.solution.previous-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | const countReducer = (count: number, change: number) => count + change 5 | 6 | function Counter({ initialCount = 0, step = 1 }) { 7 | const [count, changeCount] = useReducer(countReducer, initialCount) 8 | const increment = () => changeCount(step) 9 | const decrement = () => changeCount(-step) 10 | return ( 11 |
12 | {count} 13 |
14 | 15 | 16 |
17 |
18 | ) 19 | } 20 | 21 | function App() { 22 | const [step, setStep] = useState(1) 23 | 24 | return ( 25 |
26 |

Counter:

27 |
28 |
29 | 30 | setStep(Number(e.currentTarget.value))} 35 | /> 36 |
37 |
38 | 39 |
40 | ) 41 | } 42 | 43 | const rootEl = document.createElement('div') 44 | document.body.append(rootEl) 45 | ReactDOM.createRoot(rootEl).render() 46 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/03.problem.object/README.mdx: -------------------------------------------------------------------------------- 1 | # State Object 2 | 3 | 4 | 5 | 👨‍💼 Back in the day, we had `this.setState` from class components? We're going to 6 | make the state updater (`dispatch` function) behave in a similar way by changing 7 | our `state` to an object (`{count: 0}`) and then calling the state updater with 8 | an object which merges with the current state. 9 | 10 | So here's how I want things to look now: 11 | 12 | ```tsx 13 | const [state, setState] = useReducer(countReducer, { 14 | count: initialCount, 15 | }) 16 | const { count } = state 17 | const increment = () => setState({ count: count + step }) 18 | const decrement = () => setState({ count: count - step }) 19 | ``` 20 | 21 | How would you need to change the reducer to make this work? 22 | 23 | How would you make it support multiple state properties? For example: 24 | 25 | ```tsx 26 | const [state, setState] = useReducer(countReducer, { 27 | count: initialCount, 28 | someOtherState: 'hello', 29 | }) 30 | const { count } = state 31 | const increment = () => setState({ count: count + step }) 32 | const decrement = () => setState({ count: count - step }) 33 | ``` 34 | 35 | Calling `increment` or `decrement` in this case should only update the `count` 36 | property and leave the `someOtherState` property alone. So the `setState` 37 | function should merge the new state with the old state. 38 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/03.problem.object/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/03.problem.object/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | // 🦺 make a type called "State" which is an object with a count property as a number 5 | // 🦺 make a type called "Action" which is the same as the State type 6 | // 🐨 update this function to accept "state" (type "State") and an 7 | // "action" (type "Action") 8 | // 🐨 the function should merge properties from the state and the action and 9 | // return that new object 10 | const countReducer = (count: number, change: number) => count + change 11 | 12 | function Counter({ initialCount = 0, step = 1 }) { 13 | // 🐨 change this to "state" and "setState" and update the second argument 14 | // to be an object with a count property. 15 | const [count, changeCount] = useReducer(countReducer, initialCount) 16 | // 🐨 update these calls to call setState with an object and a count property 17 | const increment = () => changeCount(step) 18 | const decrement = () => changeCount(-step) 19 | return ( 20 |
21 | {count} 22 |
23 | 24 | 25 |
26 |
27 | ) 28 | } 29 | 30 | function App() { 31 | const [step, setStep] = useState(1) 32 | 33 | return ( 34 |
35 |

Counter:

36 |
37 |
38 | 39 | setStep(Number(e.currentTarget.value))} 44 | /> 45 |
46 |
47 | 48 |
49 | ) 50 | } 51 | 52 | const rootEl = document.createElement('div') 53 | document.body.append(rootEl) 54 | ReactDOM.createRoot(rootEl).render() 55 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/03.solution.object/README.mdx: -------------------------------------------------------------------------------- 1 | # State Object 2 | 3 | 4 | 5 | 👨‍💼 Great! Hopefully you're starting to get the hang of this API! 6 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/03.solution.object/counter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('The user can see the counter', async () => { 7 | const output = await screen.findByRole('status') 8 | expect(output).toHaveTextContent('0') 9 | }) 10 | 11 | const incrementButton = await testStep( 12 | 'The user can see the increment button', 13 | async () => { 14 | const result = await screen.findByRole('button', { name: '➡️' }) 15 | return result 16 | }, 17 | ) 18 | 19 | const decrementButton = await testStep( 20 | 'The user can see the decrement button', 21 | async () => { 22 | const result = await screen.findByRole('button', { name: '⬅️' }) 23 | return result 24 | }, 25 | ) 26 | 27 | await testStep('The user can increment the counter', async () => { 28 | fireEvent.click(incrementButton) 29 | const output = await screen.findByRole('status') 30 | expect(output).toHaveTextContent('1') 31 | }) 32 | 33 | await testStep('The user can decrement the counter', async () => { 34 | fireEvent.click(decrementButton) 35 | const output = await screen.findByRole('status') 36 | expect(output).toHaveTextContent('0') 37 | }) 38 | 39 | const stepInput = await testStep( 40 | 'The user can see the step input', 41 | async () => { 42 | const result = await screen.findByLabelText(/step/i) 43 | expect(result).toHaveValue(1) 44 | return result 45 | }, 46 | ) 47 | 48 | await testStep('The user can change the step value', async () => { 49 | fireEvent.change(stepInput, { target: { value: '5' } }) 50 | expect(stepInput).toHaveValue(5) 51 | }) 52 | 53 | await testStep( 54 | 'Changing step value affects increment and decrement', 55 | async () => { 56 | fireEvent.click(incrementButton) 57 | let output = await screen.findByRole('status') 58 | expect(output).toHaveTextContent('5') 59 | 60 | fireEvent.click(decrementButton) 61 | output = await screen.findByRole('status') 62 | expect(output).toHaveTextContent('0') 63 | }, 64 | ) 65 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/03.solution.object/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/03.solution.object/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | type State = { count: number } 5 | type Action = Partial 6 | const countReducer = (state: State, action: Action) => ({ ...state, ...action }) 7 | 8 | function Counter({ initialCount = 0, step = 1 }) { 9 | const [state, setState] = useReducer(countReducer, { 10 | count: initialCount, 11 | }) 12 | const { count } = state 13 | const increment = () => setState({ count: count + step }) 14 | const decrement = () => setState({ count: count - step }) 15 | return ( 16 |
17 | {count} 18 |
19 | 20 | 21 |
22 |
23 | ) 24 | } 25 | 26 | function App() { 27 | const [step, setStep] = useState(1) 28 | 29 | return ( 30 |
31 |

Counter:

32 |
33 |
34 | 35 | setStep(Number(e.currentTarget.value))} 40 | /> 41 |
42 |
43 | 44 |
45 | ) 46 | } 47 | 48 | const rootEl = document.createElement('div') 49 | document.body.append(rootEl) 50 | ReactDOM.createRoot(rootEl).render() 51 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/04.problem.function/README.mdx: -------------------------------------------------------------------------------- 1 | # Action Function 2 | 3 | 4 | 5 | 👨‍💼 `this.setState` from class components can also accept a function. So let's 6 | add support for that with our simulated `setState` function. See if you can 7 | figure out how to make your reducer support both the object as in the last step 8 | as well as a function callback: 9 | 10 | ```tsx 11 | const [state, setState] = useReducer(countReducer, { 12 | count: initialCount, 13 | }) 14 | const { count } = state 15 | const increment = () => 16 | setState((currentState) => ({ count: currentState.count + step })) 17 | const decrement = () => 18 | setState((currentState) => ({ count: currentState.count - step })) 19 | ``` 20 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/04.problem.function/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/04.problem.function/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | type State = { count: number } 5 | // 🦺 make it so the action can be a function which accepts State and returns Partial 6 | type Action = Partial 7 | const countReducer = (state: State, action: Action) => ({ 8 | ...state, 9 | // 🐨 if the action is a function, then call it with the state and spread the results, 10 | // otherwise, just spread the results (as it is now). 11 | ...action, 12 | }) 13 | 14 | function Counter({ initialCount = 0, step = 1 }) { 15 | const [state, setState] = useReducer(countReducer, { 16 | count: initialCount, 17 | }) 18 | const { count } = state 19 | // 🐨 update these calls to use the callback form. Use the currentState given 20 | // to you by the callback form of setState when calculating the new state. 21 | const increment = () => setState({ count: count + step }) 22 | const decrement = () => setState({ count: count - step }) 23 | return ( 24 |
25 | {count} 26 |
27 | 28 | 29 |
30 |
31 | ) 32 | } 33 | 34 | function App() { 35 | const [step, setStep] = useState(1) 36 | 37 | return ( 38 |
39 |

Counter:

40 |
41 |
42 | 43 | setStep(Number(e.currentTarget.value))} 48 | /> 49 |
50 |
51 | 52 |
53 | ) 54 | } 55 | 56 | const rootEl = document.createElement('div') 57 | document.body.append(rootEl) 58 | ReactDOM.createRoot(rootEl).render() 59 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/04.solution.function/README.mdx: -------------------------------------------------------------------------------- 1 | # Action Function 2 | 3 | 4 | 5 | 👨‍💼 Great work! You're doing awesome making this as dynamic as possible. 6 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/04.solution.function/counter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('The user can see the counter', async () => { 7 | const output = await screen.findByRole('status') 8 | expect(output).toHaveTextContent('0') 9 | }) 10 | 11 | const incrementButton = await testStep( 12 | 'The user can see the increment button', 13 | async () => { 14 | const result = await screen.findByRole('button', { name: '➡️' }) 15 | return result 16 | }, 17 | ) 18 | 19 | const decrementButton = await testStep( 20 | 'The user can see the decrement button', 21 | async () => { 22 | const result = await screen.findByRole('button', { name: '⬅️' }) 23 | return result 24 | }, 25 | ) 26 | 27 | await testStep('The user can increment the counter', async () => { 28 | fireEvent.click(incrementButton) 29 | const output = await screen.findByRole('status') 30 | expect(output).toHaveTextContent('1') 31 | }) 32 | 33 | await testStep('The user can decrement the counter', async () => { 34 | fireEvent.click(decrementButton) 35 | const output = await screen.findByRole('status') 36 | expect(output).toHaveTextContent('0') 37 | }) 38 | 39 | const stepInput = await testStep( 40 | 'The user can see the step input', 41 | async () => { 42 | const result = await screen.findByLabelText(/step/i) 43 | expect(result).toHaveValue(1) 44 | return result 45 | }, 46 | ) 47 | 48 | await testStep('The user can change the step value', async () => { 49 | fireEvent.change(stepInput, { target: { value: '5' } }) 50 | expect(stepInput).toHaveValue(5) 51 | }) 52 | 53 | await testStep( 54 | 'Changing step value affects increment and decrement', 55 | async () => { 56 | fireEvent.click(incrementButton) 57 | let output = await screen.findByRole('status') 58 | expect(output).toHaveTextContent('5') 59 | 60 | fireEvent.click(decrementButton) 61 | output = await screen.findByRole('status') 62 | expect(output).toHaveTextContent('0') 63 | }, 64 | ) 65 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/04.solution.function/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/04.solution.function/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | type State = { count: number } 5 | type Action = Partial | ((currentState: State) => Partial) 6 | const countReducer = (state: State, action: Action) => ({ 7 | ...state, 8 | ...(typeof action === 'function' ? action(state) : action), 9 | }) 10 | 11 | function Counter({ initialCount = 0, step = 1 }) { 12 | const [state, setState] = useReducer(countReducer, { 13 | count: initialCount, 14 | }) 15 | const { count } = state 16 | const increment = () => 17 | setState((currentState) => ({ count: currentState.count + step })) 18 | const decrement = () => 19 | setState((currentState) => ({ count: currentState.count - step })) 20 | return ( 21 |
22 | {count} 23 |
24 | 25 | 26 |
27 |
28 | ) 29 | } 30 | 31 | function App() { 32 | const [step, setStep] = useState(1) 33 | 34 | return ( 35 |
36 |

Counter:

37 |
38 |
39 | 40 | setStep(Number(e.currentTarget.value))} 45 | /> 46 |
47 |
48 | 49 |
50 | ) 51 | } 52 | 53 | const rootEl = document.createElement('div') 54 | document.body.append(rootEl) 55 | ReactDOM.createRoot(rootEl).render() 56 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/05.problem.traditional/README.mdx: -------------------------------------------------------------------------------- 1 | # Traditional Reducer 2 | 3 | 4 | 5 | 👨‍💼 Now it's time to get to the actual convention for reducers in React apps. 6 | 7 | Update your reducer so I can do this: 8 | 9 | ```tsx 10 | const [state, dispatch] = useReducer(countReducer, { 11 | count: initialCount, 12 | }) 13 | const { count } = state 14 | const increment = () => dispatch({ type: 'INCREMENT', step }) 15 | const decrement = () => dispatch({ type: 'DECREMENT', step }) 16 | ``` 17 | 18 | The key here is that the logic for updating the state is now in the reducer, and 19 | the component is just dispatching actions. This actually gives us a bit of a 20 | declarative API for updating state, which is nice. The component is just saying 21 | what it wants to happen, and the reducer is the one that decides how to make it 22 | happen. 23 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/05.problem.traditional/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/05.problem.traditional/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | type State = { count: number } 5 | // 🐨 make it so the action is one of two objects: 6 | // - a type string with the value 'increment' and a step number with the value of the step 7 | // - a type string with the value 'decrement' and a step number with the value of the step 8 | type Action = Partial | ((currentState: State) => Partial) 9 | // 🐨 update the countReducer to handle the new action type 10 | // 💯 handle situations where the action's type is neither increment nor decrement 11 | const countReducer = (state: State, action: Action) => ({ 12 | ...state, 13 | ...(typeof action === 'function' ? action(state) : action), 14 | }) 15 | 16 | function Counter({ initialCount = 0, step = 1 }) { 17 | // 🐨 rename "setState" to "dispatch" 18 | const [state, setState] = useReducer(countReducer, { 19 | count: initialCount, 20 | }) 21 | const { count } = state 22 | // 🐨 the logic has now been moved back to the reducer, update these to pass 23 | // the appropriate action object to the dispatch function 24 | const increment = () => 25 | setState((currentState) => ({ count: currentState.count + step })) 26 | const decrement = () => 27 | setState((currentState) => ({ count: currentState.count - step })) 28 | return ( 29 |
30 | {count} 31 |
32 | 33 | 34 |
35 |
36 | ) 37 | } 38 | 39 | function App() { 40 | const [step, setStep] = useState(1) 41 | 42 | return ( 43 |
44 |

Counter:

45 |
46 |
47 | 48 | setStep(Number(e.currentTarget.value))} 53 | /> 54 |
55 |
56 | 57 |
58 | ) 59 | } 60 | 61 | const rootEl = document.createElement('div') 62 | document.body.append(rootEl) 63 | ReactDOM.createRoot(rootEl).render() 64 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/05.solution.traditional/README.mdx: -------------------------------------------------------------------------------- 1 | # Traditional Reducer 2 | 3 | 4 | 5 | 👨‍💼 Great work! This is how we typically use the `useReducer` API conventionally 6 | and it's kinda nice for complex state management. 7 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/05.solution.traditional/counter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('The user can see the counter', async () => { 7 | const output = await screen.findByRole('status') 8 | expect(output).toHaveTextContent('0') 9 | }) 10 | 11 | const incrementButton = await testStep( 12 | 'The user can see the increment button', 13 | async () => { 14 | const result = await screen.findByRole('button', { name: '➡️' }) 15 | return result 16 | }, 17 | ) 18 | 19 | const decrementButton = await testStep( 20 | 'The user can see the decrement button', 21 | async () => { 22 | const result = await screen.findByRole('button', { name: '⬅️' }) 23 | return result 24 | }, 25 | ) 26 | 27 | await testStep('The user can increment the counter', async () => { 28 | fireEvent.click(incrementButton) 29 | const output = await screen.findByRole('status') 30 | expect(output).toHaveTextContent('1') 31 | }) 32 | 33 | await testStep('The user can decrement the counter', async () => { 34 | fireEvent.click(decrementButton) 35 | const output = await screen.findByRole('status') 36 | expect(output).toHaveTextContent('0') 37 | }) 38 | 39 | const stepInput = await testStep( 40 | 'The user can see the step input', 41 | async () => { 42 | const result = await screen.findByLabelText(/step/i) 43 | expect(result).toHaveValue(1) 44 | return result 45 | }, 46 | ) 47 | 48 | await testStep('The user can change the step value', async () => { 49 | fireEvent.change(stepInput, { target: { value: '5' } }) 50 | expect(stepInput).toHaveValue(5) 51 | }) 52 | 53 | await testStep( 54 | 'Changing step value affects increment and decrement', 55 | async () => { 56 | fireEvent.click(incrementButton) 57 | let output = await screen.findByRole('status') 58 | expect(output).toHaveTextContent('5') 59 | 60 | fireEvent.click(decrementButton) 61 | output = await screen.findByRole('status') 62 | expect(output).toHaveTextContent('0') 63 | }, 64 | ) 65 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/05.solution.traditional/index.css: -------------------------------------------------------------------------------- 1 | .app { 2 | padding: 16px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | flex-direction: column; 7 | 8 | form { 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | justify-content: center; 13 | flex-direction: column; 14 | div:has(label) { 15 | width: 100%; 16 | display: flex; 17 | gap: 8px; 18 | align-items: center; 19 | label { 20 | flex-basis: 50%; 21 | text-align: right; 22 | } 23 | input { 24 | flex: 1; 25 | padding: 8px; 26 | font-size: 1.25rem; 27 | max-width: 100px; 28 | } 29 | } 30 | button { 31 | padding: 8px; 32 | font-size: 1.25rem; 33 | } 34 | } 35 | 36 | .counter { 37 | padding: 16px; 38 | display: flex; 39 | gap: 8px; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | div:has(button) { 44 | display: flex; 45 | gap: 8px; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | button { 50 | border: none; 51 | background-color: transparent; 52 | padding: 8px; 53 | font-size: 1.25rem; 54 | } 55 | output { 56 | min-width: 3ch; 57 | text-align: center; 58 | font-variant-numeric: tabular-nums; 59 | font-size: 1.5rem; 60 | padding: 16px 32px; 61 | background-color: #f0f0f0; 62 | border-radius: 8px; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/05.solution.traditional/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | type State = { count: number } 5 | type Action = 6 | | { type: 'increment'; step: number } 7 | | { type: 'decrement'; step: number } 8 | function countReducer(state: State, action: Action) { 9 | const { type, step } = action 10 | switch (type) { 11 | case 'increment': { 12 | return { 13 | ...state, 14 | count: state.count + step, 15 | } 16 | } 17 | case 'decrement': { 18 | return { 19 | ...state, 20 | count: state.count - step, 21 | } 22 | } 23 | } 24 | } 25 | 26 | function Counter({ initialCount = 0, step = 1 }) { 27 | const [state, dispatch] = useReducer(countReducer, { 28 | count: initialCount, 29 | }) 30 | const { count } = state 31 | const increment = () => dispatch({ type: 'increment', step }) 32 | const decrement = () => dispatch({ type: 'decrement', step }) 33 | return ( 34 |
35 | {count} 36 |
37 | 38 | 39 |
40 |
41 | ) 42 | } 43 | 44 | function App() { 45 | const [step, setStep] = useState(1) 46 | 47 | return ( 48 |
49 |

Counter:

50 |
51 |
52 | 53 | setStep(Number(e.currentTarget.value))} 58 | /> 59 |
60 |
61 | 62 |
63 | ) 64 | } 65 | 66 | const rootEl = document.createElement('div') 67 | document.body.append(rootEl) 68 | ReactDOM.createRoot(rootEl).render() 69 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/06.problem.tic-tac-toe/README.mdx: -------------------------------------------------------------------------------- 1 | # Real World 2 | 3 | 4 | 5 | 👨‍💼 Let's try our hand at using `useReducer` for something a little more real. 6 | We'll be refactoring the `useState` out of our tic-tac-toe game to use 7 | `useReducer` instead. 8 | 9 | Your reducer should enable the following actions: 10 | 11 | ```tsx 12 | type GameAction = 13 | | { type: 'SELECT_SQUARE'; index: number } 14 | | { type: 'SELECT_STEP'; step: number } 15 | | { type: 'RESTART' } 16 | 17 | function gameReducer(state: GameState, action: GameAction) { 18 | // your code... 19 | } 20 | ``` 21 | 22 | Note that to do the lazy state initialization we need to provide three arguments 23 | to `useReducer`. Here's an example for a count reducer: 24 | 25 | ```tsx 26 | // ... 27 | function getInitialState(initialCount: number) { 28 | return { count: initialCount } 29 | } 30 | 31 | function Counter() { 32 | const [count, dispatch] = useReducer( 33 | countReducer, 34 | props.initialCount, 35 | getInitialState, 36 | ) 37 | // ... 38 | } 39 | ``` 40 | 41 | Notice that the `getInitialState` function is called only once, when the 42 | component is first rendered and it's called with the `initialCount` prop which 43 | is passed to the `useReducer` hook as the second argument. 44 | 45 | If you don't need an argument to your initial state callback, you can just pass 46 | `null`. 47 | 48 | Good luck! 49 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/06.problem.tic-tac-toe/index.css: -------------------------------------------------------------------------------- 1 | .game { 2 | font: 3 | 14px 'Century Gothic', 4 | Futura, 5 | sans-serif; 6 | margin: 20px; 7 | min-height: 260px; 8 | } 9 | 10 | .game ol, 11 | .game ul { 12 | padding-left: 30px; 13 | } 14 | 15 | .board-row:after { 16 | clear: both; 17 | content: ''; 18 | display: table; 19 | } 20 | 21 | .status { 22 | margin-bottom: 10px; 23 | } 24 | 25 | .restart { 26 | margin-top: 10px; 27 | } 28 | 29 | .square { 30 | background: #fff; 31 | border: 1px solid #999; 32 | float: left; 33 | font-size: 24px; 34 | font-weight: bold; 35 | line-height: 34px; 36 | height: 34px; 37 | margin-right: -1px; 38 | margin-top: -1px; 39 | padding: 0; 40 | text-align: center; 41 | width: 34px; 42 | } 43 | 44 | .square:focus { 45 | outline: none; 46 | background: #ddd; 47 | } 48 | 49 | .game { 50 | display: flex; 51 | flex-direction: row; 52 | } 53 | 54 | .game-info { 55 | margin-left: 20px; 56 | min-width: 190px; 57 | } 58 | 59 | [aria-disabled='true'] { 60 | opacity: 0.6; 61 | pointer-events: none; 62 | user-select: none; 63 | } 64 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/06.solution.tic-tac-toe/README.mdx: -------------------------------------------------------------------------------- 1 | # Real World 2 | 3 | 4 | 5 | 👨‍💼 Great! The game functions the same, but the state changes are encapsulated in 6 | the reducer which makes it easier to manage and test. 7 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/06.solution.tic-tac-toe/index.css: -------------------------------------------------------------------------------- 1 | .game { 2 | font: 3 | 14px 'Century Gothic', 4 | Futura, 5 | sans-serif; 6 | margin: 20px; 7 | min-height: 260px; 8 | } 9 | 10 | .game ol, 11 | .game ul { 12 | padding-left: 30px; 13 | } 14 | 15 | .board-row:after { 16 | clear: both; 17 | content: ''; 18 | display: table; 19 | } 20 | 21 | .status { 22 | margin-bottom: 10px; 23 | } 24 | 25 | .restart { 26 | margin-top: 10px; 27 | } 28 | 29 | .square { 30 | background: #fff; 31 | border: 1px solid #999; 32 | float: left; 33 | font-size: 24px; 34 | font-weight: bold; 35 | line-height: 34px; 36 | height: 34px; 37 | margin-right: -1px; 38 | margin-top: -1px; 39 | padding: 0; 40 | text-align: center; 41 | width: 34px; 42 | } 43 | 44 | .square:focus { 45 | outline: none; 46 | background: #ddd; 47 | } 48 | 49 | .game { 50 | display: flex; 51 | flex-direction: row; 52 | } 53 | 54 | .game-info { 55 | margin-left: 20px; 56 | min-width: 190px; 57 | } 58 | 59 | [aria-disabled='true'] { 60 | opacity: 0.6; 61 | pointer-events: none; 62 | user-select: none; 63 | } 64 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Advanced State Management 2 | 3 | 4 | 5 | 👨‍💼 Great work! You've now explored the `useReducer` API pretty well and you 6 | should feel comfortable using it going forward. 7 | -------------------------------------------------------------------------------- /exercises/01.use-reducer/README.mdx: -------------------------------------------------------------------------------- 1 | # Advanced State Management 2 | 3 | 4 | 5 | React's `useState` hook can get you a really long way with React state 6 | management. That said, sometimes you want to separate the state logic from the 7 | components that make the state changes. In addition, if you have multiple 8 | elements of state that typically change together, then having an object that 9 | contains those elements of state can be quite helpful. 10 | 11 | This is where `useReducer` comes in really handy. 12 | 13 | This exercise will take you pretty deep into `useReducer`. Typically, you'll use 14 | `useReducer` with an object of state, but we're going to start by managing a 15 | single number (a `count`). We're doing this to ease you into `useReducer` and 16 | help you learn the difference between the convention and the actual API. 17 | 18 | Here's an example of using `useReducer` to manage the value of a name in an 19 | input. 20 | 21 | ```tsx 22 | function nameReducer(previousName: string, newName: string) { 23 | return newName 24 | } 25 | 26 | const initialNameValue = 'Joe' 27 | 28 | function NameInput() { 29 | const [name, setName] = useReducer(nameReducer, initialNameValue) 30 | const handleChange = (event) => setName(event.currentTarget.value) 31 | return ( 32 |
33 | 36 |
You typed: {name}
37 |
38 | ) 39 | } 40 | ``` 41 | 42 | One important thing to note here is that the reducer (called `nameReducer` 43 | above) is called with two arguments: 44 | 45 | 1. the current state 46 | 2. whatever it is that the dispatch function (called `setName` above) is called 47 | with. This is often called an "action." 48 | 49 | 📜 Here are two really helpful blog posts comparing `useState` and `useReducer`: 50 | 51 | - [Should I useState or useReducer?](https://kentcdodds.com/blog/should-i-usestate-or-usereducer) 52 | - [How to implement useState with useReducer](https://kentcdodds.com/blog/how-to-implement-usestate-with-usereducer) 53 | -------------------------------------------------------------------------------- /exercises/02.state-optimization/01.problem.optimize/README.mdx: -------------------------------------------------------------------------------- 1 | # Optimize state updates 2 | 3 | 4 | 5 | {/* prettier-ignore */} 6 | 7 | We're going to be working with the URL, so you'll want to pull this one up in 8 | 9 | a separate tab. 10 | 11 | 12 | 👨‍💼 We're bringing back our search and card page. Now we are storing the entire 13 | `URLSearchParams` in state (not just the `query` param) and we want to make sure 14 | we don't rerender the page if the params are unchanged. 15 | 16 | If you want to test this out, you'll notice we have added a `console.log` in the 17 | function body for the `App` component so you can know each time the component 18 | rerenders, and also put one in the `setSearchParams` callback so you know 19 | each time we call `setSearchParams`. Submit the form multiple times observe the 20 | logs. Alternate between changing the search params and not changing them. 21 | 22 | When you're all finished, it should log whenever you set the search params, but 23 | it should not log the rerender when you submit the form without changing the 24 | query. 25 | -------------------------------------------------------------------------------- /exercises/02.state-optimization/01.problem.optimize/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/02.state-optimization/01.solution.optimize/README.mdx: -------------------------------------------------------------------------------- 1 | # Optimize state updates 2 | 3 | 4 | 5 | 👨‍💼 Great! It's nice to know that with a simple check we can avoid triggering an 6 | unnecessary state update and improve performance a bit. 7 | -------------------------------------------------------------------------------- /exercises/02.state-optimization/01.solution.optimize/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/02.state-optimization/01.solution.optimize/optimize.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent, waitFor } = dtl 3 | 4 | import './index.tsx' 5 | 6 | const originalConsoleLog = console.log 7 | let consoleLogCalls: string[] = [] 8 | 9 | // Setup 10 | console.log = (...args: any[]) => { 11 | consoleLogCalls.push(args.join(' ')) 12 | originalConsoleLog(...args) 13 | } 14 | 15 | await testStep('The app renders initially', async () => { 16 | await screen.findByRole('searchbox', { name: /search/i }) 17 | }) 18 | 19 | const submitButton = await testStep( 20 | 'The submit button is visible', 21 | async () => { 22 | return screen.findByRole('button', { name: /submit/i }) 23 | }, 24 | ) 25 | 26 | await testStep( 27 | 'Submitting without changes does not trigger a re-render', 28 | async () => { 29 | consoleLogCalls = [] // Reset log calls 30 | const initialRenderCount = consoleLogCalls.filter((log) => 31 | log.includes('rerendering component for new query'), 32 | ).length 33 | 34 | fireEvent.click(submitButton) 35 | 36 | await new Promise((resolve) => setTimeout(resolve, 50)) 37 | 38 | const newRenderCount = consoleLogCalls.filter((log) => 39 | log.includes('rerendering component for new query'), 40 | ).length 41 | expect(newRenderCount).toBe(initialRenderCount) 42 | 43 | expect(consoleLogCalls).toContain('setting search params') 44 | }, 45 | ) 46 | 47 | await testStep( 48 | 'Changing the query and submitting triggers a re-render', 49 | async () => { 50 | consoleLogCalls = [] // Reset log calls 51 | const searchInput = screen.getByRole('searchbox', { name: /search/i }) 52 | const initialRenderCount = consoleLogCalls.filter((log) => 53 | log.includes('rerendering component for new query'), 54 | ).length 55 | 56 | fireEvent.change(searchInput, { target: { value: 'new query' } }) 57 | fireEvent.click(submitButton) 58 | 59 | await waitFor(() => { 60 | const newRenderCount = consoleLogCalls.filter((log) => 61 | log.includes('rerendering component for new query'), 62 | ).length 63 | expect(newRenderCount).toBe(initialRenderCount + 1) 64 | }) 65 | 66 | expect(consoleLogCalls).toContain('setting search params') 67 | expect(consoleLogCalls[consoleLogCalls.length - 1]).toBe( 68 | 'rerendering component for new query new query', 69 | ) 70 | }, 71 | ) 72 | 73 | // Teardown 74 | console.log = originalConsoleLog 75 | -------------------------------------------------------------------------------- /exercises/02.state-optimization/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # State Optimization 2 | 3 | 4 | 5 | 👨‍💼 Great! Now you know how to prevent unnecessary state changes that trigger 6 | rerenders. Good work. 7 | 8 | 🦉 Normally you don't want to add complexity to your code to improve performance 9 | unless you have measured before/after to make certain that the change is actually 10 | worth it. In this case, the performance improvement is likely to be negligible 11 | unless you have a very large number of state updates happening in a short period 12 | of time. But the added complexity is probably about as negligible as the 13 | performance improvement, so either way it's a wash. 14 | -------------------------------------------------------------------------------- /exercises/02.state-optimization/README.mdx: -------------------------------------------------------------------------------- 1 | # State Optimization 2 | 3 | 4 | 5 | If you set state to the exact value it already is set to, React will not bother 6 | triggering a re-render of your components because it knows nothing has changed. 7 | 8 | ```tsx 9 | const [count, setCount] = useState(0) 10 | 11 | // ... 12 | setCount(0) // <-- will not trigger a rerender if the state is still 0 13 | ``` 14 | 15 | With objects, this is a little more tricky because you need to make certain you 16 | return the exact same object: 17 | 18 | ```tsx 19 | const [state, setState] = useState({ count: 0 }) 20 | 21 | // ... 22 | setState({ count: 0 }) // <-- will trigger a rerender 23 | setState((previousState) => ({ 24 | count: previousState.count, 25 | })) // <-- will trigger a rerender 26 | setState((previousState) => previousState) // <-- will not trigger a rerender 27 | ``` 28 | 29 | So, with a little forethought, you can optimize your state updates by 30 | determining yourself whether state has changed and returning the original state 31 | if it has not. This applies both in a reducer for `useReducer` as well as the 32 | callback for `useState`. 33 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/01.problem.function/README.mdx: -------------------------------------------------------------------------------- 1 | # Hook Function 2 | 3 | 4 | 5 | 👨‍💼 We now want to create a reusable `useSearchParams` function which will handle 6 | the search params for us generally so we can use that logic in other components. 7 | 8 | 9 | There's a much more complete version of this hook as a part of 10 | [react-router](https://reactrouter.com/en/main/hooks/use-search-params) which 11 | you'll likely want to use in a real application. This is just a simple 12 | example. 13 | 14 | 15 | So your job is to take the logic from the `App` component that relates to the 16 | search params and put it in a new function called `useSearchParams`, then you'll 17 | use that function in the `App` component. 18 | 19 | ```tsx 20 | const [searchParams, setSearchParams] = useSearchParams() 21 | ``` 22 | 23 | For the types of that tuple, you may find this article helpful: 24 | [Wrapping React.useState with TypeScript](https://kentcdodds.com/blog/wrapping-react-use-state-with-type-script) 25 | 26 | 27 | There are actually not a lot of lines changed in this step of the exercise, 28 | but we're going to be bringing in `useCallback` in the next step so keep 29 | going! 30 | 31 | 32 | 33 | 🚨 this is just a refactor so the tests are passing already, you just need to 34 | make sure they don't fail! 35 | 36 | 37 | Good luck! 38 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/01.problem.function/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/01.solution.function/README.mdx: -------------------------------------------------------------------------------- 1 | # Hook Function 2 | 3 | 4 | 5 | 👨‍💼 Great job! You've made a custom hook! 6 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/01.solution.function/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/01.solution.function/search-params.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | window.history.pushState({}, '', '?query=dog') 5 | 6 | await import('./index.tsx') 7 | 8 | await testStep( 9 | 'The search box is initialized with URL query parameter', 10 | async () => { 11 | const searchBox = await screen.findByRole('searchbox', { name: /search/i }) 12 | expect(searchBox).toHaveValue('dog') 13 | }, 14 | ) 15 | 16 | await testStep( 17 | 'Updating the search box updates the URL search params', 18 | async () => { 19 | const searchBox = screen.getByRole('searchbox', { name: /search/i }) 20 | fireEvent.change(searchBox, { target: { value: 'cat' } }) 21 | 22 | expect(searchBox).toHaveValue('cat') 23 | expect(window.location.search).toBe('?query=cat') 24 | }, 25 | ) 26 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/02.problem.callback/README.mdx: -------------------------------------------------------------------------------- 1 | # useCallback 2 | 3 | 4 | 5 | 👨‍💼 We only call the `setSearchParams` function inside event handlers, so we 6 | don't have any problems, but we're making a reusable hook and we want to make 7 | certain people don't have problems if they need to use it in a `useEffect` or 8 | other hook that requires a dependency array. For example: 9 | 10 | ```tsx 11 | const [searchParams, setSearchParams] = useSearchParams() 12 | 13 | useEffect(() => { 14 | if (someCondition) { 15 | setSearchParams({ foo: 'bar' }) 16 | } 17 | }, [setSearchParams, someCondition]) 18 | ``` 19 | 20 | So I want you to wrap our `setSearchParams` function in `useCallback` to memoize 21 | it and avoid issues with the dependency array. 22 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/02.problem.callback/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/02.solution.callback/README.mdx: -------------------------------------------------------------------------------- 1 | # useCallback 2 | 3 | 4 | 5 | 👨‍💼 Great job! Now we have a pretty good reusable hook for anyone who wants to 6 | control the search params! 7 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/02.solution.callback/callback.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | import { useReducer } from 'react' 3 | import { createRoot } from 'react-dom/client' 4 | import { useSearchParams } from './index' 5 | 6 | let setSearchParams: ReturnType[1] 7 | let rerender: () => void 8 | function TestComponent() { 9 | const reducerTuple = useReducer((state) => state + 1, 0) 10 | const searchParamsTuple = useSearchParams() 11 | setSearchParams = searchParamsTuple[1] 12 | rerender = reducerTuple[1] 13 | return null 14 | } 15 | 16 | await testStep('setSearchParams is memoized', async () => { 17 | const container = document.createElement('div') 18 | const root = createRoot(container) 19 | root.render() 20 | await new Promise((resolve) => setTimeout(resolve, 100)) 21 | const firstSetSearchParams = setSearchParams 22 | rerender() 23 | await new Promise((resolve) => setTimeout(resolve, 100)) 24 | expect(firstSetSearchParams).toBe(setSearchParams) 25 | }) 26 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/02.solution.callback/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/02.solution.callback/search-params.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | window.history.pushState({}, '', '?query=dog') 5 | 6 | await import('./index.tsx') 7 | 8 | await testStep( 9 | 'The search box is initialized with URL query parameter', 10 | async () => { 11 | const searchBox = await screen.findByRole('searchbox', { name: /search/i }) 12 | expect(searchBox).toHaveValue('dog') 13 | }, 14 | ) 15 | 16 | await testStep( 17 | 'Updating the search box updates the URL search params', 18 | async () => { 19 | const searchBox = screen.getByRole('searchbox', { name: /search/i }) 20 | fireEvent.change(searchBox, { target: { value: 'cat' } }) 21 | 22 | expect(searchBox).toHaveValue('cat') 23 | expect(window.location.search).toBe('?query=cat') 24 | }, 25 | ) 26 | -------------------------------------------------------------------------------- /exercises/03.custom-hooks/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Custom Hooks 2 | 3 | 4 | 5 | 👨‍💼 Great job! 6 | 7 | 🦉 Remember, 8 | 9 | > [Custom hooks are functions that use other hooks.](https://twitter.com/kentcdodds/status/1763633880349987151) 10 | 11 | That's the only technical requirement. The `use` prefix is a useful convention, 12 | but they're just functions. 13 | -------------------------------------------------------------------------------- /exercises/04.context/01.problem.provider/README.mdx: -------------------------------------------------------------------------------- 1 | # Context Provider 2 | 3 | 4 | 5 | 🧝‍♂️ I was refactoring things a bit and decided we 6 | shouldn't need to pass the `query` and `setSearchParams` as props. Instead I 7 | just wanted to call `useSearchParams` in each of the components that need them 8 | (there's only one URL afterall). 9 | 10 | Well that didn't work. Turns out we now have multiple instances of the search 11 | params state and multiple subscriptions to the `popstate` event. That's not 12 | what we want at all. It's busted right now. Can you fix this with context? 13 | 14 | 👨‍💼 No worries Kellie. We can fix this. 15 | 16 | So we want to create a context provider that will provide the `useSearchParams` 17 | hook to the rest of the app. 18 | 19 | We're going to take this in steps though. First we'll create the context 20 | provider, render it around the app, and then make a new `useSearchParams` hook 21 | that will use the context value. 22 | 23 | Good luck! When you're finished, the app should be working again! 24 | -------------------------------------------------------------------------------- /exercises/04.context/01.problem.provider/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/04.context/01.solution.provider/README.mdx: -------------------------------------------------------------------------------- 1 | # Context Provider 2 | 3 | 4 | 5 | 👨‍💼 Great! Now we've got a context provider so all the components can reference 6 | the same state for our search params. Next let's improve this more by making 7 | a hook for it and dealing with an issue with the default value. 8 | -------------------------------------------------------------------------------- /exercises/04.context/01.solution.provider/filtering.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | const searchBox = await testStep( 7 | 'The user can see the search box', 8 | async () => { 9 | const result = await screen.findByRole('searchbox', { name: /search/i }) 10 | expect(result).toHaveValue('') 11 | return result 12 | }, 13 | ) 14 | 15 | const catResult = await testStep('The user can see the results', async () => { 16 | const result = screen.getByText(/caring for your feline friend/i) 17 | expect(result).toBeInTheDocument() 18 | return result 19 | }) 20 | 21 | await testStep('The user can search for a term', async () => { 22 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 23 | }) 24 | 25 | await testStep('The results are filtered', async () => { 26 | await dtl.waitFor(() => { 27 | expect(catResult).not.toBeInTheDocument() 28 | }) 29 | await screen.findByText(/the joy of owning a dog/i) 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/04.context/01.solution.provider/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/04.context/02.problem.hook/README.mdx: -------------------------------------------------------------------------------- 1 | # Context Hook 2 | 3 | 4 | 5 | 🧝‍♂️ I was playing around with the app and I realized that we have a default value 6 | for our context provider. So I thought maybe we 7 | could remove the context provider. Unfortunately 8 | that didn't work. Do you know why? 9 | 10 | 👨‍💼 Yeah, it's because even though the `searchParams` are shared, they're not 11 | updated when calling `setSearchParams`. This is because the `searchParams` 12 | object is no longer state. It's just a plain object. So when we call 13 | `setSearchParams`, nothing rerenders (and even if it did, it wouldn't have 14 | the updated `searchParams`). 15 | 16 | So having a default in our context is pretty meaningless. So why don't we 17 | default it to `null` and then in our `useSearchParams()` hook we can throw an 18 | error if the context is `null`? Go ahead and give it a try. 19 | 20 | When you're done, the app should be working again. 21 | -------------------------------------------------------------------------------- /exercises/04.context/02.problem.hook/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/04.context/02.solution.hook/README.mdx: -------------------------------------------------------------------------------- 1 | # Context Hook 2 | 3 | 4 | 5 | 👨‍💼 Great job! 6 | 7 | 🦉 There are definitely cases where a default value for context makes sense, but 8 | often it does not. You should normally follow the pattern we've done here when 9 | you're using context. 10 | -------------------------------------------------------------------------------- /exercises/04.context/02.solution.hook/filtering.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | const searchBox = await testStep( 7 | 'The user can see the search box', 8 | async () => { 9 | const result = await screen.findByRole('searchbox', { name: /search/i }) 10 | expect(result).toHaveValue('') 11 | return result 12 | }, 13 | ) 14 | 15 | const catResult = await testStep('The user can see the results', async () => { 16 | const result = screen.getByText(/caring for your feline friend/i) 17 | expect(result).toBeInTheDocument() 18 | return result 19 | }) 20 | 21 | await testStep('The user can search for a term', async () => { 22 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 23 | }) 24 | 25 | await testStep('The results are filtered', async () => { 26 | await dtl.waitFor(() => { 27 | expect(catResult).not.toBeInTheDocument() 28 | }) 29 | await screen.findByText(/the joy of owning a dog/i) 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/04.context/02.solution.hook/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exercises/04.context/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Shared Context 2 | 3 | 4 | 5 | 👨‍💼 Great job! You now know how to properly use context to share state across 6 | parts of the application. This should serve you well. 7 | -------------------------------------------------------------------------------- /exercises/05.portals/01.problem.create/README.mdx: -------------------------------------------------------------------------------- 1 | # createPortal 2 | 3 | 4 | 5 | 👨‍💼 Some of our users find the heart icon to be unclear and would like to have a 6 | tooltip that explains what it's for. 7 | 8 | 🧝‍♂️ I've moved things around a little bit to reduce 9 | the amount of code you need to work in. I've also added a simple tooltip 10 | component that's not working quite yet. The positioning is all funny because the 11 | tooltip is being rendered within the context of the card instead of at the root 12 | of the document. 13 | 14 | 👨‍💼 Thanks Kellie. Now, let's see if you can make the tooltip component work 15 | properly with a portal. 16 | 17 | Note, the change you're making is pretty minimal. You'll also need to change 18 | some of the CSS to make the tooltip look a little better. 19 | 20 | Additionally, check the Elements panel in the DevTools to see where the tooltip 21 | is being rendered (next to the button vs `document.body`). 22 | 23 | 🦉 Don't forget about the "Files" button in the bottom of this screen. It will 24 | show you which files are changed in this exercise step and allow you to click 25 | to open the relevant files. 26 | 27 | 📜 Parts of this exercise was lifted from [the React docs](https://react.dev/reference/react/useLayoutEffect#measuring-layout-before-the-browser-repaints-the-screen) 28 | 29 | 30 | Typically you'll want to use a library for tooltips which have been tested for 31 | accessibility best practices. But they're likely using `createPortal` under 32 | the hood and you'll very likely find yourself needing to use this API at some 33 | point. 34 | 35 | -------------------------------------------------------------------------------- /exercises/05.portals/01.problem.create/form.tsx: -------------------------------------------------------------------------------- 1 | import { getQueryParam, useSearchParams } from './params' 2 | 3 | export function Form() { 4 | const [searchParams, setSearchParams] = useSearchParams() 5 | const query = getQueryParam(searchParams) 6 | 7 | const words = query.split(' ').map((w) => w.trim()) 8 | 9 | const dogChecked = words.includes('dog') 10 | const catChecked = words.includes('cat') 11 | const caterpillarChecked = words.includes('caterpillar') 12 | 13 | function handleCheck(tag: string, checked: boolean) { 14 | const newWords = checked ? [...words, tag] : words.filter((w) => w !== tag) 15 | setSearchParams( 16 | { query: newWords.filter(Boolean).join(' ').trim() }, 17 | { replace: true }, 18 | ) 19 | } 20 | 21 | return ( 22 |
e.preventDefault()}> 23 |
24 | 25 | 31 | setSearchParams({ query: e.currentTarget.value }, { replace: true }) 32 | } 33 | /> 34 |
35 |
36 | 44 | 52 | 62 |
63 |
64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /exercises/05.portals/01.problem.create/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | /* 🦉 we disable this for now so we don't hide the tooltip issue */ 25 | /* 🐨 bring this back once you've created the portal */ 26 | /* overflow: hidden; */ 27 | border: 1px solid #ddd; 28 | width: 320px; 29 | transition: transform 0.2s ease-in-out; 30 | a { 31 | text-decoration: none; 32 | color: unset; 33 | } 34 | 35 | &:hover, 36 | &:has(*:focus), 37 | &:has(*:active) { 38 | /* 🦉 we disable this for now so we don't hide the tooltip issue */ 39 | /* 🐨 bring this back once you've created the portal */ 40 | /* transform: translate(0px, -6px); */ 41 | } 42 | 43 | .post-image { 44 | display: block; 45 | width: 100%; 46 | height: 200px; 47 | } 48 | 49 | button { 50 | position: absolute; 51 | font-size: 1.5rem; 52 | top: 20px; 53 | right: 20px; 54 | background: transparent; 55 | border: none; 56 | outline: none; 57 | &:hover, 58 | &:focus, 59 | &:active { 60 | animation: pulse 1.5s infinite; 61 | } 62 | } 63 | 64 | a { 65 | padding: 10px 10px; 66 | display: flex; 67 | gap: 8px; 68 | flex-direction: column; 69 | h2 { 70 | margin: 0; 71 | font-size: 1.5rem; 72 | font-weight: bold; 73 | } 74 | p { 75 | margin: 0; 76 | font-size: 1rem; 77 | color: #666; 78 | } 79 | } 80 | } 81 | } 82 | 83 | @keyframes pulse { 84 | 0% { 85 | transform: scale(1); 86 | } 87 | 50% { 88 | transform: scale(1.3); 89 | } 90 | 100% { 91 | transform: scale(1); 92 | } 93 | } 94 | 95 | .tooltip-container { 96 | position: absolute; 97 | pointer-events: none; 98 | left: 0; 99 | top: 0; 100 | transform: translate3d(var(--x), var(--y), 0); 101 | z-index: 10; 102 | } 103 | 104 | .tooltip { 105 | color: white; 106 | background: #222; 107 | border-radius: 4px; 108 | padding: 4px; 109 | } 110 | -------------------------------------------------------------------------------- /exercises/05.portals/01.problem.create/index.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactDOM from 'react-dom/client' 2 | import { Form } from './form' 3 | import { SearchParamsProvider } from './params' 4 | import { MatchingPosts } from './posts' 5 | 6 | export function App() { 7 | return ( 8 | 9 |
10 |
11 | 12 |
13 |
14 | ) 15 | } 16 | 17 | const rootEl = document.createElement('div') 18 | document.body.append(rootEl) 19 | ReactDOM.createRoot(rootEl).render() 20 | -------------------------------------------------------------------------------- /exercises/05.portals/01.problem.create/params.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useCallback, use, useEffect, useState } from 'react' 2 | import { setGlobalSearchParams } from '#shared/utils' 3 | 4 | type SearchParamsTuple = readonly [ 5 | URLSearchParams, 6 | typeof setGlobalSearchParams, 7 | ] 8 | const SearchParamsContext = createContext(null) 9 | 10 | export function SearchParamsProvider({ 11 | children, 12 | }: { 13 | children: React.ReactNode 14 | }) { 15 | const [searchParams, setSearchParamsState] = useState( 16 | () => new URLSearchParams(window.location.search), 17 | ) 18 | 19 | useEffect(() => { 20 | function updateSearchParams() { 21 | setSearchParamsState((prevParams) => { 22 | const newParams = new URLSearchParams(window.location.search) 23 | return prevParams.toString() === newParams.toString() 24 | ? prevParams 25 | : newParams 26 | }) 27 | } 28 | window.addEventListener('popstate', updateSearchParams) 29 | return () => window.removeEventListener('popstate', updateSearchParams) 30 | }, []) 31 | 32 | const setSearchParams = useCallback( 33 | (...args: Parameters) => { 34 | const searchParams = setGlobalSearchParams(...args) 35 | setSearchParamsState((prevParams) => { 36 | return prevParams.toString() === searchParams.toString() 37 | ? prevParams 38 | : searchParams 39 | }) 40 | return searchParams 41 | }, 42 | [], 43 | ) 44 | 45 | const searchParamsTuple = [searchParams, setSearchParams] as const 46 | 47 | return ( 48 | 49 | {children} 50 | 51 | ) 52 | } 53 | 54 | export function useSearchParams() { 55 | const context = use(SearchParamsContext) 56 | if (!context) { 57 | throw new Error( 58 | 'useSearchParams must be used within a SearchParamsProvider', 59 | ) 60 | } 61 | return context 62 | } 63 | 64 | export const getQueryParam = (params: URLSearchParams) => 65 | params.get('query') ?? '' 66 | -------------------------------------------------------------------------------- /exercises/05.portals/01.problem.create/posts.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { 3 | type BlogPost, 4 | generateGradient, 5 | getMatchingPosts, 6 | } from '#shared/blog-posts' 7 | import { getQueryParam, useSearchParams } from './params' 8 | import { ButtonWithTooltip } from './tooltip' 9 | 10 | export function MatchingPosts() { 11 | const [searchParams] = useSearchParams() 12 | const query = getQueryParam(searchParams) 13 | const matchingPosts = getMatchingPosts(query) 14 | 15 | return ( 16 |
    17 | {matchingPosts.map((post) => ( 18 | 19 | ))} 20 |
21 | ) 22 | } 23 | 24 | function Card({ post }: { post: BlogPost }) { 25 | const [isFavorited, setIsFavorited] = useState(false) 26 | return ( 27 |
  • 28 | {isFavorited ? ( 29 | setIsFavorited(false)} 32 | > 33 | ❤️ 34 | 35 | ) : ( 36 | setIsFavorited(true)} 39 | > 40 | 🤍 41 | 42 | )} 43 |
  • 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /exercises/05.portals/01.problem.create/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | 3 | type Position = { 4 | left: number 5 | top: number 6 | right: number 7 | bottom: number 8 | } 9 | 10 | export default function Tooltip({ 11 | children, 12 | targetRect, 13 | }: { 14 | children: React.ReactNode 15 | targetRect: Position | null 16 | }) { 17 | const ref = useRef(null) 18 | const [tooltipHeight, setTooltipHeight] = useState(0) 19 | 20 | useEffect(() => { 21 | const rect = ref.current?.getBoundingClientRect() 22 | if (!rect) return 23 | const { height } = rect 24 | setTooltipHeight(height) 25 | }, []) 26 | 27 | let tooltipX = 0 28 | let tooltipY = 0 29 | if (targetRect !== null) { 30 | tooltipX = targetRect.left 31 | tooltipY = targetRect.top - tooltipHeight 32 | if (tooltipY < 0) { 33 | tooltipY = targetRect.bottom 34 | } 35 | 36 | tooltipX += window.scrollX 37 | tooltipY += window.scrollY 38 | } 39 | 40 | // 🐨 put this inside a createPortal call and append it to the document.body 41 | return ( 42 | 43 | {children} 44 | 45 | ) 46 | } 47 | 48 | function TooltipContainer({ 49 | children, 50 | x, 51 | y, 52 | contentRef, 53 | }: { 54 | children: React.ReactNode 55 | x: number 56 | y: number 57 | contentRef: React.RefObject 58 | }) { 59 | return ( 60 |
    64 |
    65 | {children} 66 |
    67 |
    68 | ) 69 | } 70 | 71 | export function ButtonWithTooltip({ 72 | tooltipContent, 73 | ...rest 74 | }: React.DetailedHTMLProps< 75 | React.ButtonHTMLAttributes, 76 | HTMLButtonElement 77 | > & { tooltipContent: React.ReactNode }) { 78 | const [targetRect, setTargetRect] = useState(null) 79 | const buttonRef = useRef(null) 80 | function displayTooltip() { 81 | const rect = buttonRef.current?.getBoundingClientRect() 82 | if (!rect) return 83 | setTargetRect({ 84 | left: rect.left, 85 | top: rect.top, 86 | right: rect.right, 87 | bottom: rect.bottom, 88 | }) 89 | } 90 | const hideTooltip = () => setTargetRect(null) 91 | return ( 92 | <> 93 | 44 | {showModal && ( 45 | setShowModal(false)} 49 | /> 50 | )} 51 | 52 | ) 53 | } 54 | ``` 55 | 56 | The first argument is the UI you want rendered (which has access to props, 57 | state, whatever) and the second argument is the DOM node you want to render it 58 | to. In this case, we're rendering the modal to the `body` element. 59 | 60 | 📜 Learn more from [the `createPortal` docs](https://react.dev/reference/react-dom/createPortal) 61 | -------------------------------------------------------------------------------- /exercises/06.layout-computation/01.problem.layout-effect/README.mdx: -------------------------------------------------------------------------------- 1 | # useLayoutEffect 2 | 3 | 4 | 5 | 👨‍💼 Our tooltip is great, but we do need to make measurements when we display it. 6 | We do this in a `useEffect` hook now with code like this: 7 | 8 | ```tsx 9 | useEffect(() => { 10 | const rect = ref.current?.getBoundingClientRect() 11 | if (!rect) return 12 | const { height } = rect 13 | setTooltipHeight(height) 14 | }, []) 15 | ``` 16 | 17 | That `height` is used to determine whether the tooltip should appear above or 18 | below the target element (the heart in our case). 19 | 20 | Kellie 🧝‍♂️ noticed on low-end devices, they're seeing a little flicker 21 | so she's added an arbitrary slowdown to our 22 | component to simulate that problem. To reproduce the problem, simply hover over 23 | a heart and you'll notice it starts at the bottom of the heart and then flickers 24 | to the top (if there's room on the top of the heart). 25 | 26 | So your job is simple. Change `useEffect` to `useLayoutEffect` and that should 27 | fix things. 28 | 29 | 📜 Parts of this exercise was lifted from [the React docs](https://react.dev/reference/react/useLayoutEffect#measuring-layout-before-the-browser-repaints-the-screen) 30 | 31 | 📜 Learn more about the difference between `useEffect` and `useLayoutEffect` in 32 | [useEffect vs useLayoutEffect](https://kentcdodds.com/blog/useeffect-vs-uselayouteffect). 33 | -------------------------------------------------------------------------------- /exercises/06.layout-computation/01.problem.layout-effect/form.tsx: -------------------------------------------------------------------------------- 1 | import { getQueryParam, useSearchParams } from './params' 2 | 3 | export function Form() { 4 | const [searchParams, setSearchParams] = useSearchParams() 5 | const query = getQueryParam(searchParams) 6 | 7 | const words = query.split(' ').map((w) => w.trim()) 8 | 9 | const dogChecked = words.includes('dog') 10 | const catChecked = words.includes('cat') 11 | const caterpillarChecked = words.includes('caterpillar') 12 | 13 | function handleCheck(tag: string, checked: boolean) { 14 | const newWords = checked ? [...words, tag] : words.filter((w) => w !== tag) 15 | setSearchParams( 16 | { query: newWords.filter(Boolean).join(' ').trim() }, 17 | { replace: true }, 18 | ) 19 | } 20 | 21 | return ( 22 | e.preventDefault()}> 23 |
    24 | 25 | 31 | setSearchParams({ query: e.currentTarget.value }, { replace: true }) 32 | } 33 | /> 34 |
    35 |
    36 | 44 | 52 | 62 |
    63 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /exercises/06.layout-computation/01.problem.layout-effect/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | .app { 7 | margin: 40px auto; 8 | max-width: 1024px; 9 | form { 10 | text-align: center; 11 | } 12 | } 13 | 14 | .post-list { 15 | list-style: none; 16 | padding: 0; 17 | display: flex; 18 | gap: 20px; 19 | flex-wrap: wrap; 20 | justify-content: center; 21 | li { 22 | position: relative; 23 | border-radius: 0.5rem; 24 | overflow: hidden; 25 | border: 1px solid #ddd; 26 | width: 320px; 27 | transition: transform 0.2s ease-in-out; 28 | a { 29 | text-decoration: none; 30 | color: unset; 31 | } 32 | 33 | &:hover, 34 | &:has(*:focus), 35 | &:has(*:active) { 36 | transform: translate(0px, -6px); 37 | } 38 | 39 | .post-image { 40 | display: block; 41 | width: 100%; 42 | height: 200px; 43 | } 44 | 45 | button { 46 | position: absolute; 47 | font-size: 1.5rem; 48 | top: 20px; 49 | right: 20px; 50 | background: transparent; 51 | border: none; 52 | outline: none; 53 | &:hover, 54 | &:focus, 55 | &:active { 56 | animation: pulse 1.5s infinite; 57 | } 58 | } 59 | 60 | a { 61 | padding: 10px 10px; 62 | display: flex; 63 | gap: 8px; 64 | flex-direction: column; 65 | h2 { 66 | margin: 0; 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | p { 71 | margin: 0; 72 | font-size: 1rem; 73 | color: #666; 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes pulse { 80 | 0% { 81 | transform: scale(1); 82 | } 83 | 50% { 84 | transform: scale(1.3); 85 | } 86 | 100% { 87 | transform: scale(1); 88 | } 89 | } 90 | 91 | .tooltip-container { 92 | position: absolute; 93 | pointer-events: none; 94 | left: 0; 95 | top: 0; 96 | transform: translate3d(var(--x), var(--y), 0); 97 | z-index: 10; 98 | } 99 | 100 | .tooltip { 101 | color: white; 102 | background: #222; 103 | border-radius: 4px; 104 | padding: 4px; 105 | } 106 | -------------------------------------------------------------------------------- /exercises/06.layout-computation/01.problem.layout-effect/index.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactDOM from 'react-dom/client' 2 | import { Form } from './form' 3 | import { SearchParamsProvider } from './params' 4 | import { MatchingPosts } from './posts' 5 | 6 | export function App() { 7 | return ( 8 | 9 |
    10 |
    11 | 12 |
    13 |
    14 | ) 15 | } 16 | 17 | const rootEl = document.createElement('div') 18 | document.body.append(rootEl) 19 | ReactDOM.createRoot(rootEl).render() 20 | -------------------------------------------------------------------------------- /exercises/06.layout-computation/01.problem.layout-effect/params.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useCallback, use, useEffect, useState } from 'react' 2 | import { setGlobalSearchParams } from '#shared/utils' 3 | 4 | type SearchParamsTuple = readonly [ 5 | URLSearchParams, 6 | typeof setGlobalSearchParams, 7 | ] 8 | const SearchParamsContext = createContext(null) 9 | 10 | export function SearchParamsProvider({ 11 | children, 12 | }: { 13 | children: React.ReactNode 14 | }) { 15 | const [searchParams, setSearchParamsState] = useState( 16 | () => new URLSearchParams(window.location.search), 17 | ) 18 | 19 | useEffect(() => { 20 | function updateSearchParams() { 21 | setSearchParamsState((prevParams) => { 22 | const newParams = new URLSearchParams(window.location.search) 23 | return prevParams.toString() === newParams.toString() 24 | ? prevParams 25 | : newParams 26 | }) 27 | } 28 | window.addEventListener('popstate', updateSearchParams) 29 | return () => window.removeEventListener('popstate', updateSearchParams) 30 | }, []) 31 | 32 | const setSearchParams = useCallback( 33 | (...args: Parameters) => { 34 | const searchParams = setGlobalSearchParams(...args) 35 | setSearchParamsState((prevParams) => { 36 | return prevParams.toString() === searchParams.toString() 37 | ? prevParams 38 | : searchParams 39 | }) 40 | return searchParams 41 | }, 42 | [], 43 | ) 44 | 45 | const searchParamsTuple = [searchParams, setSearchParams] as const 46 | 47 | return ( 48 | 49 | {children} 50 | 51 | ) 52 | } 53 | 54 | export function useSearchParams() { 55 | const context = use(SearchParamsContext) 56 | if (!context) { 57 | throw new Error( 58 | 'useSearchParams must be used within a SearchParamsProvider', 59 | ) 60 | } 61 | return context 62 | } 63 | 64 | export const getQueryParam = (params: URLSearchParams) => 65 | params.get('query') ?? '' 66 | -------------------------------------------------------------------------------- /exercises/06.layout-computation/01.problem.layout-effect/posts.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { 3 | type BlogPost, 4 | generateGradient, 5 | getMatchingPosts, 6 | } from '#shared/blog-posts' 7 | import { getQueryParam, useSearchParams } from './params' 8 | import { ButtonWithTooltip } from './tooltip' 9 | 10 | export function MatchingPosts() { 11 | const [searchParams] = useSearchParams() 12 | const query = getQueryParam(searchParams) 13 | const matchingPosts = getMatchingPosts(query) 14 | 15 | return ( 16 |
      17 | {matchingPosts.map((post) => ( 18 | 19 | ))} 20 |
    21 | ) 22 | } 23 | 24 | function Card({ post }: { post: BlogPost }) { 25 | const [isFavorited, setIsFavorited] = useState(false) 26 | return ( 27 |
  • 28 | {isFavorited ? ( 29 | setIsFavorited(false)} 32 | > 33 | ❤️ 34 | 35 | ) : ( 36 | setIsFavorited(true)} 39 | > 40 | 🤍 41 | 42 | )} 43 |
  • 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /exercises/06.layout-computation/01.problem.layout-effect/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import { createPortal } from 'react-dom' 3 | 4 | type Position = { 5 | left: number 6 | top: number 7 | right: number 8 | bottom: number 9 | } 10 | 11 | export default function Tooltip({ 12 | children, 13 | targetRect, 14 | }: { 15 | children: React.ReactNode 16 | targetRect: Position | null 17 | }) { 18 | const ref = useRef(null) 19 | const [tooltipHeight, setTooltipHeight] = useState(0) 20 | 21 | // 🐨 change this to useLayoutEffect to ensure it runs synchronously after the 22 | // DOM has been updated so the user doesn't see the tooltip jump around. 23 | useEffect(() => { 24 | const rect = ref.current?.getBoundingClientRect() 25 | if (!rect) return 26 | const { height } = rect 27 | setTooltipHeight(height) 28 | }, []) 29 | 30 | let tooltipX = 0 31 | let tooltipY = 0 32 | if (targetRect !== null) { 33 | tooltipX = targetRect.left 34 | tooltipY = targetRect.top - tooltipHeight 35 | if (tooltipY < 0) { 36 | tooltipY = targetRect.bottom 37 | } 38 | 39 | tooltipX += window.scrollX 40 | tooltipY += window.scrollY 41 | } 42 | 43 | // This artificially slows down rendering 44 | let now = performance.now() 45 | while (performance.now() - now < 100) { 46 | // Do nothing for a bit... 47 | } 48 | 49 | return createPortal( 50 | 51 | {children} 52 | , 53 | document.body, 54 | ) 55 | } 56 | 57 | function TooltipContainer({ 58 | children, 59 | x, 60 | y, 61 | contentRef, 62 | }: { 63 | children: React.ReactNode 64 | x: number 65 | y: number 66 | contentRef: React.RefObject 67 | }) { 68 | return ( 69 |
    73 |
    74 | {children} 75 |
    76 |
    77 | ) 78 | } 79 | 80 | export function ButtonWithTooltip({ 81 | tooltipContent, 82 | ...rest 83 | }: React.DetailedHTMLProps< 84 | React.ButtonHTMLAttributes, 85 | HTMLButtonElement 86 | > & { tooltipContent: React.ReactNode }) { 87 | const [targetRect, setTargetRect] = useState(null) 88 | const buttonRef = useRef(null) 89 | 90 | function displayTooltip() { 91 | const rect = buttonRef.current?.getBoundingClientRect() 92 | if (!rect) return 93 | setTargetRect({ 94 | left: rect.left, 95 | top: rect.top, 96 | right: rect.right, 97 | bottom: rect.bottom, 98 | }) 99 | } 100 | 101 | const hideTooltip = () => setTargetRect(null) 102 | 103 | return ( 104 | <> 105 | 65 | 66 | 67 |
    68 |
    69 | 70 |
    71 | 72 | {messages.map((message, index, array) => ( 73 |
    74 | {message.author}: {message.content} 75 | {array.length - 1 === index ? null :
    } 76 |
    77 | ))} 78 |
    79 |
    80 | 81 |
    82 | 83 | ) 84 | } 85 | 86 | const rootEl = document.createElement('div') 87 | document.body.append(rootEl) 88 | ReactDOM.createRoot(rootEl).render() 89 | 90 | /* 91 | eslint 92 | @typescript-eslint/no-unused-vars: "off", 93 | */ 94 | -------------------------------------------------------------------------------- /exercises/07.imperative-handle/01.solution.ref/messages.tsx: -------------------------------------------------------------------------------- 1 | export type Message = { id: string; author: string; content: string } 2 | 3 | export const allMessages: Array = [ 4 | `Leia: Aren't you a little short to be a stormtrooper?`, 5 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`, 6 | `Leia: You're who?`, 7 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`, 8 | `Leia: Ben Kenobi is here! Where is he?`, 9 | `Luke: Come on!`, 10 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`, 11 | `Leia: Put that thing away! You're going to get us all killed.`, 12 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`, 13 | `Leia: It could be worse...`, 14 | `Han: It's worse.`, 15 | `Luke: There's something alive in here!`, 16 | `Han: That's your imagination.`, 17 | `Luke: Something just moves past my leg! Look! Did you see that?`, 18 | `Han: What?`, 19 | `Luke: Help!`, 20 | `Han: Luke! Luke! Luke!`, 21 | `Leia: Luke!`, 22 | `Leia: Luke, Luke, grab a hold of this.`, 23 | `Luke: Blast it, will you! My gun's jammed.`, 24 | `Han: Where?`, 25 | `Luke: Anywhere! Oh!!`, 26 | `Han: Luke! Luke!`, 27 | `Leia: Grab him!`, 28 | `Leia: What happened?`, 29 | `Luke: I don't know, it just let go of me and disappeared...`, 30 | `Han: I've got a very bad feeling about this.`, 31 | `Luke: The walls are moving!`, 32 | `Leia: Don't just stand there. Try to brace it with something.`, 33 | `Luke: Wait a minute!`, 34 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`, 35 | ].map((m, i) => ({ 36 | id: String(i), 37 | author: m.split(': ')[0]!, 38 | content: m.split(': ')[1]!, 39 | })) 40 | -------------------------------------------------------------------------------- /exercises/07.imperative-handle/01.solution.ref/scroll.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent, waitFor } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep( 7 | 'Scrollable component handles scroll to top and bottom', 8 | async () => { 9 | // Find the scroll buttons 10 | const scrollTopButton = await screen.findByText(/Scroll to Top/i) 11 | const scrollBottomButton = await screen.findByText(/Scroll to Bottom/i) 12 | 13 | // Find the scrollable container 14 | const scrollableContainer = screen.getByRole('log') 15 | 16 | // Scroll to bottom 17 | fireEvent.click(scrollBottomButton) 18 | await waitFor(() => { 19 | expect( 20 | scrollableContainer.scrollTop, 21 | '🚨 Scrollable container should be scrolled to the bottom when the scroll to bottom button is clicked', 22 | ).toBe( 23 | scrollableContainer.scrollHeight - scrollableContainer.clientHeight, 24 | ) 25 | }) 26 | 27 | // Scroll to top 28 | fireEvent.click(scrollTopButton) 29 | await waitFor(() => { 30 | expect( 31 | scrollableContainer.scrollTop, 32 | '🚨 Scrollable container should be scrolled to the top when the scroll to top button is clicked', 33 | ).toBe(0) 34 | }) 35 | }, 36 | ) 37 | -------------------------------------------------------------------------------- /exercises/07.imperative-handle/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Imperative Handles 2 | 3 | 4 | 5 | 👨‍💼 Hooray! You did it! 6 | -------------------------------------------------------------------------------- /exercises/08.focus/01.problem.flush-sync/README.mdx: -------------------------------------------------------------------------------- 1 | # flushSync 2 | 3 | 4 | 5 | 🧝‍♂️ I've put together a new component we need. It's called `` and 6 | it allows users to edit a piece of text inline. We display it in a button and 7 | when the user clicks it, the button turns into a text input. When the user 8 | presses enter, blurs, or hits escape, the text input turns back into a button. 9 | 10 | Right now, when the user clicks the button, the button goes away and is replaced 11 | by the text input, but because their focus was on the button which is now gone, 12 | their focus returns to the `` and the text input is not focused. This is 13 | not a good user experience. 14 | 15 | 👨‍💼 Thanks Kellie. So now what we need is for you to properly manage focus for 16 | all of these cases. 17 | 18 | - When the user submits the form (by hitting "enter") 19 | - When the user cancels the form (by hitting "escape") 20 | - When the user blurs the input (by tabbing or clicking away) 21 | 22 | Additionally, when the user clicks the button, we want to select all the text so 23 | it's easy for them to edit. 24 | 25 | 🧝‍♂️ I've added some buttons before and after the input so you have something to 26 | test tab focus with. Good luck! 27 | 28 | 29 | This example uses code from 30 | [trellix](https://github.com/remix-run/example-trellix/blob/3379b3d5e9c0173381031e4f062877e8a3696b2e/app/routes/board.%24id/components.tsx). 31 | 32 | 33 | 34 | 🚨 Because this deals with focus, you'll need to expand the test and then run 35 | it for it to pass. 36 | 37 | -------------------------------------------------------------------------------- /exercises/08.focus/01.problem.flush-sync/index.css: -------------------------------------------------------------------------------- 1 | main { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | padding: 3rem; 6 | } 7 | .editable-text { 8 | button { 9 | /* remove button styles. Make it look like text */ 10 | background: none; 11 | border: none; 12 | padding: 4px 8px; 13 | font-size: 1.5rem; 14 | font-weight: bold; 15 | } 16 | 17 | input { 18 | /* make it the same size as the button */ 19 | font-size: 1.5rem; 20 | font-weight: bold; 21 | padding: 4px 8px; 22 | border: none; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /exercises/08.focus/01.problem.flush-sync/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | function EditableText({ 5 | id, 6 | initialValue = '', 7 | fieldName, 8 | inputLabel, 9 | buttonLabel, 10 | }: { 11 | id?: string 12 | initialValue?: string 13 | fieldName: string 14 | inputLabel: string 15 | buttonLabel: string 16 | }) { 17 | const [edit, setEdit] = useState(false) 18 | const [value, setValue] = useState(initialValue) 19 | const inputRef = useRef(null) 20 | // 🐨 add a button ref here 21 | 22 | return edit ? ( 23 | { 26 | event.preventDefault() 27 | // here's where you'd send the updated value to the server 28 | // 🐨 wrap these calls in a flushSync 29 | setValue(inputRef.current?.value ?? '') 30 | setEdit(false) 31 | // 🐨 after flushSync, focus the button with the button ref 32 | }} 33 | > 34 | { 43 | if (event.key === 'Escape') { 44 | // 🐨 wrap this in a flushSync 45 | setEdit(false) 46 | // 🐨 after the flushSync, focus the button 47 | } 48 | }} 49 | onBlur={(event) => { 50 | // 🐨 wrap these in a flushSync 51 | setValue(event.currentTarget.value) 52 | setEdit(false) 53 | // 🐨 after the flushSync, focus the button 54 | }} 55 | /> 56 | 57 | ) : ( 58 | 70 | ) 71 | } 72 | 73 | function App() { 74 | return ( 75 |
    76 | 77 |
    78 | 84 |
    85 | 86 |
    87 | ) 88 | } 89 | 90 | const rootEl = document.createElement('div') 91 | document.body.append(rootEl) 92 | ReactDOM.createRoot(rootEl).render() 93 | -------------------------------------------------------------------------------- /exercises/08.focus/01.solution.flush-sync/README.mdx: -------------------------------------------------------------------------------- 1 | # flushSync 2 | 3 | 4 | 5 | 👨‍💼 Awesome job. That's a mighty fine UX! 6 | -------------------------------------------------------------------------------- /exercises/08.focus/01.solution.flush-sync/focus.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent, waitFor } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('EditableText component renders', async () => { 7 | const editButton = await screen.findByRole('button', { 8 | name: /Edit project name/i, 9 | }) 10 | expect(editButton).toBeTruthy() 11 | return editButton 12 | }) 13 | 14 | await testStep( 15 | 'Clicking edit button focuses input and selects text', 16 | async () => { 17 | const editButton = await screen.findByRole('button', { 18 | name: /Edit project name/i, 19 | }) 20 | fireEvent.click(editButton) 21 | 22 | const input = screen.getByRole('textbox', { name: /Edit project name/i }) 23 | await waitFor(() => { 24 | expect( 25 | input, 26 | '🚨 Input should be focused after clicking edit button', 27 | ).toHaveFocus() 28 | if (!(input instanceof HTMLInputElement)) { 29 | throw new Error('Input is not an HTMLInputElement') 30 | } 31 | expect( 32 | input.selectionStart, 33 | '🚨 Input text should be fully selected', 34 | ).toBe(0) 35 | expect(input.selectionEnd, '🚨 Input text should be fully selected').toBe( 36 | input.value.length, 37 | ) 38 | }) 39 | return input 40 | }, 41 | ) 42 | 43 | await testStep('Submitting form focuses button', async () => { 44 | const input = await screen.findByRole('textbox', { 45 | name: /Edit project name/i, 46 | }) 47 | fireEvent.keyDown(input, { key: 'Enter' }) 48 | fireEvent.submit(input.closest('form')!) 49 | 50 | await waitFor(() => { 51 | const newButton = screen.getByRole('button', { name: /Edit project name/i }) 52 | expect( 53 | newButton, 54 | '🚨 Button should be focused after submitting', 55 | ).toHaveFocus() 56 | }) 57 | }) 58 | 59 | await testStep('Canceling edit focuses button', async () => { 60 | const editButton = screen.getByRole('button', { name: /Edit project name/i }) 61 | fireEvent.click(editButton) 62 | 63 | const input = screen.getByRole('textbox', { name: /Edit project name/i }) 64 | fireEvent.keyDown(input, { key: 'Escape' }) 65 | 66 | await waitFor(() => { 67 | const newButton = screen.getByRole('button', { name: /Edit project name/i }) 68 | expect( 69 | newButton, 70 | '🚨 Button should be focused after canceling', 71 | ).toHaveFocus() 72 | }) 73 | }) 74 | 75 | await testStep('Blurring input focuses button', async () => { 76 | const editButton = screen.getByRole('button', { name: /Edit project name/i }) 77 | fireEvent.click(editButton) 78 | 79 | const input = screen.getByRole('textbox', { name: /Edit project name/i }) 80 | fireEvent.blur(input) 81 | input.blur() 82 | 83 | await waitFor(() => { 84 | const newButton = screen.getByRole('button', { name: /Edit project name/i }) 85 | expect( 86 | newButton, 87 | '🚨 Button should be focused after blurring input', 88 | ).toHaveFocus() 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /exercises/08.focus/01.solution.flush-sync/index.css: -------------------------------------------------------------------------------- 1 | main { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | padding: 3rem; 6 | } 7 | .editable-text { 8 | button { 9 | /* remove button styles. Make it look like text */ 10 | background: none; 11 | border: none; 12 | padding: 4px 8px; 13 | font-size: 1.5rem; 14 | font-weight: bold; 15 | } 16 | 17 | input { 18 | /* make it the same size as the button */ 19 | font-size: 1.5rem; 20 | font-weight: bold; 21 | padding: 4px 8px; 22 | border: none; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /exercises/08.focus/01.solution.flush-sync/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react' 2 | import { flushSync } from 'react-dom' 3 | import * as ReactDOM from 'react-dom/client' 4 | 5 | function EditableText({ 6 | id, 7 | initialValue = '', 8 | fieldName, 9 | inputLabel, 10 | buttonLabel, 11 | }: { 12 | id?: string 13 | initialValue?: string 14 | fieldName: string 15 | inputLabel: string 16 | buttonLabel: string 17 | }) { 18 | const [edit, setEdit] = useState(false) 19 | const [value, setValue] = useState(initialValue) 20 | const inputRef = useRef(null) 21 | const buttonRef = useRef(null) 22 | 23 | return edit ? ( 24 |
    { 27 | event.preventDefault() 28 | flushSync(() => { 29 | setValue(inputRef.current?.value ?? '') 30 | setEdit(false) 31 | }) 32 | buttonRef.current?.focus() 33 | }} 34 | > 35 | { 44 | if (event.key === 'Escape') { 45 | flushSync(() => { 46 | setEdit(false) 47 | }) 48 | buttonRef.current?.focus() 49 | } 50 | }} 51 | onBlur={(event) => { 52 | flushSync(() => { 53 | setValue(event.currentTarget.value) 54 | setEdit(false) 55 | }) 56 | buttonRef.current?.focus() 57 | }} 58 | /> 59 |
    60 | ) : ( 61 | 74 | ) 75 | } 76 | 77 | function App() { 78 | return ( 79 |
    80 | 81 |
    82 | 88 |
    89 | 90 |
    91 | ) 92 | } 93 | 94 | const rootEl = document.createElement('div') 95 | document.body.append(rootEl) 96 | ReactDOM.createRoot(rootEl).render() 97 | -------------------------------------------------------------------------------- /exercises/08.focus/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Focus Management 2 | 3 | 4 | 5 | 👨‍💼 It's important to consider users of keyboards, both those with disabilities 6 | who rely on them as well as power users who prefer to use the keyboard over the 7 | mouse. Good work! 8 | -------------------------------------------------------------------------------- /exercises/08.focus/README.mdx: -------------------------------------------------------------------------------- 1 | # Focus Management 2 | 3 | 4 | 5 | Helping the user's focus stay on the right place is a key part of the user 6 | experience. This is especially important for users who rely on screen readers or 7 | keyboard navigation. But even able users can benefit from a well-thought focus 8 | management experience. 9 | 10 | Sometimes, the element you want to focus on only becomes available after a state 11 | update. For example: 12 | 13 | ```tsx 14 | function MyComponent() { 15 | const [show, setShow] = useState(false) 16 | 17 | return ( 18 |
    19 | 20 | {show ? : null} 21 |
    22 | ) 23 | } 24 | ``` 25 | 26 | Presumably after the user clicks "show" they will want to type something in the 27 | input there. Good focus management would focus the input after it becomes 28 | visible. 29 | 30 | It's important for you to know that in React state updates happen in batches. 31 | So state updates do not necessarily take place at the same time you 32 | call the state updater function. 33 | 34 | As a result of React state update batching, if you try to focus an element right 35 | after a state update, it might not work as expected. This is because the element 36 | you want to focus on might not be available yet. 37 | 38 | ```tsx remove=10 39 | function MyComponent() { 40 | const inputRef = useRef(null) 41 | const [show, setShow] = useState(false) 42 | 43 | return ( 44 |
    45 | 53 | {show ? : null} 54 |
    55 | ) 56 | } 57 | ``` 58 | 59 | The solution to this problem is to force React to run the state and DOM updates 60 | synchronously so that the element you want to focus on is available when you try 61 | to focus it. 62 | 63 | You do this by using the `flushSync` function from the `react-dom` package. 64 | 65 | ```tsx 66 | import { flushSync } from 'react-dom' 67 | 68 | function MyComponent() { 69 | const inputRef = useRef(null) 70 | const [show, setShow] = useState(false) 71 | 72 | return ( 73 |
    74 | 84 | {show ? : null} 85 |
    86 | ) 87 | } 88 | ``` 89 | 90 | What `flushSync` does is that it forces React to run the state update and DOM 91 | update synchronously. This way, the input element will be available when you try 92 | to focus it on the line following the `flushSync` call. 93 | 94 | In general you want to avoid this de-optimization, but in some cases (like focus 95 | management), it's the perfect solution. 96 | 97 | Learn more in [📜 the `flushSync` docs](https://react.dev/reference/react-dom/flushSync). 98 | -------------------------------------------------------------------------------- /exercises/09.sync-external/01.problem.sub/README.mdx: -------------------------------------------------------------------------------- 1 | # useSyncExternalStore 2 | 3 | 4 | 5 | 🦉 When you have a design that needs to be responsive, you use 6 | [media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries) 7 | to change the layout of the page based on the size of the screen. Media queries 8 | can tell you a lot more than just the width of the page and sometimes you need 9 | to know whether a media query matches even outside of a CSS context. 10 | 11 | The browser supports a JavaScript API called `matchMedia` that allows you to 12 | query the current state of a media query: 13 | 14 | ```tsx 15 | const prefersDarkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') 16 | console.log(prefersDarkModeQuery.matches) // true if the user prefers dark mode 17 | ``` 18 | 19 | 👨‍💼 Thanks for that Olivia. So yes, our users want a component that displays 20 | whether they're on a narrow screen. We're going to build this into a more 21 | generic hook that will allow us to determine any media query's match and also 22 | keep the state in sync with the media query. And you're going to need to use 23 | `useSyncExternalStore` to do it. 24 | 25 | Go ahead and follow the emoji instructions. You'll know you got it right when 26 | you resize your screen and the text changes. 27 | 28 | 29 | 🦉 If we really were just trying to display some different text based on the 30 | screen size, we could use CSS media queries and not have to write any 31 | JavaScript at all. But sometimes we need to know the state of a media query in 32 | JavaScript for more complex interactions, so we're going to use a simple 33 | example to demonstrate how to do this to handle those cases and we'll be using 34 | `useSyncExternalStore` for that. 35 | 36 | -------------------------------------------------------------------------------- /exercises/09.sync-external/01.problem.sub/index.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactDOM from 'react-dom/client' 2 | 3 | // 💰 this is the mediaQuery we're going to be matching against: 4 | // const mediaQuery = '(max-width: 600px)' 5 | 6 | // 🐨 make a getSnapshot function here that returns whether the media query matches 7 | 8 | // 🐨 make a subscribe function here which takes a callback function 9 | // 🐨 create a matchQueryList variable here with the mediaQuery from above (📜 https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList) 10 | // 🐨 add a change listener to the mediaQueryList which calls the callback 11 | // 🐨 return a cleanup function which removes the change event listener for the callback 12 | 13 | function NarrowScreenNotifier() { 14 | // 🐨 assign this to useSyncExternalStore with the subscribe and getSnapshot functions above 15 | const isNarrow = false 16 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen' 17 | } 18 | 19 | function App() { 20 | return 21 | } 22 | 23 | const rootEl = document.createElement('div') 24 | document.body.append(rootEl) 25 | const root = ReactDOM.createRoot(rootEl) 26 | root.render() 27 | 28 | // @ts-expect-error 🚨 this is for the test 29 | window.__epicReactRoot = root 30 | -------------------------------------------------------------------------------- /exercises/09.sync-external/01.solution.sub/README.mdx: -------------------------------------------------------------------------------- 1 | # useSyncExternalStore 2 | 3 | 4 | 5 | 👨‍💼 Great work! Our users will now know whether they're on a narrow screen 🤡 6 | -------------------------------------------------------------------------------- /exercises/09.sync-external/01.solution.sub/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | const mediaQuery = '(max-width: 600px)' 5 | function getSnapshot() { 6 | return window.matchMedia(mediaQuery).matches 7 | } 8 | 9 | function subscribe(callback: () => void) { 10 | const mediaQueryList = window.matchMedia(mediaQuery) 11 | mediaQueryList.addEventListener('change', callback) 12 | return () => { 13 | mediaQueryList.removeEventListener('change', callback) 14 | } 15 | } 16 | 17 | function NarrowScreenNotifier() { 18 | const isNarrow = useSyncExternalStore(subscribe, getSnapshot) 19 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen' 20 | } 21 | 22 | function App() { 23 | return 24 | } 25 | 26 | const rootEl = document.createElement('div') 27 | document.body.append(rootEl) 28 | const root = ReactDOM.createRoot(rootEl) 29 | root.render() 30 | 31 | // @ts-expect-error 🚨 this is for the test 32 | window.__epicReactRoot = root 33 | -------------------------------------------------------------------------------- /exercises/09.sync-external/01.solution.sub/sync-media-query.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen } = dtl 3 | 4 | import './index.tsx' 5 | 6 | let mediaQueryCallbacks: Array<(e: { matches: boolean }) => void> = [] 7 | let currentMatches = false 8 | 9 | const originalMatchMedia = window.matchMedia 10 | // @ts-expect-error - meh it's free javascript 11 | window.matchMedia = (query: string) => ({ 12 | ...originalMatchMedia(query), 13 | matches: currentMatches, 14 | media: query, 15 | addEventListener: ( 16 | event: string, 17 | callback: (e: { matches: boolean }) => void, 18 | ) => { 19 | mediaQueryCallbacks.push(callback) 20 | }, 21 | removeEventListener: ( 22 | event: string, 23 | callback: (e: { matches: boolean }) => void, 24 | ) => { 25 | mediaQueryCallbacks = mediaQueryCallbacks.filter((cb) => cb !== callback) 26 | }, 27 | }) 28 | 29 | function triggerMediaQueryChange(matches: boolean) { 30 | currentMatches = matches 31 | mediaQueryCallbacks.forEach((callback) => callback({ matches })) 32 | } 33 | 34 | await testStep( 35 | 'NarrowScreenNotifier renders wide screen message initially', 36 | async () => { 37 | const message = await screen.findByText('You are on a wide screen') 38 | expect(message).toBeTruthy() 39 | }, 40 | ) 41 | 42 | await testStep( 43 | 'NarrowScreenNotifier updates when media query changes to narrow', 44 | async () => { 45 | triggerMediaQueryChange(true) 46 | const message = await screen.findByText('You are on a narrow screen') 47 | expect(message).toBeTruthy() 48 | }, 49 | ) 50 | 51 | await testStep( 52 | 'NarrowScreenNotifier updates when media query changes back to wide', 53 | async () => { 54 | triggerMediaQueryChange(false) 55 | const message = await screen.findByText('You are on a wide screen') 56 | expect(message).toBeTruthy() 57 | }, 58 | ) 59 | 60 | await testStep( 61 | 'NarrowScreenNotifier removes event listener on unmount', 62 | async () => { 63 | const initialCallbackCount = mediaQueryCallbacks.length 64 | // @ts-expect-error 🚨 this is for the test 65 | window.__epicReactRoot.unmount() 66 | expect(mediaQueryCallbacks.length).toBe(initialCallbackCount - 1) 67 | }, 68 | ) 69 | 70 | // Cleanup 71 | window.matchMedia = originalMatchMedia 72 | -------------------------------------------------------------------------------- /exercises/09.sync-external/02.problem.util/README.mdx: -------------------------------------------------------------------------------- 1 | # Make Store Utility 2 | 3 | 4 | 5 | 👨‍💼 We want to make this utility generally useful so we can use it for any media 6 | query. So please stick most of our logic in a `makeMediaQueryStore` function 7 | and have that return a custom hook people can use to keep track of the current 8 | media query's matching state. 9 | 10 | It'll be something like this: 11 | 12 | ```tsx 13 | export function makeMediaQueryStore(mediaQuery: string) { 14 | // ... 15 | } 16 | 17 | const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)') 18 | 19 | function App() { 20 | const isNarrow = useNarrowMediaQuery() 21 | // ... 22 | } 23 | ``` 24 | -------------------------------------------------------------------------------- /exercises/09.sync-external/02.problem.util/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | const mediaQuery = '(max-width: 600px)' 5 | 6 | // 🐨 put getSnapshot and subscribe in a new function called makeMediaQueryStore 7 | // which accepts a mediaQuery and returns a hook that uses useSyncExternalStore 8 | // with the subscribe and getSnapshot functions. 9 | function getSnapshot() { 10 | return window.matchMedia(mediaQuery).matches 11 | } 12 | 13 | function subscribe(callback: () => void) { 14 | const mediaQueryList = window.matchMedia(mediaQuery) 15 | mediaQueryList.addEventListener('change', callback) 16 | return () => { 17 | mediaQueryList.removeEventListener('change', callback) 18 | } 19 | } 20 | // 🐨 put everything above in the makeMediaQueryStore function 21 | 22 | // 🐨 call makeMediaQueryStore with '(max-width: 600px)' and assign the return 23 | // value to a variable called useNarrowMediaQuery 24 | 25 | function NarrowScreenNotifier() { 26 | // 🐨 call useNarrowMediaQuery here instead of useSyncExternalStore 27 | const isNarrow = useSyncExternalStore(subscribe, getSnapshot) 28 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen' 29 | } 30 | 31 | function App() { 32 | return 33 | } 34 | 35 | const rootEl = document.createElement('div') 36 | document.body.append(rootEl) 37 | const root = ReactDOM.createRoot(rootEl) 38 | root.render() 39 | 40 | // @ts-expect-error 🚨 this is for the test 41 | window.__epicReactRoot = root 42 | -------------------------------------------------------------------------------- /exercises/09.sync-external/02.solution.util/README.mdx: -------------------------------------------------------------------------------- 1 | # Make Store Utility 2 | 3 | 4 | 5 | 👨‍💼 Great! With that we now have a reusable utility and can use this to subscribe 6 | to any media query! 7 | -------------------------------------------------------------------------------- /exercises/09.sync-external/02.solution.util/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | export function makeMediaQueryStore(mediaQuery: string) { 5 | function getSnapshot() { 6 | return window.matchMedia(mediaQuery).matches 7 | } 8 | 9 | function subscribe(callback: () => void) { 10 | const mediaQueryList = window.matchMedia(mediaQuery) 11 | mediaQueryList.addEventListener('change', callback) 12 | return () => { 13 | mediaQueryList.removeEventListener('change', callback) 14 | } 15 | } 16 | 17 | return function useMediaQuery() { 18 | return useSyncExternalStore(subscribe, getSnapshot) 19 | } 20 | } 21 | 22 | const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)') 23 | 24 | function NarrowScreenNotifier() { 25 | const isNarrow = useNarrowMediaQuery() 26 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen' 27 | } 28 | 29 | function App() { 30 | return 31 | } 32 | 33 | const rootEl = document.createElement('div') 34 | document.body.append(rootEl) 35 | const root = ReactDOM.createRoot(rootEl) 36 | root.render() 37 | 38 | // @ts-expect-error 🚨 this is for the test 39 | window.__epicReactRoot = root 40 | -------------------------------------------------------------------------------- /exercises/09.sync-external/02.solution.util/sync-media-query.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen } = dtl 3 | 4 | import './index.tsx' 5 | 6 | let mediaQueryCallbacks: Array<(e: { matches: boolean }) => void> = [] 7 | let currentMatches = false 8 | 9 | const originalMatchMedia = window.matchMedia 10 | // @ts-expect-error - meh it's free javascript 11 | window.matchMedia = (query: string) => ({ 12 | ...originalMatchMedia(query), 13 | matches: currentMatches, 14 | media: query, 15 | addEventListener: ( 16 | event: string, 17 | callback: (e: { matches: boolean }) => void, 18 | ) => { 19 | mediaQueryCallbacks.push(callback) 20 | }, 21 | removeEventListener: ( 22 | event: string, 23 | callback: (e: { matches: boolean }) => void, 24 | ) => { 25 | mediaQueryCallbacks = mediaQueryCallbacks.filter((cb) => cb !== callback) 26 | }, 27 | }) 28 | 29 | function triggerMediaQueryChange(matches: boolean) { 30 | currentMatches = matches 31 | mediaQueryCallbacks.forEach((callback) => callback({ matches })) 32 | } 33 | 34 | await testStep( 35 | 'NarrowScreenNotifier renders wide screen message initially', 36 | async () => { 37 | const message = await screen.findByText('You are on a wide screen') 38 | expect(message).toBeTruthy() 39 | }, 40 | ) 41 | 42 | await testStep( 43 | 'NarrowScreenNotifier updates when media query changes to narrow', 44 | async () => { 45 | triggerMediaQueryChange(true) 46 | const message = await screen.findByText('You are on a narrow screen') 47 | expect(message).toBeTruthy() 48 | }, 49 | ) 50 | 51 | await testStep( 52 | 'NarrowScreenNotifier updates when media query changes back to wide', 53 | async () => { 54 | triggerMediaQueryChange(false) 55 | const message = await screen.findByText('You are on a wide screen') 56 | expect(message).toBeTruthy() 57 | }, 58 | ) 59 | 60 | await testStep( 61 | 'NarrowScreenNotifier removes event listener on unmount', 62 | async () => { 63 | const initialCallbackCount = mediaQueryCallbacks.length 64 | // @ts-expect-error 🚨 this is for the test 65 | window.__epicReactRoot.unmount() 66 | expect(mediaQueryCallbacks.length).toBe(initialCallbackCount - 1) 67 | }, 68 | ) 69 | 70 | // Cleanup 71 | window.matchMedia = originalMatchMedia 72 | -------------------------------------------------------------------------------- /exercises/09.sync-external/03.problem.ssr/README.mdx: -------------------------------------------------------------------------------- 1 | # Handling Server Rendering 2 | 3 | 4 | 5 | 👨‍💼 We don't currently do any server rendering, but in the future we may want to 6 | and this requires some special handling with `useSyncExternalStore`. 7 | 8 | 🧝‍♂️ I've simulated a server rendering environment by 9 | adding some code to the bottom of our file. First, we render the `` to a 10 | string, then we set that to the `innerHTML` of our `rootEl`. Then we call 11 | `hydrateRoot` to rehydrate our application. 12 | 13 | ```tsx 14 | const rootEl = document.createElement('div') 15 | document.body.append(rootEl) 16 | // simulate server rendering 17 | rootEl.innerHTML = (await import('react-dom/server')).renderToString() 18 | 19 | // simulate taking a while for the JS to load... 20 | await new Promise((resolve) => setTimeout(resolve, 1000)) 21 | 22 | ReactDOM.hydrateRoot(rootEl, ) 23 | ``` 24 | 25 | 👨‍💼 This is a bit of a hack, but it's a good way to simulate server rendering 26 | and ensure that our application works in a server rendering situation. 27 | 28 | Because the server won't know whether a media query matches, we can't use the 29 | `getServerSnapshot()` argument of `useSyncExternalStore`. Instead, we'll leave 30 | that argument off, and wrap our `` in a 31 | [``](https://react.dev/reference/react/Suspense) component with a 32 | fallback of `""` (we won't show anything until the client hydrates). 33 | 34 | With this, you'll notice there's an error in the console. Nothing's technically 35 | wrong, but React logs this in this situation (I honestly personally disagree 36 | that they should do this, but 🤷‍♂️). So as extra credit, you can add an 37 | `onRecoverableError` function to the `hydrateRoot` call and if the given error 38 | includes the string `'Missing getServerSnapshot'` then you can return, 39 | otherwise, log the error. 40 | 41 | Good luck! 42 | 43 | ```tsx 44 | import { hydrateRoot } from 'react-dom/client' 45 | 46 | const root = hydrateRoot(document.getElementById('root'), , { 47 | onRecoverableError: (error, errorInfo) => { 48 | console.error('Caught error', error, error.cause, errorInfo.componentStack) 49 | }, 50 | }) 51 | ``` 52 | -------------------------------------------------------------------------------- /exercises/09.sync-external/03.problem.ssr/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | export function makeMediaQueryStore(mediaQuery: string) { 5 | function getSnapshot() { 6 | return window.matchMedia(mediaQuery).matches 7 | } 8 | 9 | function subscribe(callback: () => void) { 10 | const mediaQueryList = window.matchMedia(mediaQuery) 11 | mediaQueryList.addEventListener('change', callback) 12 | return () => { 13 | mediaQueryList.removeEventListener('change', callback) 14 | } 15 | } 16 | 17 | return function useMediaQuery() { 18 | return useSyncExternalStore(subscribe, getSnapshot) 19 | } 20 | } 21 | 22 | const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)') 23 | 24 | function NarrowScreenNotifier() { 25 | const isNarrow = useNarrowMediaQuery() 26 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen' 27 | } 28 | 29 | function App() { 30 | return ( 31 |
    32 |
    This is your narrow screen state:
    33 | {/* 🐨 wrap this in a Suspense component around this with a fallback prop of "" */} 34 | {/* 📜 https://react.dev/reference/react/Suspense */} 35 | 36 |
    37 | ) 38 | } 39 | 40 | const rootEl = document.createElement('div') 41 | document.body.append(rootEl) 42 | // 🦉 here's how we pretend we're server-rendering 43 | rootEl.innerHTML = (await import('react-dom/server')).renderToString() 44 | 45 | // 🦉 here's how we simulate a delay in hydrating with client-side js 46 | await new Promise((resolve) => setTimeout(resolve, 1000)) 47 | 48 | const root = ReactDOM.hydrateRoot(rootEl, , { 49 | // 💯 if you want to silence the error add a onRecoverableError function here 50 | // and if the error includes 'Missing getServerSnapshot' then return early 51 | // otherwise log the error so you don't miss any other errors. 52 | }) 53 | 54 | // @ts-expect-error 🚨 this is for the test 55 | window.__epicReactRoot = root 56 | -------------------------------------------------------------------------------- /exercises/09.sync-external/03.solution.ssr/README.mdx: -------------------------------------------------------------------------------- 1 | # Handling Server Rendering 2 | 3 | 4 | 5 | 👨‍💼 Great work! You now know how to properly handle server rendering of something 6 | we don't know until the client-render when it comes to an external store like 7 | this. 8 | 9 | 🦉 There are more things you can do for different cases (like the user's 10 | light/dark mode preference) to offer a better user experience. Check out 11 | [`@epic-web/client-hints`](https://www.npmjs.com/package/@epic-web/client-hints) 12 | to see how you can handle this even better if you're interested. 13 | -------------------------------------------------------------------------------- /exercises/09.sync-external/03.solution.ssr/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, useSyncExternalStore } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | 4 | export function makeMediaQueryStore(mediaQuery: string) { 5 | function getSnapshot() { 6 | return window.matchMedia(mediaQuery).matches 7 | } 8 | 9 | function subscribe(callback: () => void) { 10 | const mediaQueryList = window.matchMedia(mediaQuery) 11 | mediaQueryList.addEventListener('change', callback) 12 | return () => { 13 | mediaQueryList.removeEventListener('change', callback) 14 | } 15 | } 16 | 17 | return function useMediaQuery() { 18 | return useSyncExternalStore(subscribe, getSnapshot) 19 | } 20 | } 21 | 22 | const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)') 23 | 24 | function NarrowScreenNotifier() { 25 | const isNarrow = useNarrowMediaQuery() 26 | return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen' 27 | } 28 | 29 | function App() { 30 | return ( 31 |
    32 |
    This is your narrow screen state:
    33 | 34 | 35 | 36 |
    37 | ) 38 | } 39 | 40 | const rootEl = document.createElement('div') 41 | document.body.append(rootEl) 42 | // 🦉 here's how we pretend we're server-rendering 43 | rootEl.innerHTML = (await import('react-dom/server')).renderToString() 44 | 45 | // 🦉 here's how we simulate a delay in hydrating with client-side js 46 | await new Promise((resolve) => setTimeout(resolve, 1000)) 47 | 48 | const root = ReactDOM.hydrateRoot(rootEl, , { 49 | onRecoverableError(error) { 50 | if (String(error).includes('Missing getServerSnapshot')) return 51 | 52 | console.error(error) 53 | }, 54 | }) 55 | 56 | // @ts-expect-error 🚨 this is for the test 57 | window.__epicReactRoot = root 58 | -------------------------------------------------------------------------------- /exercises/09.sync-external/03.solution.ssr/sync-media-query.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen } = dtl 3 | 4 | import './index.tsx' 5 | 6 | let mediaQueryCallbacks: Array<(e: { matches: boolean }) => void> = [] 7 | let currentMatches = false 8 | 9 | const originalMatchMedia = window.matchMedia 10 | // @ts-expect-error - meh it's free javascript 11 | window.matchMedia = (query: string) => ({ 12 | ...originalMatchMedia(query), 13 | matches: currentMatches, 14 | media: query, 15 | addEventListener: ( 16 | event: string, 17 | callback: (e: { matches: boolean }) => void, 18 | ) => { 19 | mediaQueryCallbacks.push(callback) 20 | }, 21 | removeEventListener: ( 22 | event: string, 23 | callback: (e: { matches: boolean }) => void, 24 | ) => { 25 | mediaQueryCallbacks = mediaQueryCallbacks.filter((cb) => cb !== callback) 26 | }, 27 | }) 28 | 29 | function triggerMediaQueryChange(matches: boolean) { 30 | currentMatches = matches 31 | mediaQueryCallbacks.forEach((callback) => callback({ matches })) 32 | } 33 | 34 | await testStep( 35 | 'NarrowScreenNotifier renders wide screen message initially', 36 | async () => { 37 | const message = await screen.findByText('You are on a wide screen') 38 | expect(message).toBeTruthy() 39 | }, 40 | ) 41 | 42 | await testStep( 43 | 'NarrowScreenNotifier updates when media query changes to narrow', 44 | async () => { 45 | triggerMediaQueryChange(true) 46 | const message = await screen.findByText('You are on a narrow screen') 47 | expect(message).toBeTruthy() 48 | }, 49 | ) 50 | 51 | await testStep( 52 | 'NarrowScreenNotifier updates when media query changes back to wide', 53 | async () => { 54 | triggerMediaQueryChange(false) 55 | const message = await screen.findByText('You are on a wide screen') 56 | expect(message).toBeTruthy() 57 | }, 58 | ) 59 | 60 | await testStep( 61 | 'NarrowScreenNotifier removes event listener on unmount', 62 | async () => { 63 | const initialCallbackCount = mediaQueryCallbacks.length 64 | // @ts-expect-error 🚨 this is for the test 65 | window.__epicReactRoot.unmount() 66 | expect(mediaQueryCallbacks.length).toBe(initialCallbackCount - 1) 67 | }, 68 | ) 69 | 70 | // Cleanup 71 | window.matchMedia = originalMatchMedia 72 | -------------------------------------------------------------------------------- /exercises/09.sync-external/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Sync External State 2 | 3 | 4 | 5 | 👨‍💼 Great work! You now know how to integrate React with external bits of 6 | changing state. Well done! 7 | -------------------------------------------------------------------------------- /exercises/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Advanced React APIs 🔥 2 | 3 | 4 | 5 | 👨‍💼 Congratulations! You've finished the workshop! 6 | 7 | 🦉 There are a few hooks that we haven't covered in this workshop that we won't 8 | be covering in Epic React because they're extremely rarely used in the wild. Feel 9 | free to read up on them here: 10 | 11 | - [`useDebugValue`](https://react.dev/reference/react/useDebugValue) 12 | - [`useInsertionEffect`](https://react.dev/reference/react/useInsertionEffect) 13 | -------------------------------------------------------------------------------- /exercises/README.mdx: -------------------------------------------------------------------------------- 1 | # Advanced React APIs 🔥 2 | 3 | 4 | 5 | 👨‍💼 Hello there! I'm Peter the Product Manager and I'll be helping guide you 6 | through all the things that our users want to see in our app that you'll be 7 | working on in this workshop. 8 | 9 | We're going to cover a lot of ground and a handful of components that need to be 10 | enhanced for the features our users are looking for. You'll be building things 11 | using React hooks like `useReducer`, `use`, `useLayoutEffect`, 12 | `useSyncExternalStore`, and more. You'll even be building your own custom 13 | hooks! 14 | 15 | In addition to advanced hooks, we'll also be covering a couple advanced use 16 | cases like focus management with `flushSync` as well as `createPortal`. 17 | 18 | It's going to be a full experience, so let's get started! 19 | 20 | 21 | 🚨 Note because we're refactoring each step rather than changing behavior, the 22 | tests will all be working from the start. So the tests are just there to help 23 | you make sure you have not regressed any functionality in the course of your 24 | refactoring. 25 | 26 | 27 | 🎵 Check out the workshop theme song! 🎶 28 | 29 | 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "advanced-react-apis", 3 | "private": true, 4 | "epicshop": { 5 | "title": "Advanced React APIs 🔥", 6 | "subtitle": "Learn the more advanced React APIs and different use cases to enable great user experiences.", 7 | "githubRepo": "https://github.com/epicweb-dev/advanced-react-apis", 8 | "stackBlitzConfig": { 9 | "view": "editor" 10 | }, 11 | "product": { 12 | "host": "www.epicreact.dev", 13 | "slug": "advanced-react-apis", 14 | "displayName": "EpicReact.dev", 15 | "displayNameShort": "Epic React", 16 | "logo": "/logo.svg", 17 | "discordChannelId": "1285244676286189569", 18 | "discordTags": [ 19 | "1285246046498328627", 20 | "1285245763428810815" 21 | ] 22 | }, 23 | "onboardingVideo": "https://www.epicweb.dev/tips/get-started-with-the-epic-workshop-app-for-react", 24 | "instructor": { 25 | "name": "Kent C. Dodds", 26 | "avatar": "/images/instructor.png", 27 | "𝕏": "kentcdodds" 28 | } 29 | }, 30 | "type": "module", 31 | "imports": { 32 | "#*": "./*" 33 | }, 34 | "prettier": "@epic-web/config/prettier", 35 | "scripts": { 36 | "postinstall": "cd ./epicshop && npm install", 37 | "start": "npx --prefix ./epicshop epicshop start", 38 | "dev": "npx --prefix ./epicshop epicshop start", 39 | "setup": "node ./epicshop/setup.js", 40 | "setup:custom": "node ./epicshop/setup-custom.js", 41 | "lint": "eslint .", 42 | "format": "prettier --write .", 43 | "typecheck": "tsc -b" 44 | }, 45 | "keywords": [], 46 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 47 | "license": "GPL-3.0-only", 48 | "dependencies": { 49 | "react": "19.0.0", 50 | "react-dom": "19.0.0" 51 | }, 52 | "devDependencies": { 53 | "@epic-web/config": "^1.16.3", 54 | "@epic-web/workshop-utils": "^5.20.1", 55 | "@types/react": "^19.0.0", 56 | "@types/react-dom": "^19.0.0", 57 | "eslint": "^9.16.0", 58 | "npm-run-all": "^4.1.5", 59 | "prettier": "^3.4.2", 60 | "typescript": "^5.7.2" 61 | }, 62 | "engines": { 63 | "node": ">=20", 64 | "npm": ">=9.3.0", 65 | "git": ">=2.18.0" 66 | }, 67 | "prettierIgnore": [ 68 | "node_modules", 69 | "**/build/**", 70 | "**/public/build/**", 71 | ".env", 72 | "**/package.json", 73 | "**/tsconfig.json", 74 | "**/package-lock.json", 75 | "**/playwright-report/**" 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/advanced-react-apis/67fc0d2764a3ed3ace1bcbc0ceaa492327f9d885/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/hook-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/advanced-react-apis/67fc0d2764a3ed3ace1bcbc0ceaa492327f9d885/public/hook-flow.png -------------------------------------------------------------------------------- /public/images/instructor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/advanced-react-apis/67fc0d2764a3ed3ace1bcbc0ceaa492327f9d885/public/images/instructor.png -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /public/og/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/advanced-react-apis/67fc0d2764a3ed3ace1bcbc0ceaa492327f9d885/public/og/background.png -------------------------------------------------------------------------------- /reset.d.ts: -------------------------------------------------------------------------------- 1 | import '@epic-web/config/reset.d.ts' 2 | -------------------------------------------------------------------------------- /shared/tic-tac-toe-utils.tsx: -------------------------------------------------------------------------------- 1 | export type Player = 'X' | 'O' 2 | export type Squares = Array 3 | 4 | export type GameState = { 5 | history: Array 6 | currentStep: number 7 | } 8 | 9 | function isSquare(value: unknown): value is null | 'X' | 'O' { 10 | return value === null || value === 'X' || value === 'O' 11 | } 12 | 13 | function isArray(value: unknown): value is Array { 14 | return Array.isArray(value) 15 | } 16 | 17 | function isSquaresArray(value: unknown): value is Squares { 18 | if (!isArray(value)) return false 19 | return value.length === 9 && value.every(isSquare) 20 | } 21 | 22 | function isHistory(value: unknown): value is Array { 23 | if (!isArray(value)) return false 24 | if (!value.every(isSquaresArray)) return false 25 | return true 26 | } 27 | 28 | function isStep(value: unknown): value is number { 29 | return ( 30 | typeof value === 'number' && 31 | Number.isInteger(value) && 32 | value >= 0 && 33 | value <= 9 34 | ) 35 | } 36 | 37 | export function isValidGameState(value: unknown): value is GameState { 38 | return ( 39 | typeof value === 'object' && 40 | value !== null && 41 | isHistory((value as any).history) && 42 | isStep((value as any).currentStep) 43 | ) 44 | } 45 | 46 | export function calculateStatus( 47 | winner: null | string, 48 | squares: Squares, 49 | nextValue: Player, 50 | ) { 51 | return winner 52 | ? `Winner: ${winner}` 53 | : squares.every(Boolean) 54 | ? `Scratch: Cat's game` 55 | : `Next player: ${nextValue}` 56 | } 57 | 58 | export function calculateNextValue(squares: Squares): Player { 59 | const xSquaresCount = squares.filter((r) => r === 'X').length 60 | const oSquaresCount = squares.filter((r) => r === 'O').length 61 | return oSquaresCount === xSquaresCount ? 'X' : 'O' 62 | } 63 | 64 | export function calculateWinner(squares: Squares): Player | null { 65 | const lines = [ 66 | [0, 1, 2], 67 | [3, 4, 5], 68 | [6, 7, 8], 69 | [0, 3, 6], 70 | [1, 4, 7], 71 | [2, 5, 8], 72 | [0, 4, 8], 73 | [2, 4, 6], 74 | ] 75 | for (let i = 0; i < lines.length; i++) { 76 | const line = lines[i] 77 | if (!line) continue 78 | const [a, b, c] = line 79 | if (a === undefined || b === undefined || c === undefined) continue 80 | 81 | const player = squares[a] 82 | if (player && player === squares[b] && player === squares[c]) { 83 | return player 84 | } 85 | } 86 | return null 87 | } 88 | -------------------------------------------------------------------------------- /shared/utils.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Sets the search parameters of the current URL. 3 | * 4 | * @param {Record} params - The search parameters to set. 5 | * @param {Object} options - Additional options for setting the search parameters. 6 | * @param {boolean} options.replace - Whether to replace the current URL in the history or not. 7 | * @returns {URLSearchParams} - The updated search parameters. 8 | */ 9 | export function setGlobalSearchParams( 10 | params: Record, 11 | options: { replace?: boolean } = {}, 12 | ) { 13 | const searchParams = new URLSearchParams(window.location.search) 14 | for (const [key, value] of Object.entries(params)) { 15 | if (!value) searchParams.delete(key) 16 | else searchParams.set(key, value) 17 | } 18 | const newUrl = [window.location.pathname, searchParams.toString()] 19 | .filter(Boolean) 20 | .join('?') 21 | if (options.replace) { 22 | window.history.replaceState({}, '', newUrl) 23 | } else { 24 | window.history.pushState({}, '', newUrl) 25 | } 26 | return searchParams 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "extends": ["@epic-web/config/typescript"], 4 | "compilerOptions": { 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | // makes it a bit easier for workshop participants 7 | "noUncheckedIndexedAccess": false, 8 | "paths": { 9 | "#*": ["./*"] 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------