├── .github └── workflows │ └── validate.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── epicshop ├── .diffignore ├── .npmrc ├── Dockerfile ├── fix-watch.js ├── fix.js ├── fly.toml ├── package-lock.json ├── package.json ├── post-set-playground.js ├── setup-custom.js ├── setup.js ├── test.js └── update-deps.sh ├── eslint.config.js ├── exercises ├── 01.use-state │ ├── 01.problem.initial-state │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.initial-state │ │ ├── README.mdx │ │ ├── counter.test.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ └── state.test.tsx │ ├── 02.problem.update-state │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.update-state │ │ ├── README.mdx │ │ ├── counter.test.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ └── state.test.tsx │ ├── 03.problem.re-render │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 03.solution.re-render │ │ ├── README.mdx │ │ ├── counter.test.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ └── state.test.tsx │ ├── 04.problem.preserve-state │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 04.solution.preserve-state │ │ ├── README.mdx │ │ ├── counter.test.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ └── state.test.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 02.multiple-hooks │ ├── 01.problem.phase │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.phase │ │ ├── README.mdx │ │ ├── counter.test.tsx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.problem.hook-id │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.hook-id │ │ ├── README.mdx │ │ ├── counter.test.tsx │ │ ├── index.css │ │ └── index.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 03.use-effect │ ├── 01.problem.callback │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 01.solution.callback │ │ ├── README.mdx │ │ ├── counter.test.tsx │ │ ├── effect.test.tsx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.problem.dependencies │ │ ├── README.mdx │ │ ├── index.css │ │ └── index.tsx │ ├── 02.solution.dependencies │ │ ├── README.mdx │ │ ├── counter.test.tsx │ │ ├── index.css │ │ └── index.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── FINISHED.mdx └── README.mdx ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── favicon.svg ├── images │ └── instructor.png ├── logo.svg └── og │ ├── background.png │ └── logo.svg └── 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 | deploy: 39 | name: 🚀 Deploy 40 | runs-on: ubuntu-latest 41 | # only deploy main branch on pushes on non-forks 42 | if: 43 | ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' && 44 | github.repository_owner == 'epicweb-dev' }} 45 | 46 | steps: 47 | - name: ⬇️ Checkout repo 48 | uses: actions/checkout@v4 49 | 50 | - name: 🎈 Setup Fly 51 | uses: superfly/flyctl-actions/setup-flyctl@1.5 52 | 53 | - name: 🚀 Deploy 54 | run: flyctl deploy --remote-only 55 | working-directory: ./epicshop 56 | env: 57 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 58 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | **/.cache/** 3 | **/build/** 4 | **/dist/** 5 | **/public/build/** 6 | **/package-lock.json 7 | **/playwright-report/** 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Build React Hooks 🪝

3 | 4 | Understand how React hooks work by building them from scratch 5 | 6 |

7 | React hooks are a core building block upon which we build our React apps. Understanding them at a fundamental level will help you use them more efficiently. You'll go from "it doesn't work and I don't know why" to "it works and I understand why." 8 |

9 |
10 | 11 |
12 | 13 |
14 | 18 | 22 | 23 |
24 | 25 |
26 | 27 | 28 | [![Build Status][build-badge]][build] 29 | [![GPL 3.0 License][license-badge]][license] 30 | [![Code of Conduct][coc-badge]][coc] 31 | 32 | 33 | ## Prerequisites 34 | 35 | - [Experience with React hooks](https://www.epicreact.dev/hooks) 36 | 37 | ## Pre-workshop Resources 38 | 39 | Here are some resources you can read before taking the workshop to get you up to 40 | speed on some of the tools and concepts we'll be covering: 41 | 42 | - [Getting Closure on React Hooks](https://www.swyx.io/hooks) 43 | 44 | ## System Requirements 45 | 46 | - [git][git] v2.18 or greater 47 | - [NodeJS][node] v18 or greater 48 | - [npm][npm] v8 or greater 49 | 50 | All of these must be available in your `PATH`. To verify things are set up 51 | properly, you can run this: 52 | 53 | ```shell 54 | git --version 55 | node --version 56 | npm --version 57 | ``` 58 | 59 | If you have trouble with any of these, learn more about the PATH environment 60 | variable and how to fix it here for [windows][win-path] or 61 | [mac/linux][mac-path]. 62 | 63 | ## Setup 64 | 65 | This is a pretty large project (it's actually many apps in one) so it can take 66 | several minutes to get everything set up the first time. Please have a strong 67 | network connection before running the setup and grab a snack. 68 | 69 | > **Warning**: This repo is _very_ large. Make sure you have a good internet 70 | > connection before you start the setup process. The instructions below use 71 | > `--depth` to limit the amount you download, but if you have a slow connection, 72 | > or you pay for bandwidth, you may want to find a place with a better 73 | > connection. 74 | 75 | Follow these steps to get this set up: 76 | 77 | ```sh nonumber 78 | git clone --depth 1 https://github.com/epicweb-dev/build-react-hooks.git 79 | cd build-react-hooks 80 | npm run setup 81 | ``` 82 | 83 | If you experience errors here, please open [an issue][issue] with as many 84 | details as you can offer. 85 | 86 | ## Starting the app 87 | 88 | Once you have the setup finished, you can start the app with: 89 | 90 | ``` 91 | npm start 92 | ``` 93 | 94 | ## The Workshop App 95 | 96 | Learn all about the workshop app on the 97 | [Epic Web Getting Started Guide](https://www.epicweb.dev/get-started). 98 | 99 | [![Kent with the workshop app in the background](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/280407082-0e012138-e01d-45d5-abf2-86ffe5d03c69.png)](https://www.epicweb.dev/get-started) 100 | 101 | 102 | [npm]: https://www.npmjs.com/ 103 | [node]: https://nodejs.org 104 | [git]: https://git-scm.com/ 105 | [build-badge]: https://img.shields.io/github/actions/workflow/status/epicweb-dev/build-react-hooks/validate.yml?branch=main&logo=github&style=flat-square 106 | [build]: https://github.com/epicweb-dev/build-react-hooks/actions?query=workflow%3Avalidate 107 | [license-badge]: https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square 108 | [license]: https://github.com/epicweb-dev/build-react-hooks/blob/main/LICENSE 109 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 110 | [coc]: https://kentcdodds.com/conduct 111 | [win-path]: https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/ 112 | [mac-path]: http://stackoverflow.com/a/24322978/971592 113 | [issue]: https://github.com/epicweb-dev/build-react-hooks/issues/new 114 | 115 | -------------------------------------------------------------------------------- /epicshop/.diffignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | -------------------------------------------------------------------------------- /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/build-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 ./epicshop/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/fix.js: -------------------------------------------------------------------------------- 1 | // This should run by node without any dependencies 2 | // because you may need to run it without deps. 3 | 4 | import cp from 'node:child_process' 5 | import fs from 'node:fs' 6 | import path from 'node:path' 7 | import { fileURLToPath } from 'node:url' 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 10 | const here = (...p) => path.join(__dirname, ...p) 11 | const VERBOSE = false 12 | const logVerbose = (...args) => (VERBOSE ? console.log(...args) : undefined) 13 | 14 | const workshopRoot = here('..') 15 | const examples = (await readDir(here('../examples'))).map(dir => 16 | here(`../examples/${dir}`), 17 | ) 18 | const exercises = (await readDir(here('../exercises'))) 19 | .map(name => here(`../exercises/${name}`)) 20 | .filter(filepath => fs.statSync(filepath).isDirectory()) 21 | const exerciseApps = ( 22 | await Promise.all( 23 | exercises.flatMap(async exercise => { 24 | return (await readDir(exercise)) 25 | .filter(dir => { 26 | return /(problem|solution)/.test(dir) 27 | }) 28 | .map(dir => path.join(exercise, dir)) 29 | }), 30 | ) 31 | ).flat() 32 | const exampleApps = (await readDir(here('../examples'))).map(dir => 33 | here(`../examples/${dir}`), 34 | ) 35 | const apps = [...exampleApps, ...exerciseApps] 36 | 37 | const appsWithPkgJson = [...examples, ...apps].filter(app => { 38 | const pkgjsonPath = path.join(app, 'package.json') 39 | return exists(pkgjsonPath) 40 | }) 41 | 42 | // update the package.json file name property 43 | // to match the parent directory name + directory name 44 | // e.g. exercises/01-goo/problem.01-great 45 | // name: "exercises__sep__01-goo.problem__sep__01-great" 46 | 47 | function relativeToWorkshopRoot(dir) { 48 | return dir.replace(`${workshopRoot}${path.sep}`, '') 49 | } 50 | 51 | await updatePkgNames() 52 | await updateTsconfig() 53 | 54 | async function updatePkgNames() { 55 | for (const file of appsWithPkgJson) { 56 | const pkgjsonPath = path.join(file, 'package.json') 57 | const pkg = JSON.parse(await fs.promises.readFile(pkgjsonPath, 'utf8')) 58 | pkg.name = relativeToWorkshopRoot(file).replace(/\\|\//g, '_') 59 | const written = await writeIfNeeded( 60 | pkgjsonPath, 61 | `${JSON.stringify(pkg, null, 2)}\n`, 62 | ) 63 | if (written) { 64 | console.log(`updated ${path.relative(process.cwd(), pkgjsonPath)}`) 65 | } 66 | } 67 | } 68 | 69 | async function updateTsconfig() { 70 | const tsconfig = { 71 | files: [], 72 | exclude: ['node_modules'], 73 | references: appsWithPkgJson.map(a => ({ 74 | path: relativeToWorkshopRoot(a).replace(/\\/g, '/'), 75 | })), 76 | } 77 | const written = await writeIfNeeded( 78 | path.join(workshopRoot, 'tsconfig.json'), 79 | `${JSON.stringify(tsconfig, null, 2)}\n`, 80 | { parser: 'json' }, 81 | ) 82 | 83 | if (written) { 84 | // delete node_modules/.cache 85 | const cacheDir = path.join(workshopRoot, 'node_modules', '.cache') 86 | if (exists(cacheDir)) { 87 | await fs.promises.rm(cacheDir, { recursive: true }) 88 | } 89 | console.log('all fixed up') 90 | } 91 | } 92 | 93 | async function writeIfNeeded(filepath, content) { 94 | const oldContent = await fs.promises.readFile(filepath, 'utf8') 95 | if (oldContent !== content) { 96 | await fs.promises.writeFile(filepath, content) 97 | } 98 | return oldContent !== content 99 | } 100 | 101 | function exists(p) { 102 | if (!p) return false 103 | try { 104 | fs.statSync(p) 105 | return true 106 | } catch (error) { 107 | return false 108 | } 109 | } 110 | 111 | async function readDir(dir) { 112 | if (exists(dir)) { 113 | return fs.promises.readdir(dir) 114 | } 115 | return [] 116 | } 117 | -------------------------------------------------------------------------------- /epicshop/fly.toml: -------------------------------------------------------------------------------- 1 | app = "epicweb-dev-build-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 = { } -------------------------------------------------------------------------------- /epicshop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "@epic-web/workshop-app": "^5.22.5", 5 | "@epic-web/workshop-utils": "^5.22.5", 6 | "chokidar": "^3.6.0", 7 | "execa": "^9.3.0", 8 | "fs-extra": "^11.2.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /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/test.js: -------------------------------------------------------------------------------- 1 | // This should run by node without any dependencies 2 | // because you may need to run it without deps. 3 | 4 | import { spawn } from 'child_process' 5 | import path from 'node:path' 6 | import { 7 | getApps, 8 | isExampleApp, 9 | isSolutionApp, 10 | } from '@epic-web/workshop-utils/apps.server' 11 | 12 | const styles = { 13 | // got these from playing around with what I found from: 14 | // https://github.com/istanbuljs/istanbuljs/blob/0f328fd0896417ccb2085f4b7888dd8e167ba3fa/packages/istanbul-lib-report/lib/file-writer.js#L84-L96 15 | // they're the best I could find that works well for light or dark terminals 16 | success: { open: '\u001b[32;1m', close: '\u001b[0m' }, 17 | danger: { open: '\u001b[31;1m', close: '\u001b[0m' }, 18 | info: { open: '\u001b[36;1m', close: '\u001b[0m' }, 19 | subtitle: { open: '\u001b[2;1m', close: '\u001b[0m' }, 20 | } 21 | function color(modifier, string) { 22 | return styles[modifier].open + string + styles[modifier].close 23 | } 24 | 25 | const __dirname = new URL('.', import.meta.url).pathname 26 | const here = (...p) => path.join(__dirname, ...p) 27 | 28 | const workshopRoot = here('..') 29 | 30 | const relativeToWorkshopRoot = dir => 31 | dir.replace(`${workshopRoot}${path.sep}`, '') 32 | 33 | // bundleMDX - throw when process.NODE_ENV is not a string 34 | // @epic-web/workshop-app/build/utils/compile-mdx.server 35 | process.env.NODE_ENV = 'development' 36 | 37 | const apps = await getApps() 38 | const solutionApps = apps.filter(isSolutionApp) 39 | const exampleApps = apps.filter(isExampleApp) 40 | 41 | let exitCode = 0 42 | 43 | for (const app of [...solutionApps, ...exampleApps]) { 44 | if (app.test.type !== 'script') continue 45 | 46 | const relativePath = relativeToWorkshopRoot(app.fullPath) 47 | 48 | console.log(`🧪 Running "${app.test.script}" in ${relativePath}`) 49 | 50 | const cp = spawn('npm', ['run', app.test.script, '--silent'], { 51 | cwd: app.fullPath, 52 | stdio: 'inherit', 53 | shell: true, 54 | windowsHide: false, 55 | env: { 56 | OPEN_PLAYWRIGHT_REPORT: 'never', 57 | ...process.env, 58 | PORT: app.dev.portNumber, 59 | }, 60 | }) 61 | 62 | await new Promise(res => { 63 | cp.on('exit', code => { 64 | if (code === 0) { 65 | console.log(color('success', `✅ Tests passed (${relativePath})`)) 66 | } else { 67 | exitCode = 1 68 | console.error(color('danger', `❌ Tests failed (${relativePath})`)) 69 | } 70 | res() 71 | }) 72 | }) 73 | } 74 | 75 | process.exit(exitCode) 76 | -------------------------------------------------------------------------------- /epicshop/update-deps.sh: -------------------------------------------------------------------------------- 1 | npx npm-check-updates --dep prod,dev --upgrade --workspaces --root 2 | cd epicshop && npx npm-check-updates --dep prod,dev --upgrade --root 3 | cd .. 4 | rm -rf node_modules package-lock.json ./epicshop/package-lock.json ./epicshop/node_modules ./exercises/**/node_modules 5 | npm install 6 | npm run setup 7 | npm run typecheck 8 | npm run lint --fix 9 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import defaultConfig from '@epic-web/config/eslint' 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | export default [ 5 | ...defaultConfig, 6 | { 7 | rules: { 8 | // we leave unused vars around for the exercises 9 | 'no-unused-vars': 'off', 10 | '@typescript-eslint/no-unused-vars': 'off', 11 | }, 12 | }, 13 | ] 14 | -------------------------------------------------------------------------------- /exercises/01.use-state/01.problem.initial-state/README.mdx: -------------------------------------------------------------------------------- 1 | # Initial State 2 | 3 | 4 | 5 | 👨‍💼 Hello! So here's where we're starting out: 6 | 7 | ```tsx 8 | import { createRoot } from 'react-dom/client' 9 | 10 | function Counter() { 11 | const [count, setCount] = useState(0) 12 | const increment = () => setCount(count + 1) 13 | 14 | return ( 15 |
16 | 17 |
18 | ) 19 | } 20 | 21 | const rootEl = document.createElement('div') 22 | document.body.append(rootEl) 23 | const appRoot = createRoot(rootEl) 24 | appRoot.render() 25 | ``` 26 | 27 | Pretty simple, except the `useState` function isn't defined and we're not 28 | allowed to simply import it from React because we're not allowed to directly use 29 | any React hooks in this workshop! 30 | 31 | And because it's not defined, our `Counter` component won't be able to render 32 | anything (because an error will be thrown). 33 | 34 | So let's just get it to the point where the `Counter` component renders 35 | initially without errors. When you're done, it should render a button with a `0` 36 | in it. 37 | 38 | The emoji should lead the way! 39 | -------------------------------------------------------------------------------- /exercises/01.use-state/01.problem.initial-state/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/01.use-state/01.problem.initial-state/index.tsx: -------------------------------------------------------------------------------- 1 | // 💣 delete this 2 | import { useState } from 'react' 3 | 4 | import { createRoot } from 'react-dom/client' 5 | 6 | // 🐨 create a `useState` function which accepts the initial state and returns 7 | // an array of the state and a no-op function: () => {} 8 | // 🦺 note you may need to ignore some typescript errors here. We'll fix them later. 9 | // Feel free to make the `useState` a generic though! 10 | // ⚠️ don't forget to `export` your `useState` function so the tests can find it 11 | 12 | function Counter() { 13 | const [count, setCount] = useState(0) 14 | // 🦺 you'll get an error for this we'll fix that next 15 | const increment = () => setCount(count + 1) 16 | 17 | return ( 18 |
19 | 20 |
21 | ) 22 | } 23 | 24 | const rootEl = document.createElement('div') 25 | document.body.append(rootEl) 26 | const appRoot = createRoot(rootEl) 27 | appRoot.render() 28 | -------------------------------------------------------------------------------- /exercises/01.use-state/01.solution.initial-state/README.mdx: -------------------------------------------------------------------------------- 1 | # Initial State 2 | 3 | 4 | 5 | 👨‍💼 Great work! Now at least we're not just throwing an error. But let's handle 6 | the state update next. 7 | -------------------------------------------------------------------------------- /exercises/01.use-state/01.solution.initial-state/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | import { screen, waitFor } from '@testing-library/dom' 3 | import { userEvent } from '@testing-library/user-event' 4 | 5 | await import('.') 6 | 7 | const counterButton = await testStep( 8 | ({ type }) => 9 | type === 'fail' 10 | ? 'Could not find the counter button. It should start at 0. Did you forget to return the initial state from your useState?' 11 | : 'Found the counter button that starts at 0', 12 | () => screen.findByRole('button', { name: /0/i }), 13 | ) 14 | await userEvent.click(counterButton) 15 | await testStep( 16 | `The button text should still be 0 after clicking because our useState isn't working yet`, 17 | () => waitFor(() => expect(counterButton).to.have.text('0')), 18 | ) 19 | -------------------------------------------------------------------------------- /exercises/01.use-state/01.solution.initial-state/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/01.use-state/01.solution.initial-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | export function useState(initialState: State) { 4 | const state = initialState 5 | const setState = () => {} 6 | return [state, setState] as const 7 | } 8 | 9 | function Counter() { 10 | const [count, setCount] = useState(0) 11 | // @ts-expect-error we'll fix this soon 12 | const increment = () => setCount(count + 1) 13 | 14 | return ( 15 |
16 | 17 |
18 | ) 19 | } 20 | 21 | const rootEl = document.createElement('div') 22 | document.body.append(rootEl) 23 | const appRoot = createRoot(rootEl) 24 | appRoot.render() 25 | -------------------------------------------------------------------------------- /exercises/01.use-state/01.solution.initial-state/state.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | 3 | const { useState } = await import('./index.tsx') 4 | 5 | await testStep( 6 | ({ type }) => 7 | type === 'fail' 8 | ? 'Did you forget to export your `useState`? I need you to export it so I can test it.' 9 | : 'useState is exported correctly', 10 | () => expect(useState).to.be.a('function'), 11 | ) 12 | 13 | // eslint-disable-next-line react-hooks/rules-of-hooks 14 | const [state, setState] = useState(5) 15 | 16 | await testStep( 17 | ({ type }) => 18 | type === 'pass' 19 | ? 'useState is returning the initial state' 20 | : 'useState is not returning the initial state', 21 | () => { 22 | expect(state).to.equal(5) 23 | }, 24 | ) 25 | 26 | await testStep( 27 | ({ type }) => 28 | type === 'pass' 29 | ? 'useState is returning a function' 30 | : 'useState is not returning a function', 31 | () => { 32 | expect(setState).to.be.a('function') 33 | }, 34 | ) 35 | -------------------------------------------------------------------------------- /exercises/01.use-state/02.problem.update-state/README.mdx: -------------------------------------------------------------------------------- 1 | # Update State 2 | 3 | 4 | 5 | 👨‍💼 Alright, right now when you click the button, nothing happens. Let's get it 6 | to update the state. 7 | 8 | Update the `setState` function to assign the given state to the new state. 9 | 10 | 11 | When you do this, it'll not actually update the number in the button either. 12 | We'll get to that soon! 13 | 14 | -------------------------------------------------------------------------------- /exercises/01.use-state/02.problem.update-state/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/01.use-state/02.problem.update-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | export function useState(initialState: State) { 4 | // 🐨 change this to let 5 | const state = initialState 6 | // 🐨 update this to accept newState and assign state to that 7 | const setState = () => {} 8 | return [state, setState] as const 9 | } 10 | 11 | function Counter() { 12 | const [count, setCount] = useState(0) 13 | // @ts-expect-error 💣 delete this comment 14 | const increment = () => setCount(count + 1) 15 | 16 | return ( 17 |
18 | 19 |
20 | ) 21 | } 22 | 23 | const rootEl = document.createElement('div') 24 | document.body.append(rootEl) 25 | const appRoot = createRoot(rootEl) 26 | appRoot.render() 27 | -------------------------------------------------------------------------------- /exercises/01.use-state/02.solution.update-state/README.mdx: -------------------------------------------------------------------------------- 1 | # Update State 2 | 3 | 4 | 5 | 👨‍💼 So we're updating the state value, but it's not actually updating the number 6 | in the button? What gives?! 7 | -------------------------------------------------------------------------------- /exercises/01.use-state/02.solution.update-state/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | import { screen, waitFor } from '@testing-library/dom' 3 | import { userEvent } from '@testing-library/user-event' 4 | 5 | await import('.') 6 | 7 | const counterButton = await testStep( 8 | ({ type }) => 9 | type === 'fail' 10 | ? 'Could not find the counter button. It should start at 0. Did you forget to return the initial state from your useState?' 11 | : 'Found the counter button that starts at 0', 12 | () => screen.findByRole('button', { name: /0/i }), 13 | ) 14 | await userEvent.click(counterButton) 15 | await testStep( 16 | `The button text should still be 0 after clicking because our useState isn't working yet`, 17 | () => waitFor(() => expect(counterButton).to.have.text('0')), 18 | ) 19 | -------------------------------------------------------------------------------- /exercises/01.use-state/02.solution.update-state/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/01.use-state/02.solution.update-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | export function useState(initialState: State) { 4 | let state = initialState 5 | const setState = (newState: State) => (state = newState) 6 | return [state, setState] as const 7 | } 8 | 9 | function Counter() { 10 | const [count, setCount] = useState(0) 11 | const increment = () => setCount(count + 1) 12 | 13 | return ( 14 |
15 | 16 |
17 | ) 18 | } 19 | 20 | const rootEl = document.createElement('div') 21 | document.body.append(rootEl) 22 | const appRoot = createRoot(rootEl) 23 | appRoot.render() 24 | -------------------------------------------------------------------------------- /exercises/01.use-state/02.solution.update-state/state.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | 3 | const { useState } = await import('./index.tsx') 4 | 5 | await testStep( 6 | ({ type }) => 7 | type === 'fail' 8 | ? 'Did you forget to export your `useState`? I need you to export it so I can test it.' 9 | : 'useState is exported correctly', 10 | () => expect(useState).to.be.a('function'), 11 | ) 12 | 13 | // eslint-disable-next-line react-hooks/rules-of-hooks 14 | const [state, setState] = useState(5) 15 | 16 | await testStep( 17 | ({ type }) => 18 | type === 'pass' 19 | ? 'useState is returning the initial state' 20 | : 'useState is not returning the initial state', 21 | () => { 22 | expect(state).to.equal(5) 23 | }, 24 | ) 25 | 26 | await testStep( 27 | ({ type }) => 28 | type === 'pass' 29 | ? 'useState is returning a function' 30 | : 'useState is not returning a function', 31 | () => { 32 | expect(setState).to.be.a('function') 33 | }, 34 | ) 35 | 36 | await testStep( 37 | 'There are no testable changes in this exercise step from the previous one. Keep going!', 38 | () => {}, 39 | ) 40 | -------------------------------------------------------------------------------- /exercises/01.use-state/03.problem.re-render/README.mdx: -------------------------------------------------------------------------------- 1 | # Re-render 2 | 3 | 4 | 5 | 👨‍💼 Ok, so we're initializing our state properly and we're updating the state 6 | properly as well. The problem is we're not updating the UI when the state gets 7 | updated. Remember, we're not React. We need to tell React when the state has 8 | changed so it will render our component again. 9 | 10 | Because we're not React, the way we will do this is by simply calling `render` 11 | our root again. Remember what the bottom of our file looks like? 12 | 13 | ```tsx lines=4 14 | const rootEl = document.createElement('div') 15 | document.body.append(rootEl) 16 | const appRoot = createRoot(rootEl) 17 | appRoot.render() 18 | ``` 19 | 20 | That last line there is where we render the component. So we just need to call 21 | that any time we want the component updated! 22 | 23 | So in this exercise, wrap that bit in a function and call it once for the 24 | initial render and once in the `setState` function. 25 | 26 | Feel free to toss in a `console.log` in the component to make sure it's 27 | re-rendering. 28 | 29 | 30 | When you're finished with this, the UI will **still** not work like you 31 | expect. I promise we'll get to that very soon! 32 | 33 | -------------------------------------------------------------------------------- /exercises/01.use-state/03.problem.re-render/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/01.use-state/03.problem.re-render/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | export function useState(initialState: State) { 4 | let state = initialState 5 | // 🐨 update this function to call render after setting the state 6 | const setState = (newState: State) => (state = newState) 7 | return [state, setState] as const 8 | } 9 | 10 | function Counter() { 11 | const [count, setCount] = useState(0) 12 | const increment = () => setCount(count + 1) 13 | 14 | return ( 15 |
16 | 17 |
18 | ) 19 | } 20 | 21 | const rootEl = document.createElement('div') 22 | document.body.append(rootEl) 23 | const appRoot = createRoot(rootEl) 24 | 25 | // 🐨 place this in a new function called render 26 | appRoot.render() 27 | 28 | // 🐨 call render here to kick things off 29 | -------------------------------------------------------------------------------- /exercises/01.use-state/03.solution.re-render/README.mdx: -------------------------------------------------------------------------------- 1 | # Re-render 2 | 3 | 4 | 5 | 👨‍💼 Great work! Now we're not only updating the state, but we're also triggering 6 | a re-render so the UI can be updated. Unfortunately that seems to not be working 7 | either? Let's figure out why. 8 | -------------------------------------------------------------------------------- /exercises/01.use-state/03.solution.re-render/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | import { screen, waitFor } from '@testing-library/dom' 3 | import { userEvent } from '@testing-library/user-event' 4 | 5 | await import('.') 6 | 7 | const counterButton = await testStep( 8 | ({ type }) => 9 | type === 'fail' 10 | ? 'Could not find the counter button. It should start at 0. Did you forget to return the initial state from your useState?' 11 | : 'Found the counter button that starts at 0', 12 | () => screen.findByRole('button', { name: /0/i }), 13 | ) 14 | await userEvent.click(counterButton) 15 | await testStep( 16 | `The button text should still be 0 after clicking because our useState isn't working yet`, 17 | () => waitFor(() => expect(counterButton).to.have.text('0')), 18 | ) 19 | -------------------------------------------------------------------------------- /exercises/01.use-state/03.solution.re-render/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/01.use-state/03.solution.re-render/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | export function useState(initialState: State) { 4 | let state = initialState 5 | const setState = (newState: State) => { 6 | state = newState 7 | render() 8 | } 9 | return [state, setState] as const 10 | } 11 | 12 | function Counter() { 13 | const [count, setCount] = useState(0) 14 | const increment = () => setCount(count + 1) 15 | 16 | return ( 17 |
18 | 19 |
20 | ) 21 | } 22 | 23 | const rootEl = document.createElement('div') 24 | document.body.append(rootEl) 25 | const appRoot = createRoot(rootEl) 26 | 27 | function render() { 28 | appRoot.render() 29 | } 30 | 31 | render() 32 | -------------------------------------------------------------------------------- /exercises/01.use-state/03.solution.re-render/state.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | 3 | const { useState } = await import('./index.tsx') 4 | 5 | await testStep( 6 | ({ type }) => 7 | type === 'fail' 8 | ? 'Did you forget to export your `useState`? I need you to export it so I can test it.' 9 | : 'useState is exported correctly', 10 | () => expect(useState).to.be.a('function'), 11 | ) 12 | 13 | // eslint-disable-next-line react-hooks/rules-of-hooks 14 | const [state, setState] = useState(5) 15 | 16 | await testStep( 17 | ({ type }) => 18 | type === 'pass' 19 | ? 'useState is returning the initial state' 20 | : 'useState is not returning the initial state', 21 | () => { 22 | expect(state).to.equal(5) 23 | }, 24 | ) 25 | 26 | await testStep( 27 | ({ type }) => 28 | type === 'pass' 29 | ? 'useState is returning a function' 30 | : 'useState is not returning a function', 31 | () => { 32 | expect(setState).to.be.a('function') 33 | }, 34 | ) 35 | 36 | await testStep( 37 | 'There are no still testable changes in this exercise step from the previous one. Keep going!', 38 | () => {}, 39 | ) 40 | -------------------------------------------------------------------------------- /exercises/01.use-state/04.problem.preserve-state/README.mdx: -------------------------------------------------------------------------------- 1 | # Preserve State 2 | 3 | 4 | 5 | 👨‍💼 Alright, so there are actually two problems here. First, when the user clicks 6 | on the button, we update the `state` variable inside the `useState` closure, but 7 | that variable is not accessible by our component. Our component has its own 8 | variable called `count` which is not being updated 9 | 10 | 11 | Just because two variables point to the same object in memory (or in our case, 12 | the same number) doesn't mean they stay in sync when one is reassigned. That's 13 | just how JavaScript works. 14 | 15 | 16 | The second problem we have is when our component is called, it calls `useState` 17 | and that creates a brand new `state` variable that's assigned to the 18 | `initialState` variable again. 19 | 20 | So we need a way to preserve the state between renders. We can do that by 21 | pulling the `state` and `setState` variables outside the `useState` hook and 22 | simply assigning them on the initial render of the component. 23 | 24 | Give that a try! 25 | 26 | 27 | The button will finally work on this one, I promise! 28 | 29 | -------------------------------------------------------------------------------- /exercises/01.use-state/04.problem.preserve-state/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/01.use-state/04.problem.preserve-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | // 🐨 create state and setState variables here using let 4 | // 🦺 set their type to "any" 5 | 6 | export function useState(initialState: State) { 7 | // 🐨 remove the "let" and "const" here so this function references the 8 | // variables declared above 9 | // 🐨 Next, change this so we only do these assignments if the state is undefined 10 | let state = initialState 11 | const setState = (newState: State) => { 12 | state = newState 13 | render() 14 | } 15 | // 🦺 because our state and setState are now typed as any, you may choose to 16 | // update this to as [State, (newState: State) => void] so we can preserve 17 | // the type of state 18 | return [state, setState] as const 19 | } 20 | 21 | function Counter() { 22 | const [count, setCount] = useState(0) 23 | const increment = () => setCount(count + 1) 24 | 25 | return ( 26 |
27 | 28 |
29 | ) 30 | } 31 | 32 | const rootEl = document.createElement('div') 33 | document.body.append(rootEl) 34 | const appRoot = createRoot(rootEl) 35 | 36 | function render() { 37 | appRoot.render() 38 | } 39 | 40 | render() 41 | -------------------------------------------------------------------------------- /exercises/01.use-state/04.solution.preserve-state/README.mdx: -------------------------------------------------------------------------------- 1 | # Preserve State 2 | 3 | 4 | 5 | 👨‍💼 Great work! Our UI is working properly! By preserving our state we're able to 6 | make changes to it and render again whenever that value changes. 7 | -------------------------------------------------------------------------------- /exercises/01.use-state/04.solution.preserve-state/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | import { screen, waitFor } from '@testing-library/dom' 3 | import { userEvent } from '@testing-library/user-event' 4 | 5 | await import('.') 6 | 7 | const counterButton = await testStep( 8 | ({ type }) => 9 | type === 'fail' 10 | ? 'Could not find the counter button. It should start at 0. Did you forget to return the initial state from your useState?' 11 | : 'Found the counter button that starts at 0', 12 | () => screen.findByRole('button', { name: /0/i }), 13 | ) 14 | await userEvent.click(counterButton) 15 | await testStep(`The button text should be 1 after clicking`, () => 16 | waitFor(() => expect(counterButton).to.have.text('1')), 17 | ) 18 | -------------------------------------------------------------------------------- /exercises/01.use-state/04.solution.preserve-state/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/01.use-state/04.solution.preserve-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | let state: any, setState: any 4 | 5 | export function useState(initialState: State) { 6 | if (state === undefined) { 7 | state = initialState 8 | setState = (newState: State) => { 9 | state = newState 10 | render() 11 | } 12 | } 13 | return [state, setState] as [State, (newState: State) => void] 14 | } 15 | 16 | function Counter() { 17 | const [count, setCount] = useState(0) 18 | const increment = () => setCount(count + 1) 19 | 20 | return ( 21 |
22 | 23 |
24 | ) 25 | } 26 | 27 | const rootEl = document.createElement('div') 28 | document.body.append(rootEl) 29 | const appRoot = createRoot(rootEl) 30 | 31 | function render() { 32 | appRoot.render() 33 | } 34 | 35 | render() 36 | -------------------------------------------------------------------------------- /exercises/01.use-state/04.solution.preserve-state/state.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | 3 | const { useState } = await import('./index.tsx') 4 | 5 | await testStep( 6 | ({ type }) => 7 | type === 'fail' 8 | ? 'Did you forget to export your `useState`? I need you to export it so I can test it.' 9 | : 'useState is exported correctly', 10 | () => expect(useState).to.be.a('function'), 11 | ) 12 | 13 | // eslint-disable-next-line react-hooks/rules-of-hooks 14 | const [state, setState] = useState(5) 15 | 16 | await testStep( 17 | ({ type }) => 18 | type === 'pass' 19 | ? 'useState is returning the initial state' 20 | : 'useState is not returning the initial state', 21 | () => { 22 | expect(state).to.equal(5) 23 | }, 24 | ) 25 | 26 | await testStep( 27 | ({ type }) => 28 | type === 'pass' 29 | ? 'useState is returning a function' 30 | : 'useState is not returning a function', 31 | () => { 32 | expect(setState).to.be.a('function') 33 | }, 34 | ) 35 | -------------------------------------------------------------------------------- /exercises/01.use-state/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # useState 2 | 3 | 4 | 5 | 👨‍💼 This is a great time for you to take a break and reflect on your learnings so 6 | far. Take a moment and then when you're ready, we'll see you in the next 7 | exercise. 8 | 9 | There's still plenty to do! 10 | -------------------------------------------------------------------------------- /exercises/01.use-state/README.mdx: -------------------------------------------------------------------------------- 1 | # useState 2 | 3 | 4 | 5 | The `useState` hook is one of the most common hooks and is a good place for us 6 | to start because it triggers re-rendering of the component and we can observe 7 | its effects in the UI. 8 | 9 | The goal of this exercise is not to build something super rigourous, but instead 10 | to hack away until it works. 11 | 12 | Here's the basic API that we need to implement in this exercise: 13 | 14 | ```tsx 15 | const [state, setState] = useState(initialState) 16 | 17 | function handleEvent() { 18 | setState(newState) 19 | } 20 | ``` 21 | 22 | The `useState` hook accepts an initial value, and returns an array with two 23 | values: 24 | 25 | - `state`: the current value of the state 26 | - `setState`: a function that updates the state 27 | 28 | We will use the `setState` function to update the state of the component. But 29 | just updating the state is not enough. We need to trigger a re-render of the 30 | component as well. 31 | 32 | Because we're not React, we need to hack away at things a little bit. For 33 | example, the built-in `useState` hook is _the_ way to trigger re-renders, but 34 | since we'll be implementing `useState` ourselves, we'll have to be creative on 35 | how we trigger re-renders. 36 | 37 | Another tricky thing will be ensuring we can keep track of the state external to 38 | our component so when the component gets rendered again we can access the latest 39 | version of the state. 40 | 41 | Let's get into it! 42 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/01.problem.phase/README.mdx: -------------------------------------------------------------------------------- 1 | # Render Phase 2 | 3 | 4 | 5 | 🧝‍♂️ Hi! I made a change to the code a bit. Now we're rendering two buttons, the 6 | count button is still there, but now we're also a button for disabling the count 7 | button. I needed to add another `useState` for that, but it's not working. You 8 | can check my work if you'd like. Can you get it 9 | working? Thanks! 10 | 11 | 👨‍💼 Thanks for adding those buttons Kellie! 12 | 13 | Ok, so what we need you to do is fix the problem. If you add a 14 | `console.log({ count, enabled })` to the component, you'll get 15 | `{ count: 0, enabled: 0 }`. This is because the first time the `useState` is 16 | called initializes the state and the second time it's called, it just references 17 | the first one. 18 | 19 | So to start off fixing this issue, you're going to need to formalize how we 20 | determine whether state gets initialized or referenced. 21 | 22 | Really, `useState` can be called in two scenarios: 23 | 24 | - Initialization 25 | - Updates 26 | 27 | So we're going to keep track of how this is called with a `phase` variable. The 28 | emoji will guide you in the right direction. Good luck! 29 | 30 | 31 | Note it's not going to quite work when you're finished with this step, but 32 | it'll work soon! 33 | 34 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/01.problem.phase/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/01.problem.phase/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | // 🐨 create two Symbols for the phase: "INITIALIZATION" and "UPDATE" 4 | // 💯 as extra credit, give them a descriptive name 5 | 6 | // 🦺 create a type called Phase which is the typeof INITIALIZATION | typeof UPDATE 7 | 8 | // 🐨 create a variable called phase of type Phase and set it to INITIALIZATION 9 | 10 | let state: any, setState: any 11 | 12 | export function useState(initialState: State) { 13 | // 🐨 change this to check whether the phase is INITIALIZATION 14 | if (state === undefined) { 15 | state = initialState 16 | setState = (newState: State) => { 17 | state = newState 18 | // 🐨 pass the UPDATE phase to render here 19 | render() 20 | } 21 | } 22 | return [state, setState] as [State, (newState: State) => void] 23 | } 24 | 25 | function Counter() { 26 | const [count, setCount] = useState(0) 27 | const increment = () => setCount(count + 1) 28 | 29 | const [enabled, setEnabled] = useState(true) 30 | const toggle = () => setEnabled(!enabled) 31 | 32 | return ( 33 |
34 | 35 | 38 |
39 | ) 40 | } 41 | 42 | const rootEl = document.createElement('div') 43 | document.body.append(rootEl) 44 | const appRoot = createRoot(rootEl) 45 | 46 | // 🐨 accept a newPhase argument 47 | function render() { 48 | // 🐨 assign the phase to the newPhase 49 | appRoot.render() 50 | } 51 | 52 | // 🐨 call this with the INITIALIZATION phase 53 | render() 54 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/01.solution.phase/README.mdx: -------------------------------------------------------------------------------- 1 | # Render Phase 2 | 3 | 4 | 5 | 👨‍💼 Great! It's not quite working yet, now if we add 6 | `console.log({ count, enabled })` to the component, we'll get 7 | `{ count: 0, enabled: true }` like you'd expect, but when you click the counter 8 | button we get `{ count: 1, enabled: 1 }` 😅. And if you click the disable button 9 | you get `{ count: false, enabled: false }`. What the heck is going on!? Let's 10 | find out. 11 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/01.solution.phase/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | import { screen, waitFor } from '@testing-library/dom' 3 | import { userEvent } from '@testing-library/user-event' 4 | 5 | await import('.') 6 | 7 | const counterButton = await testStep( 8 | ({ type }) => 9 | type === 'fail' 10 | ? 'Could not find the counter button. It should start at 0. Did you forget to return the initial state from your useState?' 11 | : 'Found the counter button that starts at 0', 12 | () => screen.findByRole('button', { name: /0/i }), 13 | ) 14 | await userEvent.click(counterButton) 15 | await testStep( 16 | ({ type }) => 17 | type === 'pass' 18 | ? `The button text should be 1 after clicking.` 19 | : `The button text should be 1 after clicking, but it's not. This is most likely because the second useState call is getting the value from the first one so "enabled" is set to "0". Add the phase tracking.`, 20 | () => waitFor(() => expect(counterButton).to.have.text('1')), 21 | ) 22 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/01.solution.phase/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/01.solution.phase/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | const INITIALIZATION = Symbol('phase.initialization') 4 | const UPDATE = Symbol('phase.update') 5 | type Phase = typeof INITIALIZATION | typeof UPDATE 6 | let phase: Phase 7 | 8 | let state: any, setState: any 9 | 10 | export function useState(initialState: State) { 11 | if (phase === INITIALIZATION) { 12 | state = initialState 13 | setState = (newState: State) => { 14 | state = newState 15 | render(UPDATE) 16 | } 17 | } 18 | return [state, setState] as [State, (newState: State) => void] 19 | } 20 | 21 | function Counter() { 22 | const [count, setCount] = useState(0) 23 | const increment = () => setCount(count + 1) 24 | 25 | const [enabled, setEnabled] = useState(true) 26 | const toggle = () => setEnabled(!enabled) 27 | 28 | return ( 29 |
30 | 31 | 34 |
35 | ) 36 | } 37 | 38 | const rootEl = document.createElement('div') 39 | document.body.append(rootEl) 40 | const appRoot = createRoot(rootEl) 41 | 42 | function render(newPhase: Phase) { 43 | phase = newPhase 44 | appRoot.render() 45 | } 46 | 47 | render(INITIALIZATION) 48 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/02.problem.hook-id/README.mdx: -------------------------------------------------------------------------------- 1 | # Hook ID 2 | 3 | 4 | 5 | 👨‍💼 Based on what's happening now, I think we're not isolating the `state` 6 | between the two hooks. We need to uniquely identify each hook and store their 7 | state separately. 8 | 9 | We know that the hooks are called in the same order every time, so we could keep 10 | a call index (we'll call it the `hookIndex`) and increment it every time 11 | `useState` is called. That way, we could assign the first hook an ID of `0` and 12 | the second hook an ID of `1`. 13 | 14 | Then we store the `state` and `setState` in an array with their ID as the 15 | key. 16 | 17 | Then, whenever we render we just reset the `hookIndex` to `0` and we'll be 18 | golden! 19 | 20 | It'll work this time, I promise! 21 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/02.problem.hook-id/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/02.problem.hook-id/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | const INITIALIZATION = Symbol('phase.initialization') 4 | const UPDATE = Symbol('phase.update') 5 | type Phase = typeof INITIALIZATION | typeof UPDATE 6 | let phase: Phase 7 | // 🐨 make a hookIndex variable here that starts at 0 8 | // 🐨 make a variable called "states" which is an array of arrays (one for each 9 | // return value of a useState call) 10 | 11 | // 💣 delete these variable declarations 12 | let state: any, setState: any 13 | 14 | export function useState(initialState: State) { 15 | // 🐨 create a variable called "id" and assign it to "hookIndex++" 16 | if (phase === INITIALIZATION) { 17 | // 🐨 assign states[id] to an array with the initialState and the setState function 18 | // rather than assigning the values to the old variables 19 | state = initialState 20 | setState = (newState: State) => { 21 | // 🐨 instead of reassigning the variable state to the newState, update states[id][0] to it. 22 | state = newState 23 | render(UPDATE) 24 | } 25 | } 26 | // 🐨 return the value at states[id] instead of the old variables 27 | return [state, setState] as [State, (newState: State) => void] 28 | } 29 | 30 | function Counter() { 31 | const [count, setCount] = useState(0) 32 | const increment = () => setCount(count + 1) 33 | 34 | const [enabled, setEnabled] = useState(true) 35 | const toggle = () => setEnabled(!enabled) 36 | 37 | return ( 38 |
39 | 40 | 43 |
44 | ) 45 | } 46 | 47 | const rootEl = document.createElement('div') 48 | document.body.append(rootEl) 49 | const appRoot = createRoot(rootEl) 50 | 51 | function render(newPhase: Phase) { 52 | // 🐨 set the hookIndex to 0 53 | phase = newPhase 54 | appRoot.render() 55 | } 56 | 57 | render(INITIALIZATION) 58 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/02.solution.hook-id/README.mdx: -------------------------------------------------------------------------------- 1 | # Hook ID 2 | 3 | 4 | 5 | 👨‍💼 Hey, that works! And now you understand why it's important to avoid 6 | conditionally calling hooks or call them in loops. Their call order is their 7 | only uniquely identifying trait! 8 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/02.solution.hook-id/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | import { screen, waitFor } from '@testing-library/dom' 3 | import { userEvent } from '@testing-library/user-event' 4 | 5 | await import('.') 6 | 7 | const counterButton = await testStep( 8 | ({ type }) => 9 | type === 'fail' 10 | ? 'Could not find the counter button. It should start at 0. Did you forget to return the initial state from your useState?' 11 | : 'Found the counter button that starts at 0', 12 | () => screen.findByRole('button', { name: /0/i }), 13 | ) 14 | await userEvent.click(counterButton) 15 | await testStep( 16 | ({ type }) => 17 | type === 'pass' 18 | ? `The button text should be 1 after clicking.` 19 | : `The button text should be 1 after clicking, but it's not. This is most likely because the second useState call is getting the value from the first one so "enabled" is set to "0". Add the phase tracking.`, 20 | () => waitFor(() => expect(counterButton).to.have.text('1')), 21 | ) 22 | const disableButton = await testStep( 23 | ({ type }) => 24 | type === 'fail' 25 | ? 'Could not find the disable button. It should start with the text "disable". Did you forget to return the initial state from your useState?' 26 | : 'Found the disable button that starts with "disable" text', 27 | () => screen.findByRole('button', { name: /Disable/i }), 28 | ) 29 | await userEvent.click(disableButton) 30 | await testStep( 31 | ({ type }) => 32 | type === 'pass' 33 | ? `The counter should be disabled after clicking the disable button.` 34 | : `The counter should be disabled after clicking the disable button, but it's not. Did you properly set the id for each useState call?`, 35 | () => waitFor(() => expect(counterButton).to.have.attribute('disabled')), 36 | ) 37 | await testStep( 38 | ({ type }) => 39 | type === 'pass' 40 | ? `The button text should be "enable" after clicking the disable button.` 41 | : `The button text should be "enable" after clicking the disable button, but it's not. Did you properly set the id for each useState call?`, 42 | () => waitFor(() => expect(disableButton).to.have.text('Enable')), 43 | ) 44 | 45 | await testStep( 46 | ({ type }) => 47 | type === 'pass' 48 | ? `The counter should keep its text content even when disabled.` 49 | : `The counter should keep its text content even when disabled, but it's not. Did you properly set the id for each useState call?`, 50 | () => waitFor(() => expect(counterButton).to.have.text('1')), 51 | ) 52 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/02.solution.hook-id/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/02.solution.hook-id/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | const INITIALIZATION = Symbol('phase.initialization') 4 | const UPDATE = Symbol('phase.update') 5 | type Phase = typeof INITIALIZATION | typeof UPDATE 6 | let phase: Phase 7 | let hookIndex = 0 8 | const states: Array<[any, (newState: any) => void]> = [] 9 | 10 | export function useState(initialState: State) { 11 | const id = hookIndex++ 12 | if (phase === INITIALIZATION) { 13 | states[id] = [ 14 | initialState, 15 | (newState: State) => { 16 | states[id][0] = newState 17 | render(UPDATE) 18 | }, 19 | ] 20 | } 21 | return states[id] as [State, (newState: State) => void] 22 | } 23 | 24 | function Counter() { 25 | const [count, setCount] = useState(0) 26 | const increment = () => setCount(count + 1) 27 | 28 | const [enabled, setEnabled] = useState(true) 29 | const toggle = () => setEnabled(!enabled) 30 | 31 | return ( 32 |
33 | 34 | 37 |
38 | ) 39 | } 40 | 41 | const rootEl = document.createElement('div') 42 | document.body.append(rootEl) 43 | const appRoot = createRoot(rootEl) 44 | 45 | function render(newPhase: Phase) { 46 | hookIndex = 0 47 | phase = newPhase 48 | appRoot.render() 49 | } 50 | 51 | render(INITIALIZATION) 52 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Multiple Hooks 2 | 3 | 4 | 5 | 👨‍💼 Whew, now we've got multiple `useState` hooks in a single component working! 6 | It's a good time for a break. Write down what you learned, then we'll see you 7 | back soon! 8 | -------------------------------------------------------------------------------- /exercises/02.multiple-hooks/README.mdx: -------------------------------------------------------------------------------- 1 | # Multiple Hooks 2 | 3 | 4 | 5 | Often components require more than a single hook. In fact, often they'll use 6 | two or more of the `useState` hook. If we tried that with our implementation 7 | right now things wouldn't work so well and I think you can imagine why. 8 | 9 | The tricky part about this is when you have more than one hook, you need to be 10 | able to track their values over the lifetime of the component relative to each 11 | other and that component. What makes this difficult though is that there's no 12 | uniquely identifying information about the hooks: 13 | 14 | ```tsx 15 | const [count1, setCount1] = useState(0) 16 | const [count2, setCount2] = useState(0) 17 | ``` 18 | 19 | Having two elements of state like this in a component is perfectly legitimate, 20 | but our current implementation wouldn't work for that at all because the state 21 | would be shared between the two hooks. 22 | 23 | So how do we get around that? 24 | 25 | Well, it's not entirely true to say that there's no uniquely identifying 26 | information about the hooks.... There actually is something unique about these 27 | function calls and that is the order in which they are called! 28 | 29 | If we can assume that they'll always be called in the same order, then we can 30 | assign the first one an ID of `0` and the second one an ID of `1`. Then we can 31 | use that ID to track the state of the hooks! 32 | 33 | Something you will hopefully gather from this exercise is an understanding of 34 | why the ["rules of hooks"](https://react.dev/reference/rules/rules-of-hooks) is 35 | a thing. Specifically the rule that hooks must be called at the top level (and 36 | not conditionally). 37 | 38 | So, let's get into it! 39 | -------------------------------------------------------------------------------- /exercises/03.use-effect/01.problem.callback/README.mdx: -------------------------------------------------------------------------------- 1 | # Callback 2 | 3 | 4 | 5 | 🧝‍♂️ I've added a `useEffect`, but it's not supported yet so the app's busted: 6 | 7 | ```tsx 8 | useEffect(() => { 9 | console.info('consider yourself effective!') 10 | }) 11 | ``` 12 | 13 | You can check my work if you'd like. Can you fix 14 | it? Thanks! 15 | 16 | 👨‍💼 Sure thing Kellie, thanks! 17 | 18 | Ok, so what we need to do here is going to feel a little familiar. We'll want to 19 | create an `effects` array that stores all the callbacks. Then our `useEffect` 20 | hook implementation will actually be pretty darn simple: get the `ID` for our 21 | hook, add the callback to the `effects` array. We don't even have to return 22 | anything. 23 | 24 | The tricky bit will be to make the `appRoot.render` call synchronous with 25 | `flushSync` so we can iterate through all the effects to call the callback. 26 | 27 | I think you can do it. Let's go! 28 | -------------------------------------------------------------------------------- /exercises/03.use-effect/01.problem.callback/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/03.use-effect/01.problem.callback/index.tsx: -------------------------------------------------------------------------------- 1 | // 💣 delete this so we can implement our own 2 | import { useEffect } from 'react' 3 | 4 | // 💰 you'll need this 5 | // import { flushSync } from 'react-dom' 6 | import { createRoot } from 'react-dom/client' 7 | 8 | const INITIALIZATION = Symbol('phase.initialization') 9 | const UPDATE = Symbol('phase.update') 10 | type Phase = typeof INITIALIZATION | typeof UPDATE 11 | let phase: Phase 12 | let hookIndex = 0 13 | const states: Array<[any, (newState: any) => void]> = [] 14 | type EffectCallback = () => void 15 | // 🐨 make a variable called "effects" that's an array of objects with a callback property 16 | // of the "EffectCallback" type we've defined above 17 | 18 | export function useState(initialState: State) { 19 | const id = hookIndex++ 20 | if (phase === INITIALIZATION) { 21 | states[id] = [ 22 | initialState, 23 | (newState: State) => { 24 | states[id][0] = newState 25 | render(UPDATE) 26 | }, 27 | ] 28 | } 29 | return states[id] as [State, (newState: State) => void] 30 | } 31 | 32 | // 🐨 create a useEffect function here that accepts an "EffectCallback" callback, 33 | // and adds the callback to the effects array at the index "hookIndex++" 34 | // 🚨 make sure to export this function so I can test it 35 | 36 | function Counter() { 37 | const [count, setCount] = useState(0) 38 | const increment = () => setCount(count + 1) 39 | 40 | const [enabled, setEnabled] = useState(true) 41 | const toggle = () => setEnabled(!enabled) 42 | 43 | useEffect(() => { 44 | console.info('consider yourself effective!') 45 | }) 46 | 47 | return ( 48 |
49 | 50 | 53 |
54 | ) 55 | } 56 | 57 | const rootEl = document.createElement('div') 58 | document.body.append(rootEl) 59 | const appRoot = createRoot(rootEl) 60 | 61 | function render(newPhase: Phase) { 62 | hookIndex = 0 63 | phase = newPhase 64 | 65 | // 🦉 Because we have no way of knowing when React will finish rendering so we 66 | // can call our effects, we need to cheat a little bit by telling React to 67 | // render synchronously instead... 68 | // 🐨 wrap this in flushSync 69 | appRoot.render() 70 | 71 | // 🐨 add a for of loop for all the effects and call their callbacks, 72 | // making sure to skip over any undefined effects 73 | } 74 | 75 | render(INITIALIZATION) 76 | -------------------------------------------------------------------------------- /exercises/03.use-effect/01.solution.callback/README.mdx: -------------------------------------------------------------------------------- 1 | # Callback 2 | 3 | 4 | 5 | 👨‍💼 Great job! Now, can you handle the dependency array? 6 | -------------------------------------------------------------------------------- /exercises/03.use-effect/01.solution.callback/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | import { screen, waitFor } from '@testing-library/dom' 3 | import { userEvent } from '@testing-library/user-event' 4 | 5 | const originalConsoleInfo = console.info 6 | 7 | function info(...args: Array) { 8 | info.calls.push(args) 9 | return originalConsoleInfo(...args) 10 | } 11 | info.calls = [] as Array> 12 | 13 | console.info = info 14 | 15 | await import('.') 16 | 17 | await testStep( 18 | ({ type }) => 19 | type === 'pass' 20 | ? 'The effect callback was called on the initial render' 21 | : 'The effect callback was not called on the initial render. Did you call it after rendering and using flushSync?', 22 | () => { 23 | expect(info.calls.length).to.equal(1) 24 | }, 25 | ) 26 | 27 | const counterButton = await testStep( 28 | ({ type }) => 29 | type === 'fail' 30 | ? 'Could not find the counter button. It should start at 0. Did you forget to return the initial state from your useState?' 31 | : 'Found the counter button that starts at 0', 32 | () => screen.findByRole('button', { name: /0/i }), 33 | ) 34 | await userEvent.click(counterButton) 35 | await testStep(`The button text should be 1 after clicking`, () => 36 | waitFor(() => expect(counterButton).to.have.text('1')), 37 | ) 38 | 39 | await testStep( 40 | ({ type }) => 41 | type === 'pass' 42 | ? 'The effect callback was called after the initial render' 43 | : 'The effect callback was not called after the initial render. Did you call it after rendering and using flushSync?', 44 | () => { 45 | expect(info.calls.length).to.equal(2) 46 | }, 47 | ) 48 | -------------------------------------------------------------------------------- /exercises/03.use-effect/01.solution.callback/effect.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | 3 | const { useEffect } = await import('./index.tsx') 4 | 5 | await testStep( 6 | ({ type }) => 7 | type === 'fail' 8 | ? 'Did you forget to export your `useEffect`? I need you to export it so I can test it.' 9 | : 'useEffect is exported correctly', 10 | () => expect(useEffect).to.be.a('function'), 11 | ) 12 | 13 | await testStep( 14 | ({ type }) => 15 | type === 'pass' 16 | ? 'useEffect is returning undefined' 17 | : 'useEffect is not returning undefined', 18 | () => { 19 | expect(useEffect(() => {})).to.be.undefined 20 | }, 21 | ) 22 | -------------------------------------------------------------------------------- /exercises/03.use-effect/01.solution.callback/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/03.use-effect/01.solution.callback/index.tsx: -------------------------------------------------------------------------------- 1 | import { flushSync } from 'react-dom' 2 | import { createRoot } from 'react-dom/client' 3 | 4 | const INITIALIZATION = Symbol('phase.initialization') 5 | const UPDATE = Symbol('phase.update') 6 | type Phase = typeof INITIALIZATION | typeof UPDATE 7 | let phase: Phase 8 | let hookIndex = 0 9 | const states: Array<[any, (newState: any) => void]> = [] 10 | type EffectCallback = () => void 11 | const effects: Array<{ 12 | callback: EffectCallback 13 | }> = [] 14 | 15 | export function useState(initialState: State) { 16 | const id = hookIndex++ 17 | if (phase === INITIALIZATION) { 18 | states[id] = [ 19 | initialState, 20 | (newState: State) => { 21 | states[id][0] = newState 22 | render(UPDATE) 23 | }, 24 | ] 25 | } 26 | return states[id] as [State, (newState: State) => void] 27 | } 28 | 29 | export function useEffect(callback: EffectCallback) { 30 | const id = hookIndex++ 31 | effects[id] = { callback } 32 | } 33 | 34 | function Counter() { 35 | const [count, setCount] = useState(0) 36 | const increment = () => setCount(count + 1) 37 | 38 | const [enabled, setEnabled] = useState(true) 39 | const toggle = () => setEnabled(!enabled) 40 | 41 | useEffect(() => { 42 | console.info('consider yourself effective!') 43 | }) 44 | 45 | return ( 46 |
47 | 48 | 51 |
52 | ) 53 | } 54 | 55 | const rootEl = document.createElement('div') 56 | document.body.append(rootEl) 57 | const appRoot = createRoot(rootEl) 58 | 59 | function render(newPhase: Phase) { 60 | hookIndex = 0 61 | phase = newPhase 62 | flushSync(() => { 63 | appRoot.render() 64 | }) 65 | 66 | for (const effect of effects) { 67 | if (!effect) continue 68 | 69 | effect.callback() 70 | } 71 | } 72 | 73 | render(INITIALIZATION) 74 | -------------------------------------------------------------------------------- /exercises/03.use-effect/02.problem.dependencies/README.mdx: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | 3 | 4 | 5 | 🧝‍♂️ I've updated the `useEffect`: 6 | 7 | ```tsx 8 | useEffect(() => { 9 | if (enabled) { 10 | console.info('consider yourself effective!') 11 | } else { 12 | console.info('consider yourself ineffective!') 13 | } 14 | }, [enabled]) 15 | ``` 16 | 17 | You can check my work if you'd like. The app's not 18 | technically broken, but the logs should only happen when toggling enabled and 19 | right now we're getting logs when clicking the counter as well. 20 | 21 | 👨‍💼 We can handle this! So you'll need to also keep track of the deps array and 22 | even the previous value of the deps array. Then just add a little logic to 23 | determine whether to call the effect callback based on whether any of the 24 | dependencies changed. 25 | 26 | You can do this. Let's go! 27 | -------------------------------------------------------------------------------- /exercises/03.use-effect/02.problem.dependencies/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/03.use-effect/02.problem.dependencies/index.tsx: -------------------------------------------------------------------------------- 1 | import { flushSync } from 'react-dom' 2 | import { createRoot } from 'react-dom/client' 3 | 4 | const INITIALIZATION = Symbol('phase.initialization') 5 | const UPDATE = Symbol('phase.update') 6 | type Phase = typeof INITIALIZATION | typeof UPDATE 7 | let phase: Phase 8 | let hookIndex = 0 9 | const states: Array<[any, (newState: any) => void]> = [] 10 | type EffectCallback = () => void 11 | const effects: Array<{ 12 | callback: EffectCallback 13 | // 🦺 add an optional deps and prevDeps properties which can be arrays of anything 14 | }> = [] 15 | 16 | export function useState(initialState: State) { 17 | const id = hookIndex++ 18 | if (phase === INITIALIZATION) { 19 | states[id] = [ 20 | initialState, 21 | (newState: State) => { 22 | states[id][0] = newState 23 | render(UPDATE) 24 | }, 25 | ] 26 | } 27 | return states[id] as [State, (newState: State) => void] 28 | } 29 | 30 | // 🐨 add an optional deps argument here 31 | export function useEffect(callback: EffectCallback) { 32 | const id = hookIndex++ 33 | // 🐨 add deps and prevDeps to this object - prevDeps should be "effects[id]?.deps" 34 | effects[id] = { callback } 35 | } 36 | 37 | function Counter() { 38 | const [count, setCount] = useState(0) 39 | const increment = () => setCount(count + 1) 40 | 41 | const [enabled, setEnabled] = useState(true) 42 | const toggle = () => setEnabled(!enabled) 43 | 44 | useEffect(() => { 45 | if (enabled) { 46 | console.info('consider yourself effective!') 47 | } else { 48 | console.info('consider yourself ineffective!') 49 | } 50 | // @ts-expect-error 💣 delete this comment 51 | }, [enabled]) 52 | 53 | return ( 54 |
55 | 56 | 59 |
60 | ) 61 | } 62 | 63 | const rootEl = document.createElement('div') 64 | document.body.append(rootEl) 65 | const appRoot = createRoot(rootEl) 66 | 67 | function render(newPhase: Phase) { 68 | hookIndex = 0 69 | phase = newPhase 70 | flushSync(() => { 71 | appRoot.render() 72 | }) 73 | 74 | for (const effect of effects) { 75 | if (!effect) continue 76 | 77 | // 🐨 Create a "hasDepsChanged" variable to determine whether the effect should be called. 78 | // If the effect has no deps, "hasDepsChanged" should be true. 79 | // If the effect does have deps, "hasDepsChanged" should calculate whether any item 80 | // in the "deps" array is different from the corresponding item in the "prevDeps" array, 81 | // and return true if so, false otherwise. 82 | 83 | effect.callback() 84 | } 85 | } 86 | 87 | render(INITIALIZATION) 88 | -------------------------------------------------------------------------------- /exercises/03.use-effect/02.solution.dependencies/README.mdx: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | 3 | 4 | 5 | 👨‍💼 Great work! Now the `useEffect` dependencies work. Well done 👏 6 | -------------------------------------------------------------------------------- /exercises/03.use-effect/02.solution.dependencies/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | import { screen, waitFor } from '@testing-library/dom' 3 | import { userEvent } from '@testing-library/user-event' 4 | 5 | const originalConsoleInfo = console.info 6 | 7 | function info(...args: Array) { 8 | info.calls.push(args) 9 | return originalConsoleInfo(...args) 10 | } 11 | info.calls = [] as Array> 12 | 13 | console.info = info 14 | 15 | await import('.') 16 | 17 | await testStep( 18 | ({ type }) => 19 | type === 'pass' 20 | ? 'The effect callback was called on the initial render' 21 | : 'The effect callback was not called on the initial render. Did you call it after rendering and using flushSync?', 22 | () => { 23 | expect(info.calls.length).to.equal(1) 24 | }, 25 | ) 26 | 27 | const counterButton = await testStep( 28 | ({ type }) => 29 | type === 'fail' 30 | ? 'Could not find the counter button. It should start at 0. Did you forget to return the initial state from your useState?' 31 | : 'Found the counter button that starts at 0', 32 | () => screen.findByRole('button', { name: /0/i }), 33 | ) 34 | await userEvent.click(counterButton) 35 | await testStep(`The button text should be 1 after clicking`, () => 36 | waitFor(() => expect(counterButton).to.have.text('1')), 37 | ) 38 | 39 | await testStep( 40 | ({ type }) => 41 | type === 'pass' 42 | ? 'The effect callback was not called when dependencies are unchanged' 43 | : 'The effect callback was called when dependencies are unchanged. Did you remember to not call it when the dependencies are unchanged?', 44 | () => { 45 | expect(info.calls.length).to.equal(1) 46 | }, 47 | ) 48 | 49 | const disableButton = await testStep( 50 | ({ type }) => 51 | type === 'fail' 52 | ? 'Could not find the disable button. It should start with the text "disable". Did you forget to return the initial state from your useState?' 53 | : 'Found the disable button that starts with "disable" text', 54 | () => screen.findByRole('button', { name: /Disable/i }), 55 | ) 56 | await userEvent.click(disableButton) 57 | 58 | await testStep( 59 | ({ type }) => 60 | type === 'pass' 61 | ? 'The effect callback was called when dependencies change' 62 | : 'The effect callback was not called when dependencies change. Did you remember to call it when the dependencies change?', 63 | () => { 64 | expect(info.calls.length).to.equal(2) 65 | }, 66 | ) 67 | -------------------------------------------------------------------------------- /exercises/03.use-effect/02.solution.dependencies/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100vh; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | div:has(.counter) { 12 | height: 100%; 13 | } 14 | 15 | .counter { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | gap: 8px; 20 | min-width: 100%; 21 | padding: 20px; 22 | height: 100%; 23 | 24 | button { 25 | padding: 6px 12px; 26 | font-size: 20px; 27 | font-weight: 500; 28 | background-color: lch(30% 100 220); 29 | color: white; 30 | border: 4px solid lch(10% 100 200); 31 | border-radius: 3px; 32 | cursor: pointer; 33 | 34 | /* matches the enable/disable button when that one shows up in the exercises */ 35 | &:first-of-type { 36 | flex: 1; 37 | max-width: 100px; 38 | } 39 | 40 | /* disables first-of-type if we haven't gotten to the exercise with the enable/disable button yet */ 41 | &:last-of-type { 42 | flex: unset; 43 | max-width: unset; 44 | } 45 | 46 | &:hover, 47 | &:focus { 48 | background-color: lch(40% 100 220); 49 | border-color: lch(20% 100 200); 50 | } 51 | &:active { 52 | background-color: lch(50% 100 220); 53 | border-color: lch(30% 100 220); 54 | } 55 | &:disabled { 56 | background-color: lch(30% 20 220); 57 | border-color: lch(10% 20 200); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exercises/03.use-effect/02.solution.dependencies/index.tsx: -------------------------------------------------------------------------------- 1 | import { flushSync } from 'react-dom' 2 | import { createRoot } from 'react-dom/client' 3 | 4 | const INITIALIZATION = Symbol('phase.initialization') 5 | const UPDATE = Symbol('phase.update') 6 | type Phase = typeof INITIALIZATION | typeof UPDATE 7 | let phase: Phase 8 | let hookIndex = 0 9 | const states: Array<[any, (newState: any) => void]> = [] 10 | type EffectCallback = () => void 11 | const effects: Array<{ 12 | callback: EffectCallback 13 | deps?: Array 14 | prevDeps?: Array 15 | }> = [] 16 | 17 | export function useState(initialState: State) { 18 | const id = hookIndex++ 19 | if (phase === INITIALIZATION) { 20 | states[id] = [ 21 | initialState, 22 | (newState: State) => { 23 | states[id][0] = newState 24 | render(UPDATE) 25 | }, 26 | ] 27 | } 28 | return states[id] as [State, (newState: State) => void] 29 | } 30 | 31 | export function useEffect(callback: EffectCallback, deps?: Array) { 32 | const id = hookIndex++ 33 | effects[id] = { callback, deps, prevDeps: effects[id]?.deps } 34 | } 35 | 36 | function Counter() { 37 | const [count, setCount] = useState(0) 38 | const increment = () => setCount(count + 1) 39 | 40 | const [enabled, setEnabled] = useState(true) 41 | const toggle = () => setEnabled(!enabled) 42 | 43 | useEffect(() => { 44 | if (enabled) { 45 | console.info('consider yourself effective!') 46 | } else { 47 | console.info('consider yourself ineffective!') 48 | } 49 | }, [enabled]) 50 | 51 | return ( 52 |
53 | 54 | 57 |
58 | ) 59 | } 60 | 61 | const rootEl = document.createElement('div') 62 | document.body.append(rootEl) 63 | const appRoot = createRoot(rootEl) 64 | 65 | function render(newPhase: Phase) { 66 | hookIndex = 0 67 | phase = newPhase 68 | flushSync(() => { 69 | appRoot.render() 70 | }) 71 | 72 | for (const effect of effects) { 73 | if (!effect) continue 74 | 75 | const hasDepsChanged = effect.deps 76 | ? !effect.deps.every((dep, i) => dep === effect.prevDeps?.[i]) 77 | : true 78 | 79 | if (hasDepsChanged) effect.callback() 80 | } 81 | } 82 | 83 | render(INITIALIZATION) 84 | -------------------------------------------------------------------------------- /exercises/03.use-effect/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # useEffect 2 | 3 | 4 | 5 | 👨‍💼 That's as far as we're going to take `useEffect`. Unfortunately there's just 6 | not a good way to handle the cleanup function since we don't have a way to track 7 | when a component gets added and removed from the page because the API React 8 | offers us for that is `useEffect` 😅 9 | 10 | But hopefully you learned something valuable here! Write down what you learned! 11 | -------------------------------------------------------------------------------- /exercises/03.use-effect/README.mdx: -------------------------------------------------------------------------------- 1 | # useEffect 2 | 3 | 4 | 5 | The `useEffect` hook has a simple API: 6 | 7 | ```tsx 8 | useEffect(callback, [dep1, dep2]) 9 | ``` 10 | 11 | The dependencies are optional, the callback can return a cleanup function. On 12 | re-renders that change the dependencies, the callback will be called again ( 13 | after first calling the previous cleanup if one was given). If no dependency 14 | array is provided, the callback will be called on every render. That's about it. 15 | 16 | We can follow the same pattern with storing the callback and dependencies as we 17 | did with `useState` before. And we can call the callbacks in the `render` 18 | function we have. Should be pretty simple! 19 | 20 | However, because we're not React, we don't actually know when the component has 21 | finished rendering. So we're going to use React's 22 | [`flushSync`](https://react.dev/reference/react-dom/flushSync) API to force the 23 | render to happen synchronously so that we can get the callbacks to run. 24 | 25 | So let's get into it! 26 | -------------------------------------------------------------------------------- /exercises/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Build React Hooks 🪝 2 | 3 | 4 | 5 | Hooray! You're all done! 👏👏 6 | 7 | Of course there's much more we can do for our implementations to make them more 8 | like the actual built in version of these hooks, but I think we've gone far 9 | enough for you to understand how hooks work behind the scenes a bit. 10 | 11 | If you want to explore the actual implementation of hooks in the source code, 12 | [start here](https://github.com/facebook/react/blob/e02baf6c92833a0d45a77fb2e741676f393c24f7/packages/react-reconciler/src/ReactFiberHooks.js#L3837-L3964). 13 | It's pretty interesting (and certainly more complicated), but it's not entirely 14 | dissimilar to what we've implemented. Hopefully this helps you understand how 15 | React hooks work under the hood! 16 | -------------------------------------------------------------------------------- /exercises/README.mdx: -------------------------------------------------------------------------------- 1 | # Build React Hooks 🪝 2 | 3 | 4 | 5 | 👨‍💼 Hello, my name is Peter the Product Manager. I'm here to help you get 6 | oriented and to give you your assignments for the workshop! 7 | 8 | We're going to build our own implementation of React hooks so you get a deeper 9 | understanding of how they work behind the scenes. We'll be building `useState` 10 | and `useEffect`. 11 | 12 | 13 | This will of course not be as rigorous as the official implementation. 14 | 15 | 16 | You're gonna rock with this! Let's get going! 17 | 18 | 🦺 One note, we're going to be doing some fun hackery around here so we'll be 19 | abusing TypeScript a bit. Feel free to throw around `any` and 20 | `// @ts-expect-error` here and there. 21 | 22 | 🎵 Check out the workshop theme song! 🎶 23 | 24 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-react-hooks", 3 | "private": true, 4 | "epicshop": { 5 | "title": "Build React Hooks 🪝", 6 | "subtitle": "Understand how React hooks work by building them from scratch", 7 | "githubRoot": "https://github.com/epicweb-dev/build-react-hooks/blob/main", 8 | "product": { 9 | "host": "www.epicreact.dev", 10 | "slug": "build-react-hooks", 11 | "displayName": "EpicReact.dev", 12 | "displayNameShort": "Epic React", 13 | "logo": "/logo.svg" 14 | }, 15 | "instructor": { 16 | "name": "Kent C. Dodds", 17 | "avatar": "/images/instructor.png", 18 | "𝕏": "kentcdodds" 19 | } 20 | }, 21 | "type": "module", 22 | "scripts": { 23 | "postinstall": "cd ./epicshop && npm install", 24 | "start": "npx --prefix ./epicshop epicshop start", 25 | "dev": "npx --prefix ./epicshop epicshop start", 26 | "test": "npm run test --silent --prefix playground", 27 | "test:e2e": "npm run test:e2e --silent --prefix playground", 28 | "test:e2e:dev": "npm run test:e2e:dev --silent --prefix playground", 29 | "test:e2e:run": "npm run test:e2e:run --silent --prefix playground", 30 | "setup": "node ./epicshop/setup.js", 31 | "setup:custom": "node ./epicshop/setup-custom.js", 32 | "lint": "eslint .", 33 | "format": "prettier --write .", 34 | "typecheck": "tsc -b", 35 | "validate:all": "npm-run-all --parallel --print-label --print-name --continue-on-error test:all lint typecheck" 36 | }, 37 | "keywords": [], 38 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 39 | "license": "GPL-3.0-only", 40 | "workspaces": [ 41 | "exercises/*/*", 42 | "examples/*" 43 | ], 44 | "engines": { 45 | "node": "^18.19.0 || >=20.5.0", 46 | "npm": ">=8.16.0", 47 | "git": ">=2.18.0" 48 | }, 49 | "dependencies": { 50 | "@epic-web/workshop-utils": "^5.22.5", 51 | "@testing-library/dom": "^10.4.0", 52 | "@testing-library/user-event": "^14.5.2", 53 | "react": "19.0.0-beta-94eed63c49-20240425", 54 | "react-dom": "19.0.0-beta-94eed63c49-20240425" 55 | }, 56 | "devDependencies": { 57 | "@epic-web/config": "^1.11.2", 58 | "@types/chai": "^4.3.17", 59 | "@types/chai-dom": "^1.11.3", 60 | "@types/react": "npm:types-react@19.0.0-alpha.5", 61 | "@types/react-dom": "npm:types-react-dom@19.0.0-alpha.5", 62 | "eslint": "^9.5.0", 63 | "eslint-plugin-react-hooks": "5.1.0-beta-94eed63c49-20240425", 64 | "prettier": "^3.3.2", 65 | "typescript": "^5.5.2" 66 | }, 67 | "overrides": { 68 | "@types/react": "$@types/react", 69 | "@types/react-dom": "$@types/react-dom", 70 | "eslint-plugin-react-hooks": "$eslint-plugin-react-hooks" 71 | }, 72 | "prettier": "@epic-web/config/prettier" 73 | } 74 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/build-react-hooks/94b9e4e618e028d9ac1321c75693cc69267149cb/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/images/instructor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/build-react-hooks/94b9e4e618e028d9ac1321c75693cc69267149cb/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/build-react-hooks/94b9e4e618e028d9ac1321c75693cc69267149cb/public/og/background.png -------------------------------------------------------------------------------- /public/og/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "extends": ["@epic-web/config/typescript"], 4 | "compilerOptions": { 5 | // keep things easy for the exercises 6 | "noUncheckedIndexedAccess": false, 7 | "paths": { 8 | "#*": ["./*"] 9 | } 10 | } 11 | } 12 | --------------------------------------------------------------------------------