├── .github └── workflows │ └── validate.yml ├── .gitignore ├── .npmrc ├── .vscode └── extensions.json ├── LICENSE.md ├── README.md ├── epicshop ├── .diffignore ├── .npmrc ├── Dockerfile ├── fix-watch.js ├── fix.js ├── 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.managing-ui-state │ ├── 01.problem.use-state │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.use-state │ │ ├── README.mdx │ │ ├── filtering.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 02.problem.control │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.control │ │ ├── README.mdx │ │ ├── controlled-search.test.ts │ │ ├── filtering.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 03.problem.derive │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 03.solution.derive │ │ ├── README.mdx │ │ ├── controlled-checkbox.test.ts │ │ ├── controlled-search.test.ts │ │ ├── filtering.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 04.problem.init │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 04.solution.init │ │ ├── README.mdx │ │ ├── controlled-checkbox.test.ts │ │ ├── controlled-search.test.ts │ │ ├── filtering.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── search-params.test.ts │ ├── 05.problem.cb │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 05.solution.cb │ │ ├── README.mdx │ │ ├── controlled-checkbox.test.ts │ │ ├── controlled-search.test.ts │ │ ├── filtering.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── search-params.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── 02.side-effects │ ├── 01.problem.effects │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.effects │ │ ├── README.mdx │ │ ├── controlled-checkbox.test.ts │ │ ├── controlled-search.test.ts │ │ ├── filtering.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── popstate.test.ts │ │ └── search-params.test.ts │ ├── 02.problem.cleanup │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.cleanup │ │ ├── README.mdx │ │ ├── controlled-checkbox.test.ts │ │ ├── controlled-search.test.ts │ │ ├── filtering.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── memory-leak.test.ts │ │ ├── popstate.test.ts │ │ └── search-params.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── 03.lifting-state │ ├── 01.problem.lift │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.lift │ │ ├── README.mdx │ │ ├── controlled-checkbox.test.ts │ │ ├── controlled-search.test.ts │ │ ├── filtering.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── like-post.test.ts │ │ ├── popstate.test.ts │ │ └── search-params.test.ts │ ├── 02.problem.lift-array │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.lift-array │ │ ├── README.mdx │ │ ├── controlled-checkbox.test.ts │ │ ├── controlled-search.test.ts │ │ ├── filtering.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── like-post.test.ts │ │ ├── popstate.test.ts │ │ └── search-params.test.ts │ ├── 03.problem.colocate │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 03.solution.colocate │ │ ├── README.mdx │ │ ├── controlled-checkbox.test.ts │ │ ├── controlled-search.test.ts │ │ ├── filtering.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── like-post.test.ts │ │ ├── popstate.test.ts │ │ └── search-params.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── 04.dom │ ├── 01.problem.ref │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.ref │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── tilt.test.ts │ │ └── toggle.test.ts │ ├── 02.problem.deps │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.deps │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── tilt.test.ts │ │ └── toggle.test.ts │ ├── 03.problem.primitives │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 03.solution.primitives │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── tilt.test.ts │ │ └── toggle.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── 05.unique-ids │ ├── 01.problem.use-id │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.use-id │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ └── tilt.test.ts │ ├── FINISHED.mdx │ └── README.mdx ├── 06.tic-tac-toe │ ├── 01.problem.set-state-callback │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.set-state-callback │ │ ├── README.mdx │ │ ├── board-game.test.ts │ │ ├── index.css │ │ └── index.tsx │ ├── 02.problem.local-storage │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.local-storage │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ └── local-storage.test.ts │ ├── 03.problem.history │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 03.solution.history │ │ ├── README.mdx │ │ ├── index.css │ │ ├── index.tsx │ │ └── local-storage.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 └── react-app-lifecycle.png ├── 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 | data.db 9 | /playground 10 | **/tsconfig.tsbuildinfo 11 | 12 | # in a real app you'd want to not commit the .env 13 | # file as well, but since this is for a workshop 14 | # we're going to keep them around. 15 | # .env 16 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "neotan.vscode-auto-restart-typescript-eslint-servers" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /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.* 3 | -------------------------------------------------------------------------------- /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/react-hooks ${EPICSHOP_CONTEXT_CWD} && \ 20 | cd ${EPICSHOP_CONTEXT_CWD} && \ 21 | npx epicshop start 22 | -------------------------------------------------------------------------------- /epicshop/fix-watch.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import chokidar from 'chokidar' 4 | import { $ } from 'execa' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | const here = (...p) => path.join(__dirname, ...p) 8 | 9 | const workshopRoot = here('..') 10 | 11 | const watchPath = path.join(workshopRoot, './exercises/*') 12 | const watcher = chokidar.watch(watchPath, { 13 | ignored: /(^|[/\\])\../, // ignore dotfiles 14 | persistent: true, 15 | ignoreInitial: true, 16 | depth: 2, 17 | }) 18 | 19 | const debouncedRun = debounce(run, 200) 20 | 21 | // Add event listeners. 22 | watcher 23 | .on('addDir', path => { 24 | debouncedRun() 25 | }) 26 | .on('unlinkDir', path => { 27 | // Only act if path contains two slashes (excluding the leading `./`) 28 | debouncedRun() 29 | }) 30 | .on('error', error => console.log(`Watcher error: ${error}`)) 31 | 32 | /** 33 | * Simple debounce implementation 34 | */ 35 | function debounce(fn, delay) { 36 | let timer = null 37 | return (...args) => { 38 | if (timer) clearTimeout(timer) 39 | timer = setTimeout(() => { 40 | fn(...args) 41 | }, delay) 42 | } 43 | } 44 | 45 | let running = false 46 | 47 | async function run() { 48 | if (running) { 49 | console.log('still running...') 50 | return 51 | } 52 | running = true 53 | try { 54 | await $({ 55 | stdio: 'inherit', 56 | cwd: workshopRoot, 57 | })`node ./scripts/fix.js` 58 | } catch (error) { 59 | throw error 60 | } finally { 61 | running = false 62 | } 63 | } 64 | 65 | console.log(`watching ${watchPath}`) 66 | 67 | // doing this because the watcher doesn't seem to work and I don't have time 68 | // to figure out why 🙃 69 | console.log('Polling...') 70 | setInterval(() => { 71 | run() 72 | }, 1000) 73 | 74 | console.log('running fix to start...') 75 | run() 76 | -------------------------------------------------------------------------------- /epicshop/fly.toml: -------------------------------------------------------------------------------- 1 | app = "epicweb-dev-react-hooks" 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 = { } 47 | -------------------------------------------------------------------------------- /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/workshop-app": "^5.20.1", 9 | "@epic-web/workshop-utils": "^5.20.1", 10 | "execa": "^9.5.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": ["**/*.ts", "**/*.tsx", "**/*.js"], 3 | "extends": ["@epic-web/config/typescript"], 4 | "compilerOptions": { 5 | "paths": { 6 | "#*": ["./*"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /epicshop/update-deps.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | npx npm-check-updates --dep prod,dev --upgrade --root 4 | cd epicshop && npx npm-check-updates --dep prod,dev --upgrade --root 5 | cd .. 6 | rm -rf node_modules package-lock.json ./epicshop/package-lock.json ./epicshop/node_modules ./exercises/**/node_modules 7 | npm install 8 | npm run setup 9 | npm run typecheck 10 | npm run lint --fix 11 | -------------------------------------------------------------------------------- /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.managing-ui-state/01.problem.use-state/README.mdx: -------------------------------------------------------------------------------- 1 | # useState 2 | 3 | 4 | 5 | 👨‍💼 We've got you working on a blog search page. Allow me to introduce you to 6 | 🧝‍♂️ Kellie the coworker who put this together. 7 | 8 | 🧝‍♂️ Hello! I'm Kellie the coworker and I do things for you sometimes. I've 9 | started up this app that you're going to be working with. There's a search 10 | input, a couple checkboxes, a submit button, and a list of blog posts. You've 11 | been asked to make it so when the user types into the search field it filters 12 | the list of blog posts. And when the checkboxes are checked, we want to update 13 | the filter for the user to include those values. You might find it helpful to 14 | review the code before you get started. 15 | 16 | 👨‍💼 Thanks Kellie! Ok, so we're going to take this step-by-step. For this first 17 | step, we just want you to create a state variable for the user's query and pass 18 | that along to the ``. 19 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/01.problem.use-state/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/01.managing-ui-state/01.problem.use-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts' 3 | 4 | function App() { 5 | // 🐨 call useState here and initialize the query with an empty string 6 | 7 | return ( 8 |
9 |
10 |
11 | 12 | 18 |
19 |
20 | 23 | 26 | 29 |
30 | 31 |
32 | {/* 🐨 pass the query state as a prop */} 33 | 34 |
35 | ) 36 | } 37 | 38 | function MatchingPosts({ query }: { query: string }) { 39 | const matchingPosts = getMatchingPosts(query) 40 | 41 | return ( 42 | 62 | ) 63 | } 64 | 65 | const rootEl = document.createElement('div') 66 | document.body.append(rootEl) 67 | createRoot(rootEl).render() 68 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/01.solution.use-state/README.mdx: -------------------------------------------------------------------------------- 1 | # useState 2 | 3 | 4 | 5 | 👨‍💼 Super! You've successfully filtered the blog posts based on what the user has 6 | typed. This is where we start getting into why React is so great. It makes doing 7 | stateful dynamic things like this super easy compared to the alternative of 8 | working with the DOM directly. Let's keep going. 9 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/01.solution.use-state/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/01.managing-ui-state/01.solution.use-state/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/01.managing-ui-state/01.solution.use-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts' 4 | 5 | function App() { 6 | const [query, setQuery] = useState('') 7 | 8 | return ( 9 |
10 |
11 |
12 | 13 | setQuery(e.currentTarget.value)} 18 | /> 19 |
20 |
21 | 24 | 27 | 30 |
31 | 32 |
33 | 34 |
35 | ) 36 | } 37 | 38 | function MatchingPosts({ query }: { query: string }) { 39 | const matchingPosts = getMatchingPosts(query) 40 | 41 | return ( 42 | 62 | ) 63 | } 64 | 65 | const rootEl = document.createElement('div') 66 | document.body.append(rootEl) 67 | createRoot(rootEl).render() 68 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/02.problem.control/README.mdx: -------------------------------------------------------------------------------- 1 | # Controlling Inputs 2 | 3 | 4 | 5 | 👨‍💼 When the user clicks on one of the checkboxes, we want to auto-fill that 6 | value into the search input for them. This means we need to "control" the input 7 | value so we can set it programmatically. We can do this by using the `value` 8 | prop on the input. 9 | 10 | 📜 You can read up more on this in the React docs: 11 | [Controlling an input with a state variable](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable) 12 | 13 | Once we add the query as the value for the input, we'll also want to have a 14 | function responsible for updating the query when the user checks or unchecks the 15 | checkboxes and call that in the `onChange` handler of the checkboxes. 16 | 17 | Good luck! 18 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/02.problem.control/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/01.managing-ui-state/02.problem.control/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts' 4 | 5 | function App() { 6 | const [query, setQuery] = useState('') 7 | 8 | // 🐨 make a function called handleCheck that accepts a "tag" string and a "checked" boolean 9 | // 🐨 By calling setQuery, add the tag to the query if checked and remove it if not 10 | 11 | return ( 12 |
13 |
14 |
15 | 16 | setQuery(e.currentTarget.value)} 22 | /> 23 |
24 |
25 | 32 | 39 | 46 |
47 | 48 |
49 | 50 |
51 | ) 52 | } 53 | 54 | function MatchingPosts({ query }: { query: string }) { 55 | const matchingPosts = getMatchingPosts(query) 56 | 57 | return ( 58 | 78 | ) 79 | } 80 | 81 | const rootEl = document.createElement('div') 82 | document.body.append(rootEl) 83 | createRoot(rootEl).render() 84 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/02.solution.control/README.mdx: -------------------------------------------------------------------------------- 1 | # Controlling Inputs 2 | 3 | 4 | 5 | 👨‍💼 Great! Now you can add/remove each of these tags to the search when they're 6 | checked. Programmatically controlling the input value is why you use the `value` 7 | prop. 8 | 9 | But the feature is incomplete because a user could manually type the tag into 10 | the search input and it would not be reflected in the checkbox. Let's look at 11 | that next. 12 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/02.solution.control/controlled-search.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can select the dog checkbox', async () => { 25 | fireEvent.click(dogCheckbox) 26 | expect(dogCheckbox).toBeChecked() 27 | }) 28 | 29 | await testStep( 30 | 'Selecting the checkbox updates the search and results', 31 | async () => { 32 | // Check that the search box value has been updated 33 | expect(searchBox).toHaveValue('dog') 34 | 35 | // Check that the results have been filtered 36 | await dtl.waitFor(async () => { 37 | await screen.findByText(/the joy of owning a dog/i) 38 | 39 | const catResult = screen.queryByText(/caring for your feline friend/i) 40 | expect(catResult).not.toBeInTheDocument() 41 | }) 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/02.solution.control/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/01.managing-ui-state/02.solution.control/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/01.managing-ui-state/02.solution.control/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts' 4 | 5 | function App() { 6 | const [query, setQuery] = useState('') 7 | 8 | function handleCheck(tag: string, checked: boolean) { 9 | const words = query.split(' ') 10 | const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) 11 | setQuery(newWords.filter(Boolean).join(' ').trim()) 12 | } 13 | 14 | return ( 15 |
16 |
17 |
18 | 19 | setQuery(e.currentTarget.value)} 25 | /> 26 |
27 |
28 | 35 | 42 | 51 |
52 | 53 |
54 | 55 |
56 | ) 57 | } 58 | 59 | function MatchingPosts({ query }: { query: string }) { 60 | const matchingPosts = getMatchingPosts(query) 61 | 62 | return ( 63 | 83 | ) 84 | } 85 | 86 | const rootEl = document.createElement('div') 87 | document.body.append(rootEl) 88 | createRoot(rootEl).render() 89 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/03.problem.derive/README.mdx: -------------------------------------------------------------------------------- 1 | # Derive State 2 | 3 | 4 | 5 | 🦉 Often, it can be easy to think you need to keep track of two elements of 6 | state when you really only need one. For example, let's say you have a counter 7 | that will display the number of times a user has clicked a button and also it 8 | will display whether that number is odd or even. You might be tempted to write 9 | the following code: 10 | 11 | ```tsx 12 | import { useState } from 'react' 13 | 14 | export default function Counter() { 15 | const [count, setCount] = useState(0) 16 | const [isEven, setIsEven] = useState(true) 17 | 18 | function handleClick() { 19 | const newCount = count + 1 20 | setCount(newCount) 21 | setIsEven(newCount % 2 === 0) 22 | } 23 | 24 | return ( 25 |
26 |

{count}

27 |

{isEven ? 'Even' : 'Odd'}

28 | 29 |
30 | ) 31 | } 32 | ``` 33 | 34 | This code works, but it's not ideal because it's keeping track of two pieces of 35 | state when it only needs to keep track of one. Imagine if we had multiple places 36 | where the count could be changed. We'd have to remember to update the `isEven` 37 | state in all of those places. This is a recipe for bugs. 38 | 39 | Instead, we can derive the `isEven` state from the `count` state: 40 | 41 | ```tsx 42 | import { useState } from 'react' 43 | 44 | export default function Counter() { 45 | const [count, setCount] = useState(0) 46 | 47 | function handleClick() { 48 | const newCount = count + 1 49 | setCount(newCount) 50 | } 51 | 52 | // this is the derived state 53 | const isEven = count % 2 === 0 54 | 55 | return ( 56 |
57 |

{count}

58 |

{isEven ? 'Even' : 'Odd'}

59 | 60 |
61 | ) 62 | } 63 | ``` 64 | 65 | This is a much better solution because we only have to keep track of the `count` 66 | state. The `isEven` state is derived from the `count` state. This means we don't 67 | have to worry about keeping the `isEven` state in sync with the `count` state. 68 | 69 | 👨‍💼 Thanks Olivia! So what we want to do in this step is derive the checkboxes' 70 | `checked` state based on whether the query contains the word they represent. 71 | 72 | Give that a shot. 73 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/03.problem.derive/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/01.managing-ui-state/03.problem.derive/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts' 4 | 5 | function App() { 6 | const [query, setQuery] = useState('') 7 | // 🐨 move the words variable from handleCheck to here 8 | // 🦉 this is deriving state! 9 | 10 | // 🐨 create a dogChecked variable that is whether words includes "dog" 11 | // and do the same for "cat" and "caterpillar" 12 | // 🦉 this is deriving state from derived state! 13 | 14 | function handleCheck(tag: string, checked: boolean) { 15 | // 🐨 move the words variable up to just below the useState call 16 | const words = query.split(' ') 17 | const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) 18 | setQuery(newWords.filter(Boolean).join(' ').trim()) 19 | } 20 | 21 | return ( 22 |
23 |
24 |
25 | 26 | setQuery(e.currentTarget.value)} 32 | /> 33 |
34 |
35 | 43 | 51 | 61 |
62 | 63 |
64 | 65 |
66 | ) 67 | } 68 | 69 | function MatchingPosts({ query }: { query: string }) { 70 | const matchingPosts = getMatchingPosts(query) 71 | 72 | return ( 73 | 93 | ) 94 | } 95 | 96 | const rootEl = document.createElement('div') 97 | document.body.append(rootEl) 98 | createRoot(rootEl).render() 99 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/03.solution.derive/README.mdx: -------------------------------------------------------------------------------- 1 | # Derive State 2 | 3 | 4 | 5 | 👨‍💼 Awesome work! It looks really cool to have the checkboxes check and uncheck 6 | automatically as you type! We're now controlling both all the inputs on here 7 | all based on a single element of state. Well done. 8 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/03.solution.derive/controlled-checkbox.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can search for a checkbox value', async () => { 25 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 26 | }) 27 | 28 | await testStep('checkbox is checked automatically', async () => { 29 | expect(dogCheckbox).toBeChecked() 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/03.solution.derive/controlled-search.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can select the dog checkbox', async () => { 25 | fireEvent.click(dogCheckbox) 26 | expect(dogCheckbox).toBeChecked() 27 | }) 28 | 29 | await testStep( 30 | 'Selecting the checkbox updates the search and results', 31 | async () => { 32 | // Check that the search box value has been updated 33 | expect(searchBox).toHaveValue('dog') 34 | 35 | // Check that the results have been filtered 36 | await dtl.waitFor(async () => { 37 | await screen.findByText(/the joy of owning a dog/i) 38 | 39 | const catResult = screen.queryByText(/caring for your feline friend/i) 40 | expect(catResult).not.toBeInTheDocument() 41 | }) 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/03.solution.derive/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/01.managing-ui-state/03.solution.derive/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/01.managing-ui-state/03.solution.derive/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts' 4 | 5 | function App() { 6 | const [query, setQuery] = useState('') 7 | const words = query.split(' ') 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 | setQuery(newWords.filter(Boolean).join(' ').trim()) 16 | } 17 | 18 | return ( 19 |
20 |
21 |
22 | 23 | setQuery(e.currentTarget.value)} 29 | /> 30 |
31 |
32 | 40 | 48 | 58 |
59 | 60 |
61 | 62 |
63 | ) 64 | } 65 | 66 | function MatchingPosts({ query }: { query: string }) { 67 | const matchingPosts = getMatchingPosts(query) 68 | 69 | return ( 70 | 90 | ) 91 | } 92 | 93 | const rootEl = document.createElement('div') 94 | document.body.append(rootEl) 95 | createRoot(rootEl).render() 96 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/04.problem.init/README.mdx: -------------------------------------------------------------------------------- 1 | # Initialize State 2 | 3 | 4 | 5 | 👨‍💼 We want users to be able to share a link to this page with a prefilled 6 | search. For example: 7 | [`https://www.example.com/search?query=cat+dog`](/app/playground?query=cat+dog). 8 | 9 | Right now, this won't work, because we don't have a way to initialize the 10 | state of the search box from the URL. Let's fix that. 11 | 12 | The `useState` hook supports an initial value. Right now we're just passing 13 | `''` as the initial value, but we can use the `URLSearchParams` API to get the 14 | query string from the URL and use that as the initial value. 15 | 16 | ```tsx 17 | const params = new URLSearchParams(window.location.search) 18 | const initialQuery = params.get('query') ?? '' 19 | ``` 20 | 21 | Then you can pass that `initialQuery` to `useState`. Give that a shot and then 22 | the link above should work! 23 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/04.problem.init/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/01.managing-ui-state/04.solution.init/README.mdx: -------------------------------------------------------------------------------- 1 | # Initialize State 2 | 3 | 4 | 5 | 👨‍💼 Great! Now our users can share links to this page that initialize the search 6 | to something specific. 7 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/04.solution.init/controlled-checkbox.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can search for a checkbox value', async () => { 25 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 26 | }) 27 | 28 | await testStep('checkbox is checked automatically', async () => { 29 | expect(dogCheckbox).toBeChecked() 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/04.solution.init/controlled-search.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can select the dog checkbox', async () => { 25 | fireEvent.click(dogCheckbox) 26 | expect(dogCheckbox).toBeChecked() 27 | }) 28 | 29 | await testStep( 30 | 'Selecting the checkbox updates the search and results', 31 | async () => { 32 | // Check that the search box value has been updated 33 | expect(searchBox).toHaveValue('dog') 34 | 35 | // Check that the results have been filtered 36 | await dtl.waitFor(async () => { 37 | await screen.findByText(/the joy of owning a dog/i) 38 | 39 | const catResult = screen.queryByText(/caring for your feline friend/i) 40 | expect(catResult).not.toBeInTheDocument() 41 | }) 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/04.solution.init/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/01.managing-ui-state/04.solution.init/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/01.managing-ui-state/04.solution.init/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts' 4 | 5 | function App() { 6 | // NOTE: this will not work with server rendering, but in a real app you can 7 | // use react-router's useSearchParams instead 8 | const params = new URLSearchParams(window.location.search) 9 | const [query, setQuery] = useState(params.get('query') ?? '') 10 | const words = query.split(' ') 11 | 12 | const dogChecked = words.includes('dog') 13 | const catChecked = words.includes('cat') 14 | const caterpillarChecked = words.includes('caterpillar') 15 | 16 | function handleCheck(tag: string, checked: boolean) { 17 | const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) 18 | setQuery(newWords.filter(Boolean).join(' ').trim()) 19 | } 20 | 21 | return ( 22 |
23 |
24 |
25 | 26 | setQuery(e.currentTarget.value)} 32 | /> 33 |
34 |
35 | 43 | 51 | 61 |
62 | 63 |
64 | 65 |
66 | ) 67 | } 68 | 69 | function MatchingPosts({ query }: { query: string }) { 70 | const matchingPosts = getMatchingPosts(query) 71 | 72 | return ( 73 | 93 | ) 94 | } 95 | 96 | const rootEl = document.createElement('div') 97 | document.body.append(rootEl) 98 | createRoot(rootEl).render() 99 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/04.solution.init/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 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/05.problem.cb/README.mdx: -------------------------------------------------------------------------------- 1 | # Init Callback 2 | 3 | 4 | 5 | 🦉 There's one more thing you should know about `useState` initialization and 6 | that is a small performance optimization. `useState` can accept a function. 7 | 8 | You may recall from earlier we mentioned that the first argument to `useState` 9 | is only used during the initial render. It's not used on subsequent renders. 10 | This is because the initial value is only used when the component is first 11 | rendered. After that, the value is managed by React and you use the updater 12 | function to update it. 13 | 14 | But imagine a situation where calculating that initial value were 15 | computationally expensive. It would be a waste to compute the initial value for 16 | all but the initial render right? That's where the function form of `useState` 17 | comes in. 18 | 19 | Let's imagine we have a function that calculates the initial value and it's 20 | computationally expensive: 21 | 22 | ```tsx 23 | const [val, setVal] = useState(calculateInitialValue()) 24 | ``` 25 | 26 | This will work just fine, but it's not ideal. The `calculateInitialValue` will 27 | be called on every render, even though it's only needed for the initial render. 28 | So instead of calling the function, we can just pass it: 29 | 30 | ```tsx 31 | const [val, setVal] = useState(calculateInitialValue) 32 | ``` 33 | 34 | Typically doing this is unnecessary, but it's good to know about in case you 35 | need it. 36 | 37 | So 38 | 39 | ```tsx 40 | // This will call getQueryParam on every render, undermining our optimization! 😵 41 | const [query, setQuery] = useState(getQueryParam()) 42 | 43 | // This will _only_ call getQueryParam on init. Great! ✅ 44 | const [query, setQuery] = useState(getQueryParam) 45 | ``` 46 | 47 | You're going to be making the `getQueryParam` function. Got it? Great, let's go! 48 | 49 | 50 | 🚨 Note, we can't reasonably test whether you're doing this right so the tests 51 | are passing from the get-go, but you'll know you didn't break anything if the 52 | tests are still working when you're finished. 53 | 54 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/05.problem.cb/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/01.managing-ui-state/05.solution.cb/README.mdx: -------------------------------------------------------------------------------- 1 | # Init Callback 2 | 3 | 4 | 5 | 👨‍💼 Great! This isn't 100% necessary as a performance optimization, but it's easy 6 | and doesn't hurt readability so we may as well! 7 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/05.solution.cb/controlled-checkbox.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can search for a checkbox value', async () => { 25 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 26 | }) 27 | 28 | await testStep('checkbox is checked automatically', async () => { 29 | expect(dogCheckbox).toBeChecked() 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/05.solution.cb/controlled-search.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can select the dog checkbox', async () => { 25 | fireEvent.click(dogCheckbox) 26 | expect(dogCheckbox).toBeChecked() 27 | }) 28 | 29 | await testStep( 30 | 'Selecting the checkbox updates the search and results', 31 | async () => { 32 | // Check that the search box value has been updated 33 | expect(searchBox).toHaveValue('dog') 34 | 35 | // Check that the results have been filtered 36 | await dtl.waitFor(async () => { 37 | await screen.findByText(/the joy of owning a dog/i) 38 | 39 | const catResult = screen.queryByText(/caring for your feline friend/i) 40 | expect(catResult).not.toBeInTheDocument() 41 | }) 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/05.solution.cb/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/01.managing-ui-state/05.solution.cb/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/01.managing-ui-state/05.solution.cb/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts' 4 | 5 | function getQueryParam() { 6 | const params = new URLSearchParams(window.location.search) 7 | return params.get('query') ?? '' 8 | } 9 | 10 | function App() { 11 | const [query, setQuery] = useState(getQueryParam) 12 | const words = query.split(' ') 13 | 14 | const dogChecked = words.includes('dog') 15 | const catChecked = words.includes('cat') 16 | const caterpillarChecked = words.includes('caterpillar') 17 | 18 | function handleCheck(tag: string, checked: boolean) { 19 | const newWords = checked ? [...words, tag] : words.filter(w => w !== tag) 20 | setQuery(newWords.filter(Boolean).join(' ').trim()) 21 | } 22 | 23 | return ( 24 |
25 |
26 |
27 | 28 | setQuery(e.currentTarget.value)} 34 | /> 35 |
36 |
37 | 45 | 53 | 63 |
64 | 65 |
66 | 67 |
68 | ) 69 | } 70 | 71 | function MatchingPosts({ query }: { query: string }) { 72 | const matchingPosts = getMatchingPosts(query) 73 | 74 | return ( 75 | 95 | ) 96 | } 97 | 98 | const rootEl = document.createElement('div') 99 | document.body.append(rootEl) 100 | createRoot(rootEl).render() 101 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/05.solution.cb/search-params.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen } = dtl 3 | 4 | const currentPath = window.location.pathname 5 | window.history.pushState({}, '', `${currentPath}?query=dog`) 6 | 7 | await import('./index.tsx') 8 | 9 | await testStep( 10 | 'The search box is initialized with URL query parameter', 11 | async () => { 12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i }) 13 | expect(searchBox).toHaveValue('dog') 14 | }, 15 | ) 16 | -------------------------------------------------------------------------------- /exercises/01.managing-ui-state/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Managing UI State 2 | 3 | 4 | 5 | 👨‍💼 Great work! You now know how to manage client-side component state in a React 6 | application! 7 | -------------------------------------------------------------------------------- /exercises/02.side-effects/01.problem.effects/README.mdx: -------------------------------------------------------------------------------- 1 | # useEffect 2 | 3 | 4 | 5 | 👨‍💼 We want the query to update as the query params change through a 6 | [`popstate`](https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event) 7 | event. Once you added the `useEffect` hook, you then have to prevent the default 8 | full refresh of the page when the user clicks the back/forward button. 9 | 10 | 🧝‍♂️ I added a new utility called `setGlobalSearchParams` which allows us to set 11 | the URL search params without triggering a full-page refresh. So whenever the 12 | user clicks "submit," we update the URL search params. The trouble is, when they 13 | hit the back button, the search doesn't stay synchronized with the URL (yet). 14 | 15 | You can take a look at my changes for 16 | details on what I did. 17 | 18 | 👨‍💼 Thanks Kellie. So what we want you to do is make it so when the user hits the 19 | back button in their browser and the search params are updated, we update the input 20 | value. 21 | 22 | The only way to be notified of a back/forward event is to listen for the 23 | `popstate` event. You can do this by adding an event listener to the `window` 24 | object. 25 | 26 | ```tsx 27 | window.addEventListener('popstate', () => { 28 | // update the input value 29 | }) 30 | ``` 31 | 32 | To test whether you got this right follow these steps: 33 | 34 | - Go to (in a new tab) 35 | - Add " cat" in the input 36 | - Click "submit" 37 | - Click the back button in your browser 38 | - The input value should now be "dog" 39 | 40 | That last step will be broken and that's what you should fix in this step. 41 | 42 | 43 | Spoiler alert: there's going to be a bug with our implementation that we'll 44 | fix in the next step, so if you notice a bug... we'll get to it 😅 45 | 46 | 47 | One more thing, we're going to need to retrieve the query param in two places. 48 | Right now we get it to initialize our state, but we'll also want to get it when 49 | the `popstate` event fires. So you'll be extracting that logic to a small 50 | function and we'll pass that as a function to our `useState` (using the lazy 51 | initializer as before). 52 | 53 | In our case the performance difference is so small it's not really worth it, but 54 | since we're making a function anyway, you can simply pass the function to 55 | `useState` directly: 56 | 57 | ```tsx 58 | // both of these work just fine: 59 | const [query, setQuery] = useState(getQueryParam()) 60 | const [query, setQuery] = useState(getQueryParam) 61 | ``` 62 | 63 | You're going to be making the `getQueryParam` function. Got it? Great, let's go! 64 | -------------------------------------------------------------------------------- /exercises/02.side-effects/01.problem.effects/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.side-effects/01.solution.effects/README.mdx: -------------------------------------------------------------------------------- 1 | # useEffect 2 | 3 | 4 | 5 | 👨‍💼 Great! Now you know how to make sure side-effects like global event listeners 6 | can be registered from within a React component and how to integrate that with 7 | the state of your React component. But we've still got a bug, so let's get to 8 | that. 9 | -------------------------------------------------------------------------------- /exercises/02.side-effects/01.solution.effects/controlled-checkbox.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can search for a checkbox value', async () => { 25 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 26 | }) 27 | 28 | await testStep('checkbox is checked automatically', async () => { 29 | expect(dogCheckbox).toBeChecked() 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/02.side-effects/01.solution.effects/controlled-search.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can select the dog checkbox', async () => { 25 | fireEvent.click(dogCheckbox) 26 | expect(dogCheckbox).toBeChecked() 27 | }) 28 | 29 | await testStep( 30 | 'Selecting the checkbox updates the search and results', 31 | async () => { 32 | // Check that the search box value has been updated 33 | expect(searchBox).toHaveValue('dog') 34 | 35 | // Check that the results have been filtered 36 | await dtl.waitFor(async () => { 37 | await screen.findByText(/the joy of owning a dog/i) 38 | 39 | const catResult = screen.queryByText(/caring for your feline friend/i) 40 | expect(catResult).not.toBeInTheDocument() 41 | }) 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /exercises/02.side-effects/01.solution.effects/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/02.side-effects/01.solution.effects/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.side-effects/01.solution.effects/popstate.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | const currentPath = window.location.pathname 5 | window.history.pushState({}, '', `${currentPath}?query=dog`) 6 | 7 | await import('./index.tsx') 8 | 9 | await testStep( 10 | 'The search box is initialized with URL query parameter', 11 | async () => { 12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i }) 13 | expect(searchBox).toHaveValue('dog') 14 | }, 15 | ) 16 | 17 | // wait for the event handler to be set up 18 | // for some reason it takes a bit 19 | await new Promise(resolve => setTimeout(resolve, 100)) 20 | 21 | await testStep( 22 | 'The search box updates when popstate event is triggered', 23 | async () => { 24 | // Simulate navigation to a new URL 25 | const currentPath = window.location.pathname 26 | window.history.pushState({}, '', `${currentPath}?query=cat`) 27 | 28 | // Trigger popstate event 29 | fireEvent.popState(window) 30 | 31 | // Check if the search box value is updated 32 | await dtl.waitFor(async () => 33 | expect( 34 | await screen.findByRole('searchbox', { name: /search/i }), 35 | ).toHaveValue('cat'), 36 | ) 37 | }, 38 | ) 39 | -------------------------------------------------------------------------------- /exercises/02.side-effects/01.solution.effects/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 | -------------------------------------------------------------------------------- /exercises/02.side-effects/02.problem.cleanup/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.side-effects/02.solution.cleanup/README.mdx: -------------------------------------------------------------------------------- 1 | # Effect Cleanup 2 | 3 | 4 | 5 | 👨‍💼 Phew! I'm glad we're not going to run into memory leaks now. Our users 6 | (especially) those on low-end devices will thank us for it! 7 | -------------------------------------------------------------------------------- /exercises/02.side-effects/02.solution.cleanup/controlled-checkbox.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can search for a checkbox value', async () => { 25 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 26 | }) 27 | 28 | await testStep('checkbox is checked automatically', async () => { 29 | expect(dogCheckbox).toBeChecked() 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/02.side-effects/02.solution.cleanup/controlled-search.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can select the dog checkbox', async () => { 25 | fireEvent.click(dogCheckbox) 26 | expect(dogCheckbox).toBeChecked() 27 | }) 28 | 29 | await testStep( 30 | 'Selecting the checkbox updates the search and results', 31 | async () => { 32 | // Check that the search box value has been updated 33 | expect(searchBox).toHaveValue('dog') 34 | 35 | // Check that the results have been filtered 36 | await dtl.waitFor(async () => { 37 | await screen.findByText(/the joy of owning a dog/i) 38 | 39 | const catResult = screen.queryByText(/caring for your feline friend/i) 40 | expect(catResult).not.toBeInTheDocument() 41 | }) 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /exercises/02.side-effects/02.solution.cleanup/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/02.side-effects/02.solution.cleanup/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.side-effects/02.solution.cleanup/memory-leak.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | // Import the component 5 | await import('./index.tsx') 6 | 7 | declare global { 8 | interface Performance { 9 | memory?: { 10 | usedJSHeapSize: number 11 | jsHeapSizeLimit: number 12 | totalJSHeapSize: number 13 | } 14 | } 15 | } 16 | 17 | if (performance.memory) { 18 | async function toggleShowForm(times: number) { 19 | const checkbox = await screen.findByLabelText(/show form/i) 20 | for (let i = 0; i < times; i++) { 21 | fireEvent.click(checkbox) 22 | // Wait for any asynchronous operations to complete 23 | await new Promise(resolve => setTimeout(resolve, 10)) 24 | } 25 | } 26 | 27 | await testStep( 28 | 'Memory usage does not increase linearly when toggling showForm', 29 | async () => { 30 | // Check if memory measurement is available 31 | if (!performance.memory) { 32 | console.warn( 33 | 'Memory measurement is not available in this browser. Skipping test.', 34 | ) 35 | return 36 | } 37 | 38 | // wait a bit for garbage collection to finish 39 | await new Promise(resolve => setTimeout(resolve, 500)) 40 | const initialMemory = performance.memory.usedJSHeapSize 41 | 42 | await toggleShowForm(250) 43 | 44 | // wait a bit for garbage collection to finish 45 | await new Promise(resolve => setTimeout(resolve, 500)) 46 | 47 | const finalMemory = performance.memory.usedJSHeapSize 48 | 49 | const initialMemoryMB = 50 | (initialMemory / (1024 * 1024)).toLocaleString() + ' MB' 51 | const finalMemoryMB = 52 | (finalMemory / (1024 * 1024)).toLocaleString() + ' MB' 53 | 54 | const percentageChange = ( 55 | ((finalMemory - initialMemory) / initialMemory) * 56 | 100 57 | ).toFixed(2) 58 | expect( 59 | Number(percentageChange), 60 | `🚨 The memory usage increased from ${initialMemoryMB} to ${finalMemoryMB} (a ${percentageChange}% increase)`, 61 | ).toBeLessThan(110) 62 | }, 63 | ) 64 | } else { 65 | await testStep( 66 | 'Memory measurement is not available in this browser. Skipping test.', 67 | () => {}, 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /exercises/02.side-effects/02.solution.cleanup/popstate.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | const currentPath = window.location.pathname 5 | window.history.pushState({}, '', `${currentPath}?query=dog`) 6 | 7 | await import('./index.tsx') 8 | 9 | await testStep( 10 | 'The search box is initialized with URL query parameter', 11 | async () => { 12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i }) 13 | expect(searchBox).toHaveValue('dog') 14 | }, 15 | ) 16 | 17 | // wait for the event handler to be set up 18 | // for some reason it takes a bit 19 | await new Promise(resolve => setTimeout(resolve, 100)) 20 | 21 | await testStep( 22 | 'The search box updates when popstate event is triggered', 23 | async () => { 24 | // Simulate navigation to a new URL 25 | const currentPath = window.location.pathname 26 | window.history.pushState({}, '', `${currentPath}?query=cat`) 27 | 28 | // Trigger popstate event 29 | fireEvent.popState(window) 30 | 31 | // Check if the search box value is updated 32 | await dtl.waitFor(async () => 33 | expect( 34 | await screen.findByRole('searchbox', { name: /search/i }), 35 | ).toHaveValue('cat'), 36 | ) 37 | }, 38 | ) 39 | -------------------------------------------------------------------------------- /exercises/02.side-effects/02.solution.cleanup/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 | -------------------------------------------------------------------------------- /exercises/02.side-effects/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Side-Effects 2 | 3 | 4 | 5 | 👨‍💼 Great work! You've learned the basics of how to trigger side-effects when our 6 | component is added to the page and how to properly clean up when it's through. 7 | You've not learned everything there is to know about `useEffect` yet (that 8 | dependency array is still a topic we need to get into), but we'll get into the 9 | rest of that soon. 10 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/01.problem.lift/README.mdx: -------------------------------------------------------------------------------- 1 | # Lift State 2 | 3 | 4 | 5 | 🧝‍♂️ I refactored the app a bit. In the process I kinda broke stuff and you'll be 6 | required to fix it. Feel free to check the diff 7 | to know what I've done. I pretty much just moved stuff into separate components 8 | to get a little more organization in here. 9 | 10 | 11 | 🦉 Check out [When to break up a component into multiple 12 | components](https://kentcdodds.com/blog/when-to-break-up-a-component-into-multiple-components). 13 | 14 | 15 | 🧝‍♂️ Oh, also I added a new feature. You can now "like" specific posts by clicking 16 | a little heart on the post. How nice! It's not persisted anywhere yet, so when 17 | you refresh the page the likes all get reset, but it's a nice start! 18 | 19 | 👨‍💼 Great, now your job is to fix what's broken. You need to move not only the 20 | state, but also we're going to have you move the `useEffect` that sets the query 21 | as well. 22 | 23 | Then you're going to need to pass props to the components as needed. Enjoy! 24 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/01.problem.lift/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.lifting-state/01.solution.lift/README.mdx: -------------------------------------------------------------------------------- 1 | # Lift State 2 | 3 | 4 | 5 | 👨‍💼 Great! It's working now! 6 | 7 | Hmmm... What's that I hear? It's the sound of a new requirement coming in! 😆 8 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/01.solution.lift/controlled-checkbox.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can search for a checkbox value', async () => { 25 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 26 | }) 27 | 28 | await testStep('checkbox is checked automatically', async () => { 29 | expect(dogCheckbox).toBeChecked() 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/01.solution.lift/controlled-search.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can select the dog checkbox', async () => { 25 | fireEvent.click(dogCheckbox) 26 | expect(dogCheckbox).toBeChecked() 27 | }) 28 | 29 | await testStep( 30 | 'Selecting the checkbox updates the search and results', 31 | async () => { 32 | // Check that the search box value has been updated 33 | expect(searchBox).toHaveValue('dog') 34 | 35 | // Check that the results have been filtered 36 | await dtl.waitFor(async () => { 37 | await screen.findByText(/the joy of owning a dog/i) 38 | 39 | const catResult = screen.queryByText(/caring for your feline friend/i) 40 | expect(catResult).not.toBeInTheDocument() 41 | }) 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/01.solution.lift/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/03.lifting-state/01.solution.lift/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.lifting-state/01.solution.lift/like-post.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 posts', async () => { 7 | await screen.findByText(/caring for your feline friend/i) 8 | await screen.findByText(/the joy of owning a dog/i) 9 | }) 10 | 11 | const likeButtons = await testStep( 12 | 'The user can see like buttons', 13 | async () => { 14 | const buttons = await screen.findAllByRole('button', { 15 | name: /add favorite/i, 16 | }) 17 | expect(buttons.length).toBeGreaterThan(0) 18 | return buttons 19 | }, 20 | ) 21 | 22 | const totalLikeButtons = likeButtons.length 23 | 24 | await testStep('The user can like a post', async () => { 25 | fireEvent.click(likeButtons[0]) 26 | await screen.findByRole('button', { name: /remove favorite/i }) 27 | }) 28 | 29 | await testStep('The user can unlike a post', async () => { 30 | const unlikeButton = await screen.findByRole('button', { 31 | name: /remove favorite/i, 32 | }) 33 | fireEvent.click(unlikeButton) 34 | await dtl.waitFor(() => 35 | expect( 36 | screen.queryByRole('button', { name: /remove favorite/i }), 37 | ).not.toBeInTheDocument(), 38 | ) 39 | const buttons = await screen.findAllByRole('button', { 40 | name: /add favorite/i, 41 | }) 42 | expect(buttons.length).toBe(totalLikeButtons) 43 | }) 44 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/01.solution.lift/popstate.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | const currentPath = window.location.pathname 5 | window.history.pushState({}, '', `${currentPath}?query=dog`) 6 | 7 | await import('./index.tsx') 8 | 9 | await testStep( 10 | 'The search box is initialized with URL query parameter', 11 | async () => { 12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i }) 13 | expect(searchBox).toHaveValue('dog') 14 | }, 15 | ) 16 | 17 | // wait for the event handler to be set up 18 | // for some reason it takes a bit 19 | await new Promise(resolve => setTimeout(resolve, 100)) 20 | 21 | await testStep( 22 | 'The search box updates when popstate event is triggered', 23 | async () => { 24 | // Simulate navigation to a new URL 25 | const currentPath = window.location.pathname 26 | window.history.pushState({}, '', `${currentPath}?query=cat`) 27 | 28 | // Trigger popstate event 29 | fireEvent.popState(window) 30 | 31 | // Check if the search box value is updated 32 | await dtl.waitFor(async () => 33 | expect( 34 | await screen.findByRole('searchbox', { name: /search/i }), 35 | ).toHaveValue('cat'), 36 | ) 37 | }, 38 | ) 39 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/01.solution.lift/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 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/02.problem.lift-array/README.mdx: -------------------------------------------------------------------------------- 1 | # Lift More State 2 | 3 | 4 | 5 | 👨‍💼 The users loved 🧝‍♂️ Kellie's like feature so much they want to have the blog 6 | posts sorted by whether they're favorites. 7 | 8 | 🧝‍♂️ I've already created the sort function. Now you need to add the logic to sort 9 | them based on whether they're favorited. And that means you need to lift some 10 | state... 11 | 12 | 👨‍💼 Yep. We're going to need you to lift the favorited state from the cards to 13 | the `MatchingPost` component. We'll also need to restructure it slightly as 14 | well. Don't worry. The emoji will guide you. Good luck! 15 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/02.problem.lift-array/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.lifting-state/02.solution.lift-array/README.mdx: -------------------------------------------------------------------------------- 1 | # Lift More State 2 | 3 | 4 | 5 | 👨‍💼 Great job! Unfortunately... I have some bad news. Let's talk about that next. 6 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/02.solution.lift-array/controlled-checkbox.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can search for a checkbox value', async () => { 25 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 26 | }) 27 | 28 | await testStep('checkbox is checked automatically', async () => { 29 | expect(dogCheckbox).toBeChecked() 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/02.solution.lift-array/controlled-search.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can select the dog checkbox', async () => { 25 | fireEvent.click(dogCheckbox) 26 | expect(dogCheckbox).toBeChecked() 27 | }) 28 | 29 | await testStep( 30 | 'Selecting the checkbox updates the search and results', 31 | async () => { 32 | // Check that the search box value has been updated 33 | expect(searchBox).toHaveValue('dog') 34 | 35 | // Check that the results have been filtered 36 | await dtl.waitFor(async () => { 37 | await screen.findByText(/the joy of owning a dog/i) 38 | 39 | const catResult = screen.queryByText(/caring for your feline friend/i) 40 | expect(catResult).not.toBeInTheDocument() 41 | }) 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/02.solution.lift-array/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/03.lifting-state/02.solution.lift-array/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.lifting-state/02.solution.lift-array/like-post.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 posts', async () => { 7 | await screen.findByText(/caring for your feline friend/i) 8 | await screen.findByText(/the joy of owning a dog/i) 9 | }) 10 | 11 | const likeButtons = await testStep( 12 | 'The user can see like buttons', 13 | async () => { 14 | const buttons = await screen.findAllByRole('button', { 15 | name: /add favorite/i, 16 | }) 17 | expect(buttons.length).toBeGreaterThan(0) 18 | return buttons 19 | }, 20 | ) 21 | 22 | const totalLikeButtons = likeButtons.length 23 | 24 | await testStep('The user can like a post', async () => { 25 | fireEvent.click(likeButtons[1]) 26 | await screen.findByRole('button', { name: /remove favorite/i }) 27 | }) 28 | 29 | await testStep('The liked post moves to the top', async () => { 30 | const posts = screen.getAllByRole('listitem') 31 | const firstPost = posts[0] 32 | expect( 33 | firstPost, 34 | 'The first post should have a remove favorite button', 35 | ).toContainElement(screen.getByRole('button', { name: /remove favorite/i })) 36 | }) 37 | 38 | await testStep('The user can unlike a post', async () => { 39 | const unlikeButton = await screen.findByRole('button', { 40 | name: /remove favorite/i, 41 | }) 42 | fireEvent.click(unlikeButton) 43 | 44 | await dtl.waitFor(() => 45 | expect( 46 | screen.queryByRole('button', { name: /remove favorite/i }), 47 | ).not.toBeInTheDocument(), 48 | ) 49 | const buttons = await screen.findAllByRole('button', { 50 | name: /add favorite/i, 51 | }) 52 | expect(buttons.length).toBe(totalLikeButtons) 53 | }) 54 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/02.solution.lift-array/popstate.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | const currentPath = window.location.pathname 5 | window.history.pushState({}, '', `${currentPath}?query=dog`) 6 | 7 | await import('./index.tsx') 8 | 9 | await testStep( 10 | 'The search box is initialized with URL query parameter', 11 | async () => { 12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i }) 13 | expect(searchBox).toHaveValue('dog') 14 | }, 15 | ) 16 | 17 | // wait for the event handler to be set up 18 | // for some reason it takes a bit 19 | await new Promise(resolve => setTimeout(resolve, 100)) 20 | 21 | await testStep( 22 | 'The search box updates when popstate event is triggered', 23 | async () => { 24 | // Simulate navigation to a new URL 25 | const currentPath = window.location.pathname 26 | window.history.pushState({}, '', `${currentPath}?query=cat`) 27 | 28 | // Trigger popstate event 29 | fireEvent.popState(window) 30 | 31 | // Check if the search box value is updated 32 | await dtl.waitFor(async () => 33 | expect( 34 | await screen.findByRole('searchbox', { name: /search/i }), 35 | ).toHaveValue('cat'), 36 | ) 37 | }, 38 | ) 39 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/02.solution.lift-array/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 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/03.problem.colocate/README.mdx: -------------------------------------------------------------------------------- 1 | # Colocate State 2 | 3 | 4 | 5 | 👨‍💼 Well, the users thought they wanted the articles sorted by whether they were 6 | favorited... But after using the feature, they found it to be jarring and 7 | confusing. So we're going to need you to remove that feature. Kellie already 8 | removed the sorting for you, but she didn't have time to move the state back to 9 | the `Card` component. 10 | 11 | As a community we're pretty good at lifting state. It becomes natural over time. 12 | In fact it's required to make the feature work. But as you notice here, the 13 | functionality we want is already "working" without moving any of the state 14 | around so it's easy to forget to improve the performance and maintainability of 15 | our code by moving state back down (or 16 | [colocate state](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster)). 17 | 18 | So your job is to move the `favorited` state back to the `Card` component. 19 | 20 | When you're finished, the functionality should be no different, but the code 21 | should feel simpler. 22 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/03.problem.colocate/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.lifting-state/03.solution.colocate/README.mdx: -------------------------------------------------------------------------------- 1 | # Colocate State 2 | 3 | 4 | 5 | 👨‍💼 Awesome! You now know how to colocate state! Try to do this as much as 6 | possible in the future. 7 | 8 | 🦉 You may have noticed that by putting the state in the `Card`, you actually 9 | lose the favorited state of the card whenever the card is removed from the page 10 | (like if you do a search that filters out the card). So really the best place 11 | for this state would be in the `App` to ensure it never gets lost (except on 12 | page reloads). Really the best place for this type of state would be in the 13 | database but that's out of scope for this workshop. 14 | 15 | In this exercise, I mostly want you to appreciate how much it simplifies our 16 | `Card` component and the `MatchingPosts` component by moving the state into the 17 | `Card` itself. Additionally, it helps performance because now when the favorite 18 | state changes for a card, React only needs to re-render that card rather than 19 | the entire `MatchingPosts` component. 20 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/03.solution.colocate/controlled-checkbox.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can search for a checkbox value', async () => { 25 | fireEvent.change(searchBox, { target: { value: 'dog' } }) 26 | }) 27 | 28 | await testStep('checkbox is checked automatically', async () => { 29 | expect(dogCheckbox).toBeChecked() 30 | }) 31 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/03.solution.colocate/controlled-search.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 dogCheckbox = await testStep( 16 | 'The user can see the dog checkbox', 17 | async () => { 18 | const result = await screen.findByRole('checkbox', { name: /dog/i }) 19 | expect(result).not.toBeChecked() 20 | return result 21 | }, 22 | ) 23 | 24 | await testStep('The user can select the dog checkbox', async () => { 25 | fireEvent.click(dogCheckbox) 26 | expect(dogCheckbox).toBeChecked() 27 | }) 28 | 29 | await testStep( 30 | 'Selecting the checkbox updates the search and results', 31 | async () => { 32 | // Check that the search box value has been updated 33 | expect(searchBox).toHaveValue('dog') 34 | 35 | // Check that the results have been filtered 36 | await dtl.waitFor(async () => { 37 | await screen.findByText(/the joy of owning a dog/i) 38 | 39 | const catResult = screen.queryByText(/caring for your feline friend/i) 40 | expect(catResult).not.toBeInTheDocument() 41 | }) 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/03.solution.colocate/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/03.lifting-state/03.solution.colocate/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.lifting-state/03.solution.colocate/like-post.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 posts', async () => { 7 | await screen.findByText(/caring for your feline friend/i) 8 | await screen.findByText(/the joy of owning a dog/i) 9 | }) 10 | 11 | const likeButtons = await testStep( 12 | 'The user can see like buttons', 13 | async () => { 14 | const buttons = await screen.findAllByRole('button', { 15 | name: /add favorite/i, 16 | }) 17 | expect(buttons.length).toBeGreaterThan(0) 18 | return buttons 19 | }, 20 | ) 21 | 22 | const totalLikeButtons = likeButtons.length 23 | 24 | await testStep('The user can like a post', async () => { 25 | fireEvent.click(likeButtons[1]) 26 | await screen.findByRole('button', { name: /remove favorite/i }) 27 | }) 28 | 29 | await testStep('The liked post does not move to the top', async () => { 30 | const posts = screen.getAllByRole('listitem') 31 | const firstPost = posts[0] 32 | const removeFavoriteButton = screen.getByRole('button', { 33 | name: /remove favorite/i, 34 | }) 35 | expect( 36 | firstPost, 37 | 'The first post should have a remove favorite button', 38 | ).not.toContainElement(removeFavoriteButton) 39 | const secondPost = posts[1] 40 | expect( 41 | secondPost, 42 | 'The second post should have a add favorite button', 43 | ).toContainElement(removeFavoriteButton) 44 | }) 45 | 46 | await testStep('The user can unlike a post', async () => { 47 | const unlikeButton = await screen.findByRole('button', { 48 | name: /remove favorite/i, 49 | }) 50 | fireEvent.click(unlikeButton) 51 | 52 | await dtl.waitFor(() => 53 | expect( 54 | screen.queryByRole('button', { name: /remove favorite/i }), 55 | ).not.toBeInTheDocument(), 56 | ) 57 | const buttons = await screen.findAllByRole('button', { 58 | name: /add favorite/i, 59 | }) 60 | expect(buttons.length).toBe(totalLikeButtons) 61 | }) 62 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/03.solution.colocate/popstate.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent } = dtl 3 | 4 | const currentPath = window.location.pathname 5 | window.history.pushState({}, '', `${currentPath}?query=dog`) 6 | 7 | await import('./index.tsx') 8 | 9 | await testStep( 10 | 'The search box is initialized with URL query parameter', 11 | async () => { 12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i }) 13 | expect(searchBox).toHaveValue('dog') 14 | }, 15 | ) 16 | 17 | // wait for the event handler to be set up 18 | // for some reason it takes a bit 19 | await new Promise(resolve => setTimeout(resolve, 100)) 20 | 21 | await testStep( 22 | 'The search box updates when popstate event is triggered', 23 | async () => { 24 | // Simulate navigation to a new URL 25 | const currentPath = window.location.pathname 26 | window.history.pushState({}, '', `${currentPath}?query=cat`) 27 | 28 | // Trigger popstate event 29 | fireEvent.popState(window) 30 | 31 | // Check if the search box value is updated 32 | await dtl.waitFor(async () => 33 | expect( 34 | await screen.findByRole('searchbox', { name: /search/i }), 35 | ).toHaveValue('cat'), 36 | ) 37 | }, 38 | ) 39 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/03.solution.colocate/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 | -------------------------------------------------------------------------------- /exercises/03.lifting-state/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Lifting State 2 | 3 | 4 | 5 | 👨‍💼 Hooray! You've learned how to move state around as needed like a pro! 6 | -------------------------------------------------------------------------------- /exercises/04.dom/01.problem.ref/README.mdx: -------------------------------------------------------------------------------- 1 | # Refs 2 | 3 | 4 | 5 | 👨‍💼 Our users want a button they can click to increment a count a bunch of times. 6 | They also like fancy things. So we're going to package it in a fancy way. 7 | 8 | In this exercise we're going to use a completely different example. We're going 9 | to make a `` component that renders a div and uses the 10 | [`vanilla-tilt` library](https://micku7zu.github.io/vanilla-tilt.js/) to make it 11 | super fancy. 12 | 13 | The thing is, `vanilla-tilt` works directly with DOM nodes to setup event 14 | handlers and stuff, so we need access to the DOM node. But because we're not the 15 | one calling `document.createElement` (React does) we need React to give it to 16 | us. 17 | 18 | So in this exercise we're going to use a `ref` so React can give us the DOM node 19 | and then we can pass that on to `vanilla-tilt`. 20 | 21 | Additionally, we'll need to clean up after ourselves if this component is 22 | unmounted. Otherwise we'll have event handlers dangling around on DOM nodes that 23 | are no longer in the document which can cause a memory leak. 24 | 25 | The emoji will guide you. Enjoy! 26 | -------------------------------------------------------------------------------- /exercises/04.dom/01.problem.ref/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Taken from the vanilla-tilt.js demo site: 3 | https://micku7zu.github.io/vanilla-tilt.js/index.html 4 | */ 5 | .tilt-root { 6 | height: 150px; 7 | background-color: red; 8 | width: 200px; 9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%); 10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%); 11 | transform-style: preserve-3d; 12 | will-change: transform; 13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1); 14 | } 15 | .tilt-child { 16 | position: absolute; 17 | width: 50%; 18 | height: 50%; 19 | top: 50%; 20 | left: 50%; 21 | transform: translateZ(30px) translateX(-50%) translateY(-50%); 22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3); 23 | background-color: white; 24 | } 25 | .totally-centered { 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .count-button { 34 | width: 100%; 35 | height: 100%; 36 | background: transparent; 37 | border: none; 38 | font-size: 3em; 39 | } 40 | -------------------------------------------------------------------------------- /exercises/04.dom/01.problem.ref/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | // 💰 you'll need this stuff: 4 | // import VanillaTilt from 'vanilla-tilt' 5 | // 6 | // interface HTMLVanillaTiltElement extends HTMLDivElement { 7 | // vanillaTilt?: VanillaTilt 8 | // } 9 | // 10 | // const vanillaTiltOptions = { 11 | // max: 25, 12 | // speed: 400, 13 | // glare: true, 14 | // 'max-glare': 0.5, 15 | // } 16 | 17 | function Tilt({ children }: { children: React.ReactNode }) { 18 | return ( 19 |
29 |
{children}
30 |
31 | ) 32 | } 33 | 34 | function App() { 35 | const [showTilt, setShowTilt] = useState(true) 36 | const [count, setCount] = useState(0) 37 | return ( 38 |
39 | 40 | {showTilt ? ( 41 | 42 |
43 | 49 |
50 |
51 | ) : null} 52 |
53 | ) 54 | } 55 | 56 | const rootEl = document.createElement('div') 57 | document.body.append(rootEl) 58 | createRoot(rootEl).render() 59 | -------------------------------------------------------------------------------- /exercises/04.dom/01.solution.ref/README.mdx: -------------------------------------------------------------------------------- 1 | # Refs 2 | 3 | 4 | 5 | 👨‍💼 Great job! Now our users have a fancy counter! 6 | -------------------------------------------------------------------------------- /exercises/04.dom/01.solution.ref/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Taken from the vanilla-tilt.js demo site: 3 | https://micku7zu.github.io/vanilla-tilt.js/index.html 4 | */ 5 | .tilt-root { 6 | height: 150px; 7 | background-color: red; 8 | width: 200px; 9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%); 10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%); 11 | transform-style: preserve-3d; 12 | will-change: transform; 13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1); 14 | } 15 | .tilt-child { 16 | position: absolute; 17 | width: 50%; 18 | height: 50%; 19 | top: 50%; 20 | left: 50%; 21 | transform: translateZ(30px) translateX(-50%) translateY(-50%); 22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3); 23 | background-color: white; 24 | } 25 | .totally-centered { 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .count-button { 34 | width: 100%; 35 | height: 100%; 36 | background: transparent; 37 | border: none; 38 | font-size: 3em; 39 | } 40 | -------------------------------------------------------------------------------- /exercises/04.dom/01.solution.ref/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import VanillaTilt from 'vanilla-tilt' 4 | 5 | interface HTMLVanillaTiltElement extends HTMLDivElement { 6 | vanillaTilt?: VanillaTilt 7 | } 8 | 9 | const vanillaTiltOptions = { 10 | max: 25, 11 | speed: 400, 12 | glare: true, 13 | 'max-glare': 0.5, 14 | } 15 | 16 | function Tilt({ children }: { children: React.ReactNode }) { 17 | return ( 18 |
{ 21 | // 🦉 The types show tiltNode can be null. This is for backward 22 | // compatibility reasons and will be removed in the future. 23 | if (!tiltNode) return 24 | VanillaTilt.init(tiltNode, vanillaTiltOptions) 25 | return () => tiltNode.vanillaTilt?.destroy() 26 | }} 27 | > 28 |
{children}
29 |
30 | ) 31 | } 32 | 33 | function App() { 34 | const [showTilt, setShowTilt] = useState(true) 35 | const [count, setCount] = useState(0) 36 | return ( 37 |
38 | 39 | {showTilt ? ( 40 | 41 |
42 | 48 |
49 |
50 | ) : null} 51 |
52 | ) 53 | } 54 | 55 | const rootEl = document.createElement('div') 56 | document.body.append(rootEl) 57 | createRoot(rootEl).render() 58 | -------------------------------------------------------------------------------- /exercises/04.dom/01.solution.ref/tilt.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | 3 | import './index.tsx' 4 | 5 | await testStep('VanillaTilt is initialized', async () => { 6 | await dtl.waitFor(() => { 7 | const tiltElement = document.querySelector('.tilt-root') 8 | expect(tiltElement).toHaveProperty('vanillaTilt') 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /exercises/04.dom/01.solution.ref/toggle.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 toggleButton = await testStep( 7 | 'The user can see the toggle visibility button', 8 | async () => { 9 | const result = await screen.findByRole('button', { 10 | name: /toggle visibility/i, 11 | }) 12 | expect(result).toBeInTheDocument() 13 | return result 14 | }, 15 | ) 16 | 17 | await testStep('The Tilt component is initially visible', async () => { 18 | return dtl.waitFor(() => { 19 | const result = document.querySelector('.tilt-root') 20 | expect(result).toBeInTheDocument() 21 | return result 22 | }) 23 | }) 24 | 25 | const countButton = await testStep( 26 | 'The count button is visible inside the Tilt component', 27 | async () => { 28 | const result = await screen.findByRole('button', { name: /0/i }) 29 | expect(result).toBeInTheDocument() 30 | return result 31 | }, 32 | ) 33 | 34 | await testStep('The user can increment the count', async () => { 35 | fireEvent.click(countButton) 36 | const updatedButton = await screen.findByRole('button', { name: /1/i }) 37 | expect(updatedButton).toBeInTheDocument() 38 | }) 39 | 40 | await testStep( 41 | 'The user can toggle the Tilt component visibility', 42 | async () => { 43 | fireEvent.click(toggleButton) 44 | await dtl.waitFor(() => { 45 | expect(document.querySelector('.tilt-root')).not.toBeInTheDocument() 46 | }) 47 | 48 | fireEvent.click(toggleButton) 49 | const visibleTiltElement = await dtl.waitFor(() => { 50 | const result = document.querySelector('.tilt-root') 51 | expect(result).toBeInTheDocument() 52 | return result 53 | }) 54 | expect(visibleTiltElement).toBeInTheDocument() 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /exercises/04.dom/02.problem.deps/README.mdx: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | 3 | 4 | 5 | 👨‍💼 Our users wanted to be able to control `vanilla-tilt` a bit. Some of them 6 | like the speed and glare to look different. So Kellie 🧝‍♂️ added a form that will 7 | allow them to control those values. This is working great, but something we 8 | noticed is the tilt effect is getting reset whenever you click the count button! 9 | 10 | 🦉 The reason this happens is because the ref callback is called every time the 11 | component is rendered (and the cleanup runs between renders). So we're 12 | re-initializing the tilt effect on every render. 13 | 14 | 👨‍💼 This is inefficient and it's a jarring experience if the user clicks on the 15 | corner of the count button. We want the effect to only re-initialize when the 16 | options change. 17 | 18 | The trick is we want the effect to re-initialize when the `vanillaTiltOptions` 19 | change, but nothing else. So we can use `useEffect` to do that: 20 | 21 | ```tsx 22 | useEffect( 23 | () => { 24 | // set up stuff 25 | return function cleanup() { 26 | // clean up stuff 27 | } 28 | }, 29 | // depend on stuff: 30 | [], 31 | ) 32 | ``` 33 | 34 | 🦉 React needs to know when it needs to run your effect callback function again. 35 | We do this using the dependency array which is the second argument to 36 | `useEffect`. Whenever values in that array changes, React will call the returned 37 | cleanup function and then invoke the effect callback again. 38 | 39 | By default, if you don't provide a second argument, `useEffect` runs after every 40 | render (similar to what we're currently experiencing with the `ref` callback). 41 | While this is probably the right default for correctness, it's far from optimal 42 | in most `useEffect` cases. If you're not careful, it's easy to end up with 43 | infinite loops (imagine if you're calling `setState` in the effect which 44 | triggers another render, which calls the effect, which calls `setState` and so 45 | on). 46 | 47 | 👨‍💼 So what we need to do in this step is move our `ref` callback stuff to 48 | `useEffect`, create a `useRef` so we can access the DOM node in the `useEffect` 49 | callback, and let React know that our effect callback depends on the 50 | `vanillaTiltOptions` the user is providing. Let's do that by passing the 51 | `vanillaTiltOptions` in the dependency array. 52 | 53 | 54 | You'll notice an issue when you've finished this step. If you click the button 55 | to increment the count, the tilt effect is still reset! We'll fix this in the 56 | next step. 57 | 58 | -------------------------------------------------------------------------------- /exercises/04.dom/02.problem.deps/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Taken from the vanilla-tilt.js demo site: 3 | https://micku7zu.github.io/vanilla-tilt.js/index.html 4 | */ 5 | .tilt-root { 6 | height: 150px; 7 | background-color: red; 8 | width: 200px; 9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%); 10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%); 11 | transform-style: preserve-3d; 12 | will-change: transform; 13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1); 14 | } 15 | .tilt-child { 16 | position: absolute; 17 | width: 50%; 18 | height: 50%; 19 | top: 50%; 20 | left: 50%; 21 | transform: translateZ(30px) translateX(-50%) translateY(-50%); 22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3); 23 | background-color: white; 24 | } 25 | .totally-centered { 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .count-button { 34 | width: 100%; 35 | height: 100%; 36 | background: transparent; 37 | border: none; 38 | font-size: 3em; 39 | } 40 | -------------------------------------------------------------------------------- /exercises/04.dom/02.solution.deps/README.mdx: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | 3 | 4 | 5 | 👨‍💼 Great! But... ummm... we didn't actually solve the problem. It's still 6 | getting re-initialized whenever you click the count button. That's not good. 7 | Let's look into why and fix it. 8 | -------------------------------------------------------------------------------- /exercises/04.dom/02.solution.deps/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Taken from the vanilla-tilt.js demo site: 3 | https://micku7zu.github.io/vanilla-tilt.js/index.html 4 | */ 5 | .tilt-root { 6 | height: 150px; 7 | background-color: red; 8 | width: 200px; 9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%); 10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%); 11 | transform-style: preserve-3d; 12 | will-change: transform; 13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1); 14 | } 15 | .tilt-child { 16 | position: absolute; 17 | width: 50%; 18 | height: 50%; 19 | top: 50%; 20 | left: 50%; 21 | transform: translateZ(30px) translateX(-50%) translateY(-50%); 22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3); 23 | background-color: white; 24 | } 25 | .totally-centered { 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .count-button { 34 | width: 100%; 35 | height: 100%; 36 | background: transparent; 37 | border: none; 38 | font-size: 3em; 39 | } 40 | -------------------------------------------------------------------------------- /exercises/04.dom/02.solution.deps/tilt.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | import type VanillaTilt from 'vanilla-tilt' 3 | const { screen, fireEvent, waitFor } = dtl 4 | 5 | interface HTMLVanillaTiltElement extends HTMLDivElement { 6 | vanillaTilt?: VanillaTilt 7 | } 8 | 9 | import './index.tsx' 10 | 11 | const tiltElement = await testStep('Initialize tilt element', async () => { 12 | const result = await waitFor(() => { 13 | const element = document.querySelector('.tilt-root') 14 | expect(element).toBeInTheDocument() 15 | return element 16 | }) 17 | await waitFor(() => { 18 | expect(result).toHaveProperty('vanillaTilt') 19 | }) 20 | return result as HTMLVanillaTiltElement 21 | }) 22 | 23 | await testStep('Find count button', async () => { 24 | const button = await screen.findByRole('button', { name: /0/i }) 25 | expect(button).toBeInTheDocument() 26 | return button as HTMLButtonElement 27 | }) 28 | 29 | const maxInput = await testStep('Find max input', async () => { 30 | const input = (await screen.findByLabelText('Max:')) as HTMLInputElement 31 | expect(input).toBeInTheDocument() 32 | return input as HTMLInputElement 33 | }) 34 | 35 | await testStep('Tilt effect resets when options change', async () => { 36 | const initialVanillaTilt = tiltElement.vanillaTilt 37 | fireEvent.change(maxInput, { target: { value: '30' } }) 38 | await waitFor(() => { 39 | expect(tiltElement.vanillaTilt).not.toBe(initialVanillaTilt) 40 | }) 41 | }) 42 | 43 | await testStep('Tilt effect uses updated options', async () => { 44 | const newMax = 35 45 | fireEvent.change(maxInput, { target: { value: newMax.toString() } }) 46 | await waitFor(() => { 47 | // @ts-expect-error this is not exposed 48 | expect(tiltElement.vanillaTilt?.settings.max).toBe(newMax) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /exercises/04.dom/02.solution.deps/toggle.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 toggleButton = await testStep( 7 | 'The user can see the toggle visibility button', 8 | async () => { 9 | const result = await screen.findByRole('button', { 10 | name: /toggle visibility/i, 11 | }) 12 | expect(result).toBeInTheDocument() 13 | return result 14 | }, 15 | ) 16 | 17 | await testStep('The Tilt component is initially visible', async () => { 18 | return dtl.waitFor(() => { 19 | const result = document.querySelector('.tilt-root') 20 | expect(result).toBeInTheDocument() 21 | return result 22 | }) 23 | }) 24 | 25 | const countButton = await testStep( 26 | 'The count button is visible inside the Tilt component', 27 | async () => { 28 | const result = await screen.findByRole('button', { name: /0/i }) 29 | expect(result).toBeInTheDocument() 30 | return result 31 | }, 32 | ) 33 | 34 | await testStep('The user can increment the count', async () => { 35 | fireEvent.click(countButton) 36 | const updatedButton = await screen.findByRole('button', { name: /1/i }) 37 | expect(updatedButton).toBeInTheDocument() 38 | }) 39 | 40 | await testStep( 41 | 'The user can toggle the Tilt component visibility', 42 | async () => { 43 | fireEvent.click(toggleButton) 44 | await dtl.waitFor(() => { 45 | expect(document.querySelector('.tilt-root')).not.toBeInTheDocument() 46 | }) 47 | 48 | fireEvent.click(toggleButton) 49 | const visibleTiltElement = await dtl.waitFor(() => { 50 | const result = document.querySelector('.tilt-root') 51 | expect(result).toBeInTheDocument() 52 | return result 53 | }) 54 | expect(visibleTiltElement).toBeInTheDocument() 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /exercises/04.dom/03.problem.primitives/README.mdx: -------------------------------------------------------------------------------- 1 | # Primitive Dependencies 2 | 3 | 4 | 5 | 👨‍💼 Our users are annoyed. Whenever they click the incrementing button in the 6 | middle, the tilt effect is reset. You can reproduce this more easily by clicking 7 | one of the corners of the button. 8 | 9 | Moving things into a `useEffect` was supposed to help this because we can be 10 | explicit about which dependencies trigger the `cleanup` and effect to be run 11 | again. But we're still having the problem. 12 | 13 | If you add a `console.log` to the `useEffect`, you'll notice that it runs even 14 | when the button is clicked, even if the actual options are unchanged. The reason 15 | is because the `options` object actually _did_ change! This is because the 16 | `options` object is a new object every time the component renders. This is 17 | because of the way we're using the `...` spread operator to collect the options 18 | into a single (brand new) object. This means that the dependency array will 19 | always be different and the effect will always run! 20 | 21 | `useEffect` iterates through each of our dependencies and checks whether they 22 | have changed and it uses `Object.is` to do so (this is effectively the same 23 | as `===`). This means that even if two objects have the same properties, they 24 | will not be considered equal if they are different objects. 25 | 26 | ```tsx 27 | const options1 = { glare: true, max: 25, 'max-glare': 0.5, speed: 400 } 28 | const options2 = { glare: true, max: 25, 'max-glare': 0.5, speed: 400 } 29 | Object.is(options1, options2) // false!! 30 | ``` 31 | 32 | So the easiest way to fix this is by switching from using an object to using the 33 | primitive values directly. This way, the dependency array will only change when 34 | the actual values change. 35 | 36 | So please update the `useEffect` to use the primitive values directly. Thanks! 37 | -------------------------------------------------------------------------------- /exercises/04.dom/03.problem.primitives/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Taken from the vanilla-tilt.js demo site: 3 | https://micku7zu.github.io/vanilla-tilt.js/index.html 4 | */ 5 | .tilt-root { 6 | height: 150px; 7 | background-color: red; 8 | width: 200px; 9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%); 10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%); 11 | transform-style: preserve-3d; 12 | will-change: transform; 13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1); 14 | } 15 | .tilt-child { 16 | position: absolute; 17 | width: 50%; 18 | height: 50%; 19 | top: 50%; 20 | left: 50%; 21 | transform: translateZ(30px) translateX(-50%) translateY(-50%); 22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3); 23 | background-color: white; 24 | } 25 | .totally-centered { 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .count-button { 34 | width: 100%; 35 | height: 100%; 36 | background: transparent; 37 | border: none; 38 | font-size: 3em; 39 | } 40 | -------------------------------------------------------------------------------- /exercises/04.dom/03.solution.primitives/README.mdx: -------------------------------------------------------------------------------- 1 | # Primitive Dependencies 2 | 3 | 4 | 5 | 👨‍💼 This is probably one of the more annoying parts about dependency arrays. 6 | Luckily modern React applications don't need to reach for `useEffect` for many 7 | use cases (thanks to frameworks like Remix), but it's important for you to 8 | understand. 9 | -------------------------------------------------------------------------------- /exercises/04.dom/03.solution.primitives/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Taken from the vanilla-tilt.js demo site: 3 | https://micku7zu.github.io/vanilla-tilt.js/index.html 4 | */ 5 | .tilt-root { 6 | height: 150px; 7 | background-color: red; 8 | width: 200px; 9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%); 10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%); 11 | transform-style: preserve-3d; 12 | will-change: transform; 13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1); 14 | } 15 | .tilt-child { 16 | position: absolute; 17 | width: 50%; 18 | height: 50%; 19 | top: 50%; 20 | left: 50%; 21 | transform: translateZ(30px) translateX(-50%) translateY(-50%); 22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3); 23 | background-color: white; 24 | } 25 | .totally-centered { 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .count-button { 34 | width: 100%; 35 | height: 100%; 36 | background: transparent; 37 | border: none; 38 | font-size: 3em; 39 | } 40 | -------------------------------------------------------------------------------- /exercises/04.dom/03.solution.primitives/tilt.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | import type VanillaTilt from 'vanilla-tilt' 3 | const { screen, fireEvent, waitFor } = dtl 4 | 5 | interface HTMLVanillaTiltElement extends HTMLDivElement { 6 | vanillaTilt?: VanillaTilt 7 | } 8 | 9 | import './index.tsx' 10 | 11 | const tiltElement = await testStep('Initialize tilt element', async () => { 12 | const result = await waitFor(() => { 13 | const element = document.querySelector('.tilt-root') 14 | expect(element).toBeInTheDocument() 15 | return element 16 | }) 17 | await waitFor(() => { 18 | expect(result).toHaveProperty('vanillaTilt') 19 | }) 20 | return result as HTMLVanillaTiltElement 21 | }) 22 | 23 | const countButton = await testStep('Find count button', async () => { 24 | const button = await screen.findByRole('button', { name: /0/i }) 25 | expect(button).toBeInTheDocument() 26 | return button as HTMLButtonElement 27 | }) 28 | 29 | const maxInput = await testStep('Find max input', async () => { 30 | const input = (await screen.findByLabelText('Max:')) as HTMLInputElement 31 | expect(input).toBeInTheDocument() 32 | return input as HTMLInputElement 33 | }) 34 | 35 | await testStep('Tilt effect persists after count increment', async () => { 36 | const initialVanillaTilt = tiltElement.vanillaTilt 37 | fireEvent.click(countButton) 38 | await screen.findByRole('button', { name: /1/i }) 39 | expect(tiltElement.vanillaTilt, 'vanilla tilt was reinitialized').toBe( 40 | initialVanillaTilt, 41 | ) 42 | }) 43 | 44 | await testStep('Tilt effect resets when options change', async () => { 45 | const initialVanillaTilt = tiltElement.vanillaTilt 46 | fireEvent.change(maxInput, { target: { value: '30' } }) 47 | await waitFor(() => { 48 | expect(tiltElement.vanillaTilt).not.toBe(initialVanillaTilt) 49 | }) 50 | }) 51 | 52 | await testStep('Tilt effect uses updated options', async () => { 53 | const newMax = 35 54 | fireEvent.change(maxInput, { target: { value: newMax.toString() } }) 55 | await waitFor(() => { 56 | // @ts-expect-error this is not exposed 57 | expect(tiltElement.vanillaTilt?.settings.max).toBe(newMax) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /exercises/04.dom/03.solution.primitives/toggle.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 toggleButton = await testStep( 7 | 'The user can see the toggle visibility button', 8 | async () => { 9 | const result = await screen.findByRole('button', { 10 | name: /toggle visibility/i, 11 | }) 12 | expect(result).toBeInTheDocument() 13 | return result 14 | }, 15 | ) 16 | 17 | await testStep('The Tilt component is initially visible', async () => { 18 | return dtl.waitFor(() => { 19 | const result = document.querySelector('.tilt-root') 20 | expect(result).toBeInTheDocument() 21 | return result 22 | }) 23 | }) 24 | 25 | const countButton = await testStep( 26 | 'The count button is visible inside the Tilt component', 27 | async () => { 28 | const result = await screen.findByRole('button', { name: /0/i }) 29 | expect(result).toBeInTheDocument() 30 | return result 31 | }, 32 | ) 33 | 34 | await testStep('The user can increment the count', async () => { 35 | fireEvent.click(countButton) 36 | const updatedButton = await screen.findByRole('button', { name: /1/i }) 37 | expect(updatedButton).toBeInTheDocument() 38 | }) 39 | 40 | await testStep( 41 | 'The user can toggle the Tilt component visibility', 42 | async () => { 43 | fireEvent.click(toggleButton) 44 | await dtl.waitFor(() => { 45 | expect(document.querySelector('.tilt-root')).not.toBeInTheDocument() 46 | }) 47 | 48 | fireEvent.click(toggleButton) 49 | const visibleTiltElement = await dtl.waitFor(() => { 50 | const result = document.querySelector('.tilt-root') 51 | expect(result).toBeInTheDocument() 52 | return result 53 | }) 54 | expect(visibleTiltElement).toBeInTheDocument() 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /exercises/04.dom/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # DOM Side-Effects 2 | 3 | 4 | 5 | 👨‍💼 You did well! Now you know how to properly integrate third party DOM 6 | libraries with your React app. You can apply this same knowledge to other 7 | libraries that help you interact with other browser APIs as well. Good job! 8 | -------------------------------------------------------------------------------- /exercises/05.unique-ids/01.problem.use-id/README.mdx: -------------------------------------------------------------------------------- 1 | # useId 2 | 3 | 4 | 5 | 🧝‍♂️ I've made a few changes to our `vanilla-tilt` app to try and make a reusable 6 | `Field` component. But there's a problem. When I try to click on the label for 7 | a field, it doesn't focus the input. This is because we're missing an `id`. 8 | 9 | I don't want to just use the `name` attribute as the `id` because I don't want 10 | to run the risk of having multiple elements with the same `id` on the page if 11 | we render multiple `Field` components with the same `name`. And I don't want to 12 | have to pass in an `id` prop every time I use the `Field` component. Though, if 13 | they do pass an `id` we should use the `id` they passed instead of a generated 14 | one (maybe they want a very specific `id` for some reason and we should support 15 | both!). 16 | 17 | As usual, you can check my work if you like. 18 | 19 | 👨‍💼 So your job is to fix the label association. You'll notice that the checkbox 20 | is working as expected already because it's not using the `Field` component due 21 | to its unique structure. 22 | 23 | So, let's handle the `id` in the `Field` component. 24 | -------------------------------------------------------------------------------- /exercises/05.unique-ids/01.problem.use-id/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Taken from the vanilla-tilt.js demo site: 3 | https://micku7zu.github.io/vanilla-tilt.js/index.html 4 | */ 5 | .tilt-root { 6 | height: 150px; 7 | background-color: red; 8 | width: 200px; 9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%); 10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%); 11 | transform-style: preserve-3d; 12 | will-change: transform; 13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1); 14 | } 15 | .tilt-child { 16 | position: absolute; 17 | width: 50%; 18 | height: 50%; 19 | top: 50%; 20 | left: 50%; 21 | transform: translateZ(30px) translateX(-50%) translateY(-50%); 22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3); 23 | background-color: white; 24 | } 25 | .totally-centered { 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .count-button { 34 | width: 100%; 35 | height: 100%; 36 | background: transparent; 37 | border: none; 38 | font-size: 3em; 39 | } 40 | -------------------------------------------------------------------------------- /exercises/05.unique-ids/01.solution.use-id/README.mdx: -------------------------------------------------------------------------------- 1 | # useId 2 | 3 | 4 | 5 | 👨‍💼 Great, now our labels are properly associated and that makes our users happy. 6 | -------------------------------------------------------------------------------- /exercises/05.unique-ids/01.solution.use-id/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Taken from the vanilla-tilt.js demo site: 3 | https://micku7zu.github.io/vanilla-tilt.js/index.html 4 | */ 5 | .tilt-root { 6 | height: 150px; 7 | background-color: red; 8 | width: 200px; 9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%); 10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%); 11 | transform-style: preserve-3d; 12 | will-change: transform; 13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1); 14 | } 15 | .tilt-child { 16 | position: absolute; 17 | width: 50%; 18 | height: 50%; 19 | top: 50%; 20 | left: 50%; 21 | transform: translateZ(30px) translateX(-50%) translateY(-50%); 22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3); 23 | background-color: white; 24 | } 25 | .totally-centered { 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .count-button { 34 | width: 100%; 35 | height: 100%; 36 | background: transparent; 37 | border: none; 38 | font-size: 3em; 39 | } 40 | -------------------------------------------------------------------------------- /exercises/05.unique-ids/01.solution.use-id/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useId, useRef, useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import VanillaTilt from 'vanilla-tilt' 4 | 5 | function Field({ 6 | label, 7 | ...inputProps 8 | }: { 9 | label: string 10 | } & React.ComponentProps<'input'>) { 11 | const generatedId = useId() 12 | const id = inputProps.id ?? generatedId 13 | return ( 14 |
15 | 16 | 17 |
18 | ) 19 | } 20 | 21 | interface HTMLVanillaTiltElement extends HTMLDivElement { 22 | vanillaTilt?: VanillaTilt 23 | } 24 | 25 | function Tilt({ 26 | children, 27 | max = 25, 28 | speed = 400, 29 | glare = true, 30 | maxGlare = 0.5, 31 | }: { 32 | children: React.ReactNode 33 | max?: number 34 | speed?: number 35 | glare?: boolean 36 | maxGlare?: number 37 | }) { 38 | const tiltRef = useRef(null) 39 | 40 | useEffect(() => { 41 | const { current: tiltNode } = tiltRef 42 | if (!tiltNode) return 43 | const vanillaTiltOptions = { 44 | max, 45 | speed, 46 | glare, 47 | 'max-glare': maxGlare, 48 | } 49 | VanillaTilt.init(tiltNode, vanillaTiltOptions) 50 | return () => tiltNode.vanillaTilt?.destroy() 51 | }, [glare, max, maxGlare, speed]) 52 | 53 | return ( 54 |
55 |
{children}
56 |
57 | ) 58 | } 59 | 60 | function App() { 61 | const [count, setCount] = useState(0) 62 | const [options, setOptions] = useState({ 63 | max: 25, 64 | speed: 400, 65 | glare: true, 66 | maxGlare: 0.5, 67 | }) 68 | return ( 69 |
70 |
e.preventDefault()} 72 | onChange={event => { 73 | const formData = new FormData(event.currentTarget) 74 | setOptions({ 75 | max: Number(formData.get('max')), 76 | speed: Number(formData.get('speed')), 77 | glare: formData.get('glare') === 'on', 78 | maxGlare: Number(formData.get('maxGlare')), 79 | }) 80 | }} 81 | > 82 | 83 | 84 |
85 | 89 |
90 | 96 | 97 |
98 | 99 |
100 | 103 |
104 |
105 |
106 | ) 107 | } 108 | 109 | const rootEl = document.createElement('div') 110 | document.body.append(rootEl) 111 | createRoot(rootEl).render() 112 | -------------------------------------------------------------------------------- /exercises/05.unique-ids/01.solution.use-id/tilt.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | import type VanillaTilt from 'vanilla-tilt' 3 | const { screen, fireEvent, waitFor } = dtl 4 | 5 | interface HTMLVanillaTiltElement extends HTMLDivElement { 6 | vanillaTilt?: VanillaTilt 7 | } 8 | 9 | import './index.tsx' 10 | 11 | const tiltElement = await testStep('Initialize tilt element', async () => { 12 | const result = await waitFor(() => { 13 | const element = document.querySelector('.tilt-root') 14 | expect(element).toBeInTheDocument() 15 | return element 16 | }) 17 | await waitFor(() => { 18 | expect(result).toHaveProperty('vanillaTilt') 19 | }) 20 | return result as HTMLVanillaTiltElement 21 | }) 22 | 23 | const countButton = await testStep('Find count button', async () => { 24 | const button = await screen.findByRole('button', { name: /0/i }) 25 | expect(button).toBeInTheDocument() 26 | return button as HTMLButtonElement 27 | }) 28 | 29 | const maxInput = await testStep('Find max input', async () => { 30 | const input = (await screen.findByLabelText('Max')) as HTMLInputElement 31 | expect(input).toBeInTheDocument() 32 | return input as HTMLInputElement 33 | }) 34 | 35 | await testStep('Tilt effect persists after count increment', async () => { 36 | const initialVanillaTilt = tiltElement.vanillaTilt 37 | fireEvent.click(countButton) 38 | await screen.findByRole('button', { name: /1/i }) 39 | expect(tiltElement.vanillaTilt, 'vanilla tilt was reinitialized').toBe( 40 | initialVanillaTilt, 41 | ) 42 | }) 43 | 44 | await testStep('Tilt effect resets when options change', async () => { 45 | const initialVanillaTilt = tiltElement.vanillaTilt 46 | fireEvent.change(maxInput, { target: { value: '30' } }) 47 | await waitFor(() => { 48 | expect(tiltElement.vanillaTilt).not.toBe(initialVanillaTilt) 49 | }) 50 | }) 51 | 52 | await testStep('Tilt effect uses updated options', async () => { 53 | const newMax = 35 54 | fireEvent.change(maxInput, { target: { value: newMax.toString() } }) 55 | await waitFor(() => { 56 | // @ts-expect-error this is not exposed 57 | expect(tiltElement.vanillaTilt?.settings.max).toBe(newMax) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /exercises/05.unique-ids/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Unique IDs 2 | 3 | 4 | 5 | 👨‍💼 Great work! Now you know how to make globally unique IDs for your React apps! 6 | -------------------------------------------------------------------------------- /exercises/05.unique-ids/README.mdx: -------------------------------------------------------------------------------- 1 | # Unique IDs 2 | 3 | 4 | 5 | To build accessible forms, you need to ensure that each input element has a 6 | globally unique `id` attribute, and that the corresponding label element has a 7 | `for` attribute that matches the input's `id`. This allows screen readers to 8 | associate the label with the input, making it easier for users to understand the 9 | form's structure and purpose. Additionally, it allows users to click on the 10 | label to focus the input, which can be especially helpful for users with motor 11 | impairments (or like, for everyone you know?). 12 | 13 | This gets challenging with reusable components, especially when they're used 14 | multiple times on the same page. You can't just hardcode an `id` value, because 15 | then you'd have multiple elements with the same `id`, which is invalid HTML. You 16 | could use a random number or string, but then you'd have to manage that 17 | yourself, and it wouldn't be consistent between renders. And if you want to 18 | server render your app, you'd have to make sure the ID that's generated on the 19 | client matches the one that was generated on the server to avoid bugs which is 20 | a pain. 21 | 22 | This is where the `useId` hook comes into play. 23 | 24 | The `useId` hook generates a unique and stable identifier (ID) that you can use 25 | for DOM elements. 26 | 27 | Here's an example of how you can use the `useId` hook in a form component: 28 | 29 | ```tsx 30 | function FormField() { 31 | const id = useId() 32 | return ( 33 |
34 | 35 | 36 |
37 | ) 38 | } 39 | ``` 40 | 41 | In this example, `useId` generates a unique ID that links the label to the 42 | input, ensuring that screen readers and other assistive technologies can 43 | correctly identify the form field relationship. 44 | 45 | Unlike `useState` or `useEffect`, `useId` does not accept any arguments and 46 | returns a single string value. There's no setter or updater function because the 47 | ID it provides is meant to be constant and unique throughout the component's 48 | lifecycle. 49 | 50 | It's especially useful in server-side rendering (SSR) contexts because it 51 | ensures consistency between server-generated IDs and client-side generated ones, 52 | avoiding hydration mismatches. 53 | 54 | Remember, the main use of `useId` is for accessibility and managing 55 | relationships between different DOM elements, like labels and inputs. It helps 56 | keep your UI predictable and accessible without having to manage unique IDs 57 | yourself. 58 | 59 | One important thing to call out is that you should never use `useId` to generate 60 | IDs for non-DOM elements, like keys in a list or unique keys for React elements. 61 | Those IDs should come from your data, not from `useId`. 62 | 63 | 📜 Check [the `useId` docs](https://react.dev/reference/react/useId) for more 64 | info. (There's this interesting `identifierPrefix` feature you'll probably never 65 | use too! 😅) 66 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/01.problem.set-state-callback/README.mdx: -------------------------------------------------------------------------------- 1 | # setState callback 2 | 3 | 4 | 5 | 👨‍💼 Our users want to play tic-tac-toe. 6 | 7 | 8 | If you've gone through React's official tutorial, this was originally lifted 9 | from that. 10 | 11 | 12 | You're going to need some managed state and some derived state. Remember from 13 | exercise 1: 14 | 15 | - **Managed State:** State that you need to explicitly manage 16 | - **Derived State:** State that you can calculate based on other state 17 | 18 | `squares` is the managed state and it's the state of the board in a 19 | single-dimensional array: 20 | 21 | ``` 22 | [ 23 | 'X', 'O', 'X', 24 | 'X', 'O', 'O', 25 | 'X', 'X', 'O' 26 | ] 27 | ``` 28 | 29 | This will start out as an empty array because it's the start of the game. 30 | 31 | `nextValue` will be either the string `X` or `O` and is derived state which you 32 | can determine based on the value of `squares`. We can determine whose turn it is 33 | based on how many "X" and "O" squares there are. We've written this out for you 34 | in a `calculateNextValue` function in the `tic-tac-toe-utils.tsx` file. 35 | 36 | `winner` will be either the string `X` or `O` and is derived state which can 37 | also be determined based on the value of `squares` and we've provided a 38 | `calculateWinner` function you can use to get that value. 39 | 40 | If you want to try this exercise on beast mode then you can ignore 41 | `calculateNextValue` and `calculateWinner` and write your own version of those 42 | utilities. 43 | 44 | Another important thing you'll need to do for this step of the exercise is to 45 | use the callback version of `setState` to ensure that the state is updated 46 | correctly. This is because the state is updated based on the previous state and 47 | you want to make sure that you're always using the most up-to-date state. 48 | 49 | ```tsx 50 | setCount(currentCount => { 51 | return currentCount + 1 52 | }) 53 | ``` 54 | 55 | Finally, something you need to know about state in React is it's important that 56 | you not mutate state directly. So instead of setting `squares[0] = 'X'` you will 57 | need to make a copy of the array with the modifications, for example, using 58 | [`with`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/with): 59 | 60 | ```tsx 61 | const newSquares = squares.with(index, 'X') 62 | ``` 63 | 64 | The emoji should guide you well! Enjoy the exercise! 65 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/01.problem.set-state-callback/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 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/01.problem.set-state-callback/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | // 💰 here are some handy utilities for you: 3 | // import { 4 | // calculateNextValue, 5 | // calculateStatus, 6 | // calculateWinner, 7 | // type Squares, 8 | // } from '#shared/tic-tac-toe-utils' 9 | 10 | const defaultState = Array(9).fill(null) 11 | 12 | function Board() { 13 | // 🐨 squares is the state for this component. Add useState for squares 14 | // 🦺 you can use the Squares type for the useState generic 15 | const squares = defaultState 16 | 17 | // 🐨 We'll need the following bits of derived state: 18 | // - nextValue ('X' or 'O') 19 | // - winner ('X', 'O', or null) 20 | // - status (`Winner: ${winner}`, `Scratch: Cat's game`, or `Next player: ${nextValue}`) 21 | // 💰 I've written the calculations for you! So you can use my utilities 22 | // from the imports above to create these variables 23 | 24 | // This is the function your square click handler will call. `square` should 25 | // be an index. So if they click the center square, this will be `4`. 26 | function selectSquare(index: number) { 27 | // 🐨 first, if there's already winner or there's already a value at the 28 | // given square index (like someone clicked a square that's already been 29 | // clicked), then return early so we don't make any state changes 30 | // 31 | // 🐨 call setSquares and pass a callback 32 | // which accepts the "previousSquares", and does this: 33 | // 🐨 make a copy of the squares array with the updated value 34 | // 💰 previousSquares.with(index, nextValue) will do it! 35 | // 36 | // 🐨 return your copy of the squares 37 | } 38 | 39 | function restart() { 40 | // 🐨 reset the squares by calling setSquares with an array of empty squares 41 | // 💰 you can use the defaultState variable 42 | } 43 | 44 | function renderSquare(i: number) { 45 | return ( 46 | 49 | ) 50 | } 51 | 52 | return ( 53 |
54 | {/* 🐨 put the status in the div below */} 55 |
STATUS
56 |
57 | {renderSquare(0)} 58 | {renderSquare(1)} 59 | {renderSquare(2)} 60 |
61 |
62 | {renderSquare(3)} 63 | {renderSquare(4)} 64 | {renderSquare(5)} 65 |
66 |
67 | {renderSquare(6)} 68 | {renderSquare(7)} 69 | {renderSquare(8)} 70 |
71 | 74 |
75 | ) 76 | } 77 | 78 | function App() { 79 | return ( 80 |
81 |
82 | 83 |
84 |
85 | ) 86 | } 87 | 88 | const rootEl = document.createElement('div') 89 | document.body.append(rootEl) 90 | createRoot(rootEl).render() 91 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/01.solution.set-state-callback/README.mdx: -------------------------------------------------------------------------------- 1 | # Real World Review: Tic Tac Toe 2 | 3 | 4 | 5 | 👨‍💼 Awesome! Now our users can play tic-tac-toe! They're thrilled. But you know, 6 | they kinda want more... As is often the case. We call this "job security!" 😆 7 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/01.solution.set-state-callback/board-game.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent, waitFor } = dtl 3 | 4 | await import('./index.tsx') 5 | 6 | function getSquares() { 7 | return waitFor(() => { 8 | const squares = document.querySelectorAll('button.square') 9 | expect(squares).toHaveLength(9) 10 | return squares 11 | }) 12 | } 13 | 14 | await testStep('Initial board state', getSquares) 15 | 16 | const statusElement = await testStep('Find status element', async () => { 17 | const status = await screen.findByText(/Next player: X/) 18 | expect(status).toBeInTheDocument() 19 | return status 20 | }) 21 | 22 | await testStep('Play a game', async () => { 23 | const squares = await getSquares() 24 | 25 | // X plays 26 | fireEvent.click(squares[0]) 27 | await waitFor(() => { 28 | expect(squares[0]).toHaveTextContent('X') 29 | }) 30 | expect(statusElement).toHaveTextContent('Next player: O') 31 | 32 | // O plays 33 | fireEvent.click(squares[4]) 34 | await waitFor(() => { 35 | expect(squares[4]).toHaveTextContent('O') 36 | }) 37 | expect(statusElement).toHaveTextContent('Next player: X') 38 | 39 | // X plays 40 | fireEvent.click(squares[1]) 41 | // O plays 42 | fireEvent.click(squares[5]) 43 | // X plays and wins 44 | fireEvent.click(squares[2]) 45 | 46 | await waitFor(() => { 47 | expect(statusElement).toHaveTextContent('Winner: X') 48 | }) 49 | }) 50 | 51 | await testStep('Restart game', async () => { 52 | const restartButton = await screen.findByRole('button', { name: /restart/i }) 53 | fireEvent.click(restartButton) 54 | 55 | await waitFor(async () => { 56 | const squares = await getSquares() 57 | expect(squares).toHaveLength(9) 58 | expect(statusElement).toHaveTextContent('Next player: X') 59 | }) 60 | }) 61 | 62 | await testStep('Cannot play on occupied square', async () => { 63 | const squares = await getSquares() 64 | 65 | fireEvent.click(squares[0]) 66 | await waitFor(() => { 67 | expect(squares[0]).toHaveTextContent('X') 68 | }) 69 | 70 | fireEvent.click(squares[0]) 71 | await waitFor(() => { 72 | expect(squares[0]).toHaveTextContent('X') 73 | expect(statusElement).toHaveTextContent('Next player: O') 74 | }) 75 | }) 76 | 77 | await testStep('Game ends in a draw', async () => { 78 | const restartButton = await screen.findByRole('button', { name: /restart/i }) 79 | fireEvent.click(restartButton) 80 | await new Promise(resolve => setTimeout(resolve, 10)) 81 | 82 | const squares = await getSquares() 83 | const moves = [0, 1, 2, 4, 3, 5, 7, 6, 8] 84 | 85 | for (const move of moves) { 86 | fireEvent.click(squares[move]) 87 | await new Promise(resolve => setTimeout(resolve, 10)) 88 | } 89 | 90 | await waitFor(() => { 91 | expect(statusElement).toHaveTextContent(`Cat's game`) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/01.solution.set-state-callback/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 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/01.solution.set-state-callback/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { 4 | calculateNextValue, 5 | calculateStatus, 6 | calculateWinner, 7 | type Squares, 8 | } from '#shared/tic-tac-toe-utils' 9 | 10 | const defaultState = Array(9).fill(null) 11 | 12 | function Board() { 13 | const [squares, setSquares] = useState(defaultState) 14 | 15 | const nextValue = calculateNextValue(squares) 16 | const winner = calculateWinner(squares) 17 | const status = calculateStatus(winner, squares, nextValue) 18 | 19 | function selectSquare(index: number) { 20 | if (winner || squares[index]) return 21 | setSquares(previousSquares => previousSquares.with(index, nextValue)) 22 | } 23 | 24 | function restart() { 25 | setSquares(defaultState) 26 | } 27 | 28 | function renderSquare(i: number) { 29 | return ( 30 | 33 | ) 34 | } 35 | 36 | return ( 37 |
38 |
{status}
39 |
40 | {renderSquare(0)} 41 | {renderSquare(1)} 42 | {renderSquare(2)} 43 |
44 |
45 | {renderSquare(3)} 46 | {renderSquare(4)} 47 | {renderSquare(5)} 48 |
49 |
50 | {renderSquare(6)} 51 | {renderSquare(7)} 52 | {renderSquare(8)} 53 |
54 | 57 |
58 | ) 59 | } 60 | 61 | function App() { 62 | return ( 63 |
64 |
65 | 66 |
67 |
68 | ) 69 | } 70 | 71 | const rootEl = document.createElement('div') 72 | document.body.append(rootEl) 73 | createRoot(rootEl).render() 74 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/02.problem.local-storage/README.mdx: -------------------------------------------------------------------------------- 1 | # Preserve State in localStorage 2 | 3 | 4 | 5 | 👨‍💼 Our customers want to be able to close the tab in the middle of a game and 6 | then resume the game later. Can you store the game's state in `localStorage`? 7 | 8 | I think you can! You're going to need to use `useEffect` to coordinate things 9 | with `localStorage`. Luckily for us, `localStorage` is synchronous, so 10 | initializing our state from `localStorage` is straightforward (you should 11 | definitely use the callback form of `useState` for initialization though!). 12 | 13 | For keeping the squares up-to-date in `localStorage`, you'll want to use 14 | `useEffect` with a dependency array that includes the squares. 15 | 16 | 📜 If you need to learn a bit about the `localStorage` API, you can check out the 17 | [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). 18 | 19 | 20 | 🚨 Note this exercise depends on `localStorage` and so the tests could 21 | interfer with your work by changing the `localStorage` you're working with. 22 | 23 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/02.problem.local-storage/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 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/02.problem.local-storage/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { 4 | calculateNextValue, 5 | calculateStatus, 6 | calculateWinner, 7 | type Squares, 8 | } from '#shared/tic-tac-toe-utils' 9 | 10 | const defaultState = Array(9).fill(null) 11 | // 🐨 create a variable for the key you'll use for storing the squares 12 | // 💰 'squares' should work well. 13 | function Board() { 14 | // 🐨 use the callback form for useState. The callback should: 15 | // 1. get the value from localStorage using the key you created above 16 | // 2. parse the JSON from that value 17 | // 3. return the parsed value (or the default value if there isn't one) 18 | // 💯 for extra credit, handle situations where the value doesn't exist or fails to parse 19 | const [squares, setSquares] = useState(Array(9).fill(null)) 20 | 21 | // 🐨 add a useEffect here that updates the local storage value of the squares 22 | // 💰 you should stringify the squares using JSON.stringify because local storage only supports strings 23 | 24 | const nextValue = calculateNextValue(squares) 25 | const winner = calculateWinner(squares) 26 | const status = calculateStatus(winner, squares, nextValue) 27 | 28 | function selectSquare(index: number) { 29 | if (winner || squares[index]) return 30 | setSquares(previousSquares => previousSquares.with(index, nextValue)) 31 | } 32 | 33 | function restart() { 34 | setSquares(defaultState) 35 | } 36 | 37 | function renderSquare(i: number) { 38 | return ( 39 | 42 | ) 43 | } 44 | 45 | return ( 46 |
47 |
{status}
48 |
49 | {renderSquare(0)} 50 | {renderSquare(1)} 51 | {renderSquare(2)} 52 |
53 |
54 | {renderSquare(3)} 55 | {renderSquare(4)} 56 | {renderSquare(5)} 57 |
58 |
59 | {renderSquare(6)} 60 | {renderSquare(7)} 61 | {renderSquare(8)} 62 |
63 | 66 |
67 | ) 68 | } 69 | 70 | function App() { 71 | return ( 72 |
73 |
74 | 75 |
76 |
77 | ) 78 | } 79 | 80 | const rootEl = document.createElement('div') 81 | document.body.append(rootEl) 82 | createRoot(rootEl).render() 83 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/02.solution.local-storage/README.mdx: -------------------------------------------------------------------------------- 1 | # Preserve State in localStorage 2 | 3 | 4 | 5 | 👨‍💼 Stellar work! Now our users can refresh the page and they'll be right back 6 | where they started... 7 | 8 | But wouldn't you know it... the users have another feature request for your job 9 | security 🥰 10 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/02.solution.local-storage/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 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/02.solution.local-storage/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { 4 | calculateNextValue, 5 | calculateStatus, 6 | calculateWinner, 7 | type Squares, 8 | } from '#shared/tic-tac-toe-utils' 9 | 10 | const defaultState = Array(9).fill(null) 11 | 12 | const localStorageKey = 'squares' 13 | function Board() { 14 | const [squares, setSquares] = useState(() => { 15 | let localStorageValue 16 | try { 17 | localStorageValue = JSON.parse( 18 | window.localStorage.getItem(localStorageKey) ?? 'null', 19 | ) 20 | } catch { 21 | // something is wrong in localStorage, so don't use it 22 | } 23 | return localStorageValue && Array.isArray(localStorageValue) 24 | ? localStorageValue 25 | : defaultState 26 | }) 27 | 28 | useEffect(() => { 29 | window.localStorage.setItem(localStorageKey, JSON.stringify(squares)) 30 | }, [squares]) 31 | 32 | const nextValue = calculateNextValue(squares) 33 | const winner = calculateWinner(squares) 34 | const status = calculateStatus(winner, squares, nextValue) 35 | 36 | function selectSquare(index: number) { 37 | if (winner || squares[index]) return 38 | setSquares(previousSquares => previousSquares.with(index, nextValue)) 39 | } 40 | 41 | function restart() { 42 | setSquares(defaultState) 43 | } 44 | 45 | function renderSquare(i: number) { 46 | return ( 47 | 50 | ) 51 | } 52 | 53 | return ( 54 |
55 |
{status}
56 |
57 | {renderSquare(0)} 58 | {renderSquare(1)} 59 | {renderSquare(2)} 60 |
61 |
62 | {renderSquare(3)} 63 | {renderSquare(4)} 64 | {renderSquare(5)} 65 |
66 |
67 | {renderSquare(6)} 68 | {renderSquare(7)} 69 | {renderSquare(8)} 70 |
71 | 74 |
75 | ) 76 | } 77 | 78 | function App() { 79 | return ( 80 |
81 |
82 | 83 |
84 |
85 | ) 86 | } 87 | 88 | const rootEl = document.createElement('div') 89 | document.body.append(rootEl) 90 | createRoot(rootEl).render() 91 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/02.solution.local-storage/local-storage.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, fireEvent, waitFor } = dtl 3 | 4 | const localStorageKey = 'squares' 5 | const initialState = ['X', null, 'O', null, 'X', null, null, null, null] 6 | window.localStorage.setItem(localStorageKey, JSON.stringify(initialState)) 7 | 8 | // Dynamically import the game component 9 | await import('./index.tsx') 10 | 11 | await testStep('Game initializes from localStorage', async () => { 12 | await waitFor(() => { 13 | const squares = document.querySelectorAll('button.square') 14 | expect(squares[0]).toHaveTextContent('X') 15 | expect(squares[2]).toHaveTextContent('O') 16 | expect(squares[4]).toHaveTextContent('X') 17 | }) 18 | }) 19 | 20 | await testStep('Game updates localStorage after a move', async () => { 21 | // Make a move 22 | const squares = document.querySelectorAll('button.square') 23 | fireEvent.click(squares[1]) 24 | 25 | // Verify localStorage is updated 26 | await waitFor(() => { 27 | const storedState = JSON.parse( 28 | window.localStorage.getItem(localStorageKey) || '[]', 29 | ) 30 | expect(storedState).toEqual([ 31 | 'X', 32 | 'O', 33 | 'O', 34 | null, 35 | 'X', 36 | null, 37 | null, 38 | null, 39 | null, 40 | ]) 41 | }) 42 | }) 43 | 44 | await testStep('Restart button clears localStorage', async () => { 45 | const restartButton = await screen.findByRole('button', { name: /restart/i }) 46 | fireEvent.click(restartButton) 47 | 48 | // Check if localStorage is cleared 49 | await waitFor(() => { 50 | const storedState = JSON.parse( 51 | window.localStorage.getItem(localStorageKey) || '[]', 52 | ) 53 | expect(storedState).toEqual([ 54 | null, 55 | null, 56 | null, 57 | null, 58 | null, 59 | null, 60 | null, 61 | null, 62 | null, 63 | ]) 64 | }) 65 | 66 | // Check if the board is reset 67 | const squares = document.querySelectorAll('button.square') 68 | expect(squares).toHaveLength(9) 69 | }) 70 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/03.problem.history/README.mdx: -------------------------------------------------------------------------------- 1 | # Add Game History Feature 2 | 3 | 4 | 5 | 👨‍💼 Sometimes users are playing tic-tac-toe against their children and their kids 6 | want to change their move when they lose 😆 So we're going to add a feature that 7 | allows users to go backward through the game and change moves. 8 | 9 | As usual, you can check the solution to 10 | check what the final experience should be like. 11 | 12 | 🧝‍♂️ I made a couple changes you can check here if 13 | you'd like. I mostly just refactored things a bit to get it ready for your work. 14 | Should be a fun feature to add! Enjoy! 15 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/03.problem.history/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 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/03.solution.history/README.mdx: -------------------------------------------------------------------------------- 1 | # Add Game History Feature 2 | 3 | 4 | 5 | 👨‍💼 Hey, great job! Our users are really happy with what you've put together 👏 6 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/03.solution.history/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 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Tic Tac Toe 2 | 3 | 4 | 5 | 👨‍💼 Congratulations! You now have a great handle on `useState` and `useEffect`. 6 | -------------------------------------------------------------------------------- /exercises/06.tic-tac-toe/README.mdx: -------------------------------------------------------------------------------- 1 | # Tic Tac Toe 2 | 3 | 4 | 5 | This exercise is just to give you more practice with `useState` and `useEffect`. 6 | For the most part, this is just review, but there is one thing we'll be doing 7 | in this exercise we haven't done yet with `useState` and that is, it can 8 | actually accept a function. For example: 9 | 10 | ```tsx 11 | const [count, setCount] = useState(0) 12 | 13 | // then in a click event handler or something: 14 | setCount(count + 1) 15 | 16 | // but this is the exact same thing: 17 | setCount(previousCount => previousCount + 1) 18 | ``` 19 | 20 | Because there are two ways to do the same thing, it's nice to know why/when 21 | you'd use one over the other. Here's the "rule": 22 | 23 | **If your new value for state is calculated based on the previous value of 24 | state, use the function form. Otherwise, either works fine.** 25 | 26 | For a deeper dive on this, read 27 | [useState lazy initialization and function updates](https://kentcdodds.com/blog/use-state-lazy-initialization-and-function-updates). 28 | 29 | This one's a bit tougher than some of the other exercises, so make sure you're 30 | well hydrated and do a bit of stretching before starting this. 31 | -------------------------------------------------------------------------------- /exercises/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # React Hooks 🎣 2 | 3 | 4 | 5 | 👨‍💼 You've done an awesome job. Now you understand the foundational hooks with 6 | React and you're prepared to learn some of the more advanced hooks that React 7 | has to offer. Great job! 8 | -------------------------------------------------------------------------------- /exercises/README.mdx: -------------------------------------------------------------------------------- 1 | # React Hooks 🎣 2 | 3 | 4 | 5 | 👨‍💼 Hello! I'm Peter the Product manager and I'm here to help guide you through 6 | this workshop. I'll be there to tell you what our users want and to help you 7 | understand the requirements of the tasks you'll be working on. 8 | 9 | React hooks are a critical part of React development. You can think of them as 10 | atoms to the React component molecule. Everything outside of basic rendering 11 | can be done with hooks. 12 | 13 | In this workshop, we'll learn how to use hooks to manage state, synchronize 14 | side-effects, generate unique ids, and more. There's a lot to hooks and we'll 15 | get into more advanced use cases in a future workshop. In this one you'll get 16 | a solid understanding of the basic hooks and how to use them. 17 | 18 | So let's get started! 19 | 20 | 🎵 Check out the workshop theme song! 🎶 21 | 22 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hooks", 3 | "private": true, 4 | "epicshop": { 5 | "title": "React Hooks 🎣", 6 | "subtitle": "Understand the building blocks of React", 7 | "githubRepo": "https://github.com/epicweb-dev/react-hooks", 8 | "stackBlitzConfig": { 9 | "view": "editor" 10 | }, 11 | "product": { 12 | "host": "www.epicreact.dev", 13 | "slug": "react-hooks", 14 | "displayName": "EpicReact.dev", 15 | "displayNameShort": "Epic React", 16 | "logo": "/logo.svg", 17 | "discordChannelId": "1285244676286189569", 18 | "discordTags": [ 19 | "1285246046498328627", 20 | "1285245663281549448" 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 | "scripts": { 35 | "postinstall": "cd ./epicshop && npm install", 36 | "start": "npx --prefix ./epicshop epicshop start", 37 | "dev": "npx --prefix ./epicshop epicshop start", 38 | "setup": "node ./epicshop/setup.js", 39 | "setup:custom": "node ./epicshop/setup-custom.js", 40 | "lint": "eslint .", 41 | "format": "prettier --write .", 42 | "typecheck": "tsc -b" 43 | }, 44 | "keywords": [], 45 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 46 | "license": "GPL-3.0-only", 47 | "dependencies": { 48 | "react": "19.0.0", 49 | "react-dom": "19.0.0", 50 | "vanilla-tilt": "^1.8.1" 51 | }, 52 | "devDependencies": { 53 | "@epic-web/config": "^1.16.3", 54 | "@epic-web/workshop-utils": "^5.20.1", 55 | "@testing-library/user-event": "^14.5.2", 56 | "@types/react": "^19.0.0", 57 | "@types/react-dom": "^19.0.0", 58 | "eslint": "^9.16.0", 59 | "npm-run-all": "^4.1.5", 60 | "prettier": "^3.4.2", 61 | "typescript": "^5.7.2" 62 | }, 63 | "engines": { 64 | "node": ">=20", 65 | "npm": ">=8.16.0", 66 | "git": ">=2.18.0" 67 | }, 68 | "prettier": "@epic-web/config/prettier", 69 | "prettierIgnore": [ 70 | "node_modules", 71 | "**/build/**", 72 | "**/public/build/**", 73 | ".env", 74 | "**/package.json", 75 | "**/tsconfig.json", 76 | "**/package-lock.json", 77 | "**/playwright-report/**" 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/hook-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/public/hook-flow.png -------------------------------------------------------------------------------- /public/images/instructor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/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/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/public/og/background.png -------------------------------------------------------------------------------- /public/react-app-lifecycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/public/react-app-lifecycle.png -------------------------------------------------------------------------------- /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": ["ES2023", "DOM"], 6 | // keep things easy for the exercises 7 | "noUncheckedIndexedAccess": false, 8 | "paths": { 9 | "#*": ["./*"] 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------