├── .github
└── workflows
│ └── validate.yml
├── .gitignore
├── .npmrc
├── .vscode
└── extensions.json
├── LICENSE.md
├── README.md
├── epicshop
├── .diffignore
├── .npmrc
├── Dockerfile
├── fix-watch.js
├── fix.js
├── fly.toml
├── in-browser-tests.spec.js
├── package-lock.json
├── package.json
├── playwright.config.js
├── post-set-playground.js
├── setup-custom.js
├── setup.js
├── tsconfig.json
└── update-deps.sh
├── eslint.config.js
├── exercises
├── 01.managing-ui-state
│ ├── 01.problem.use-state
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 01.solution.use-state
│ │ ├── README.mdx
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 02.problem.control
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 02.solution.control
│ │ ├── README.mdx
│ │ ├── controlled-search.test.ts
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 03.problem.derive
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 03.solution.derive
│ │ ├── README.mdx
│ │ ├── controlled-checkbox.test.ts
│ │ ├── controlled-search.test.ts
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 04.problem.init
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 04.solution.init
│ │ ├── README.mdx
│ │ ├── controlled-checkbox.test.ts
│ │ ├── controlled-search.test.ts
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ └── search-params.test.ts
│ ├── 05.problem.cb
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 05.solution.cb
│ │ ├── README.mdx
│ │ ├── controlled-checkbox.test.ts
│ │ ├── controlled-search.test.ts
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ └── search-params.test.ts
│ ├── FINISHED.mdx
│ └── README.mdx
├── 02.side-effects
│ ├── 01.problem.effects
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 01.solution.effects
│ │ ├── README.mdx
│ │ ├── controlled-checkbox.test.ts
│ │ ├── controlled-search.test.ts
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── popstate.test.ts
│ │ └── search-params.test.ts
│ ├── 02.problem.cleanup
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 02.solution.cleanup
│ │ ├── README.mdx
│ │ ├── controlled-checkbox.test.ts
│ │ ├── controlled-search.test.ts
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── memory-leak.test.ts
│ │ ├── popstate.test.ts
│ │ └── search-params.test.ts
│ ├── FINISHED.mdx
│ └── README.mdx
├── 03.lifting-state
│ ├── 01.problem.lift
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 01.solution.lift
│ │ ├── README.mdx
│ │ ├── controlled-checkbox.test.ts
│ │ ├── controlled-search.test.ts
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── like-post.test.ts
│ │ ├── popstate.test.ts
│ │ └── search-params.test.ts
│ ├── 02.problem.lift-array
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 02.solution.lift-array
│ │ ├── README.mdx
│ │ ├── controlled-checkbox.test.ts
│ │ ├── controlled-search.test.ts
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── like-post.test.ts
│ │ ├── popstate.test.ts
│ │ └── search-params.test.ts
│ ├── 03.problem.colocate
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 03.solution.colocate
│ │ ├── README.mdx
│ │ ├── controlled-checkbox.test.ts
│ │ ├── controlled-search.test.ts
│ │ ├── filtering.test.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── like-post.test.ts
│ │ ├── popstate.test.ts
│ │ └── search-params.test.ts
│ ├── FINISHED.mdx
│ └── README.mdx
├── 04.dom
│ ├── 01.problem.ref
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 01.solution.ref
│ │ ├── README.mdx
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── tilt.test.ts
│ │ └── toggle.test.ts
│ ├── 02.problem.deps
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 02.solution.deps
│ │ ├── README.mdx
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── tilt.test.ts
│ │ └── toggle.test.ts
│ ├── 03.problem.primitives
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 03.solution.primitives
│ │ ├── README.mdx
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── tilt.test.ts
│ │ └── toggle.test.ts
│ ├── FINISHED.mdx
│ └── README.mdx
├── 05.unique-ids
│ ├── 01.problem.use-id
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 01.solution.use-id
│ │ ├── README.mdx
│ │ ├── index.css
│ │ ├── index.tsx
│ │ └── tilt.test.ts
│ ├── FINISHED.mdx
│ └── README.mdx
├── 06.tic-tac-toe
│ ├── 01.problem.set-state-callback
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 01.solution.set-state-callback
│ │ ├── README.mdx
│ │ ├── board-game.test.ts
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 02.problem.local-storage
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 02.solution.local-storage
│ │ ├── README.mdx
│ │ ├── index.css
│ │ ├── index.tsx
│ │ └── local-storage.test.ts
│ ├── 03.problem.history
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 03.solution.history
│ │ ├── README.mdx
│ │ ├── index.css
│ │ ├── index.tsx
│ │ └── local-storage.test.ts
│ ├── FINISHED.mdx
│ └── README.mdx
├── FINISHED.mdx
└── README.mdx
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── favicon.svg
├── hook-flow.png
├── images
│ └── instructor.png
├── logo.svg
├── og
│ ├── background.png
│ └── logo.svg
└── react-app-lifecycle.png
├── shared
├── blog-posts.tsx
├── tic-tac-toe-utils.tsx
└── utils.tsx
└── tsconfig.json
/.github/workflows/validate.yml:
--------------------------------------------------------------------------------
1 | name: deploy
2 |
3 | concurrency:
4 | group: ${{ github.workflow }}-${{ github.ref }}
5 | cancel-in-progress: true
6 |
7 | on:
8 | push:
9 | branches:
10 | - 'main'
11 | pull_request:
12 | branches:
13 | - 'main'
14 | jobs:
15 | setup:
16 | strategy:
17 | matrix:
18 | os: [ubuntu-latest, windows-latest, macos-latest]
19 | runs-on: ${{ matrix.os }}
20 | steps:
21 | - name: ⬇️ Checkout repo
22 | uses: actions/checkout@v4
23 |
24 | - name: ⎔ Setup node
25 | uses: actions/setup-node@v4
26 | with:
27 | node-version: 20
28 |
29 | - name: ▶️ Run setup script
30 | run: npm run setup
31 |
32 | - name: ʦ TypeScript
33 | run: npm run typecheck
34 |
35 | - name: ⬣ ESLint
36 | run: npm run lint
37 |
38 | # TODO: get this working again
39 | # - name: ⬇️ Install Playwright
40 | # run: npm --prefix epicshop run test:setup
41 |
42 | # - name: 🧪 In-browser tests
43 | # run: npm --prefix epicshop test
44 |
45 | deploy:
46 | name: 🚀 Deploy
47 | runs-on: ubuntu-latest
48 | # only deploy main branch on pushes
49 | if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
50 |
51 | steps:
52 | - name: ⬇️ Checkout repo
53 | uses: actions/checkout@v4
54 |
55 | - name: 🎈 Setup Fly
56 | uses: superfly/flyctl-actions/setup-flyctl@1.5
57 |
58 | - name: 🚀 Deploy
59 | run: flyctl deploy --remote-only
60 | working-directory: ./epicshop
61 | env:
62 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
63 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | workspace/
4 | **/.cache/
5 | **/build/
6 | **/public/build
7 | **/playwright-report
8 | data.db
9 | /playground
10 | **/tsconfig.tsbuildinfo
11 |
12 | # in a real app you'd want to not commit the .env
13 | # file as well, but since this is for a workshop
14 | # we're going to keep them around.
15 | # .env
16 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 | registry=https://registry.npmjs.org/
3 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode",
5 | "neotan.vscode-auto-restart-typescript-eslint-servers"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | This material is available for private, non-commercial use under the
2 | [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html). If you
3 | would like to use this material to conduct your own workshop, please contact us
4 | at team@epicweb.dev
5 |
--------------------------------------------------------------------------------
/epicshop/.diffignore:
--------------------------------------------------------------------------------
1 | tsconfig.json
2 | *.test.*
3 |
--------------------------------------------------------------------------------
/epicshop/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 | registry=https://registry.npmjs.org/
3 |
--------------------------------------------------------------------------------
/epicshop/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-bookworm-slim as base
2 |
3 | RUN apt-get update && apt-get install -y git
4 |
5 | ENV EPICSHOP_CONTEXT_CWD="/myapp/workshop-content"
6 | ENV EPICSHOP_DEPLOYED="true"
7 | ENV EPICSHOP_DISABLE_WATCHER="true"
8 | ENV FLY="true"
9 | ENV PORT="8080"
10 | ENV NODE_ENV="production"
11 |
12 | WORKDIR /myapp
13 |
14 | ADD . .
15 |
16 | RUN npm install --omit=dev
17 |
18 | CMD rm -rf ${EPICSHOP_CONTEXT_CWD} && \
19 | git clone https://github.com/epicweb-dev/react-hooks ${EPICSHOP_CONTEXT_CWD} && \
20 | cd ${EPICSHOP_CONTEXT_CWD} && \
21 | npx epicshop start
22 |
--------------------------------------------------------------------------------
/epicshop/fix-watch.js:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import { fileURLToPath } from 'node:url'
3 | import chokidar from 'chokidar'
4 | import { $ } from 'execa'
5 |
6 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
7 | const here = (...p) => path.join(__dirname, ...p)
8 |
9 | const workshopRoot = here('..')
10 |
11 | const watchPath = path.join(workshopRoot, './exercises/*')
12 | const watcher = chokidar.watch(watchPath, {
13 | ignored: /(^|[/\\])\../, // ignore dotfiles
14 | persistent: true,
15 | ignoreInitial: true,
16 | depth: 2,
17 | })
18 |
19 | const debouncedRun = debounce(run, 200)
20 |
21 | // Add event listeners.
22 | watcher
23 | .on('addDir', path => {
24 | debouncedRun()
25 | })
26 | .on('unlinkDir', path => {
27 | // Only act if path contains two slashes (excluding the leading `./`)
28 | debouncedRun()
29 | })
30 | .on('error', error => console.log(`Watcher error: ${error}`))
31 |
32 | /**
33 | * Simple debounce implementation
34 | */
35 | function debounce(fn, delay) {
36 | let timer = null
37 | return (...args) => {
38 | if (timer) clearTimeout(timer)
39 | timer = setTimeout(() => {
40 | fn(...args)
41 | }, delay)
42 | }
43 | }
44 |
45 | let running = false
46 |
47 | async function run() {
48 | if (running) {
49 | console.log('still running...')
50 | return
51 | }
52 | running = true
53 | try {
54 | await $({
55 | stdio: 'inherit',
56 | cwd: workshopRoot,
57 | })`node ./scripts/fix.js`
58 | } catch (error) {
59 | throw error
60 | } finally {
61 | running = false
62 | }
63 | }
64 |
65 | console.log(`watching ${watchPath}`)
66 |
67 | // doing this because the watcher doesn't seem to work and I don't have time
68 | // to figure out why 🙃
69 | console.log('Polling...')
70 | setInterval(() => {
71 | run()
72 | }, 1000)
73 |
74 | console.log('running fix to start...')
75 | run()
76 |
--------------------------------------------------------------------------------
/epicshop/fly.toml:
--------------------------------------------------------------------------------
1 | app = "epicweb-dev-react-hooks"
2 | primary_region = "sjc"
3 | kill_signal = "SIGINT"
4 | kill_timeout = 5
5 | processes = [ ]
6 | swap_size_mb = 512
7 |
8 | [experimental]
9 | allowed_public_ports = [ ]
10 | auto_rollback = true
11 |
12 | [[services]]
13 | internal_port = 8080
14 | processes = [ "app" ]
15 | protocol = "tcp"
16 | script_checks = [ ]
17 |
18 | [services.concurrency]
19 | hard_limit = 100
20 | soft_limit = 80
21 | type = "connections"
22 |
23 | [[services.ports]]
24 | handlers = [ "http" ]
25 | port = 80
26 | force_https = true
27 |
28 | [[services.ports]]
29 | handlers = [ "tls", "http" ]
30 | port = 443
31 |
32 | [[services.tcp_checks]]
33 | grace_period = "1s"
34 | interval = "15s"
35 | restart_limit = 0
36 | timeout = "2s"
37 |
38 | [[services.http_checks]]
39 | interval = "10s"
40 | grace_period = "5s"
41 | method = "get"
42 | path = "/"
43 | protocol = "http"
44 | timeout = "2s"
45 | tls_skip_verify = false
46 | headers = { }
47 |
--------------------------------------------------------------------------------
/epicshop/in-browser-tests.spec.js:
--------------------------------------------------------------------------------
1 | import { dirname, resolve } from 'path'
2 | import { fileURLToPath } from 'url'
3 | import { setupInBrowserTests } from '@epic-web/workshop-utils/playwright.server'
4 |
5 | const __dirname = dirname(fileURLToPath(import.meta.url))
6 | process.env.EPICSHOP_CONTEXT_CWD = resolve(__dirname, '..')
7 |
8 | setupInBrowserTests()
9 |
--------------------------------------------------------------------------------
/epicshop/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "scripts": {
4 | "test:setup": "playwright install chromium --with-deps",
5 | "test": "playwright test"
6 | },
7 | "dependencies": {
8 | "@epic-web/workshop-app": "^5.20.1",
9 | "@epic-web/workshop-utils": "^5.20.1",
10 | "execa": "^9.5.1"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/epicshop/playwright.config.js:
--------------------------------------------------------------------------------
1 | import os from 'os'
2 | import path from 'path'
3 | import { defineConfig, devices } from '@playwright/test'
4 |
5 | const PORT = process.env.PORT || '5639'
6 | const tmpDir = path.join(
7 | os.tmpdir(),
8 | 'epicshop-playwright',
9 | path.basename(new URL('../', import.meta.url).pathname),
10 | )
11 |
12 | export default defineConfig({
13 | outputDir: path.join(tmpDir, 'playwright-test-output'),
14 | reporter: [
15 | [
16 | 'html',
17 | { open: 'never', outputFolder: path.join(tmpDir, 'playwright-report') },
18 | ],
19 | ],
20 | use: {
21 | baseURL: `http://localhost:${PORT}/`,
22 | trace: 'retain-on-failure',
23 | },
24 |
25 | projects: [
26 | {
27 | name: 'chromium',
28 | use: { ...devices['Desktop Chrome'] },
29 | },
30 | ],
31 |
32 | webServer: {
33 | command: 'cd .. && npm start',
34 | port: Number(PORT),
35 | reuseExistingServer: !process.env.CI,
36 | stdout: 'pipe',
37 | stderr: 'pipe',
38 | env: { PORT },
39 | },
40 | })
41 |
--------------------------------------------------------------------------------
/epicshop/post-set-playground.js:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs'
2 | import path from 'node:path'
3 |
4 | fs.writeFileSync(
5 | path.join(process.env.EPICSHOP_PLAYGROUND_DEST_DIR, 'tsconfig.json'),
6 | JSON.stringify({ extends: '../tsconfig' }, null, 2),
7 | )
8 |
--------------------------------------------------------------------------------
/epicshop/setup-custom.js:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import {
3 | getApps,
4 | isProblemApp,
5 | setPlayground,
6 | } from '@epic-web/workshop-utils/apps.server'
7 | import fsExtra from 'fs-extra'
8 |
9 | const allApps = await getApps()
10 | const problemApps = allApps.filter(isProblemApp)
11 |
12 | if (!process.env.SKIP_PLAYGROUND) {
13 | const firstProblemApp = problemApps[0]
14 | if (firstProblemApp) {
15 | console.log('🛝 setting up the first problem app...')
16 | const playgroundPath = path.join(process.cwd(), 'playground')
17 | if (await fsExtra.exists(playgroundPath)) {
18 | console.log('🗑 deleting existing playground app')
19 | await fsExtra.remove(playgroundPath)
20 | }
21 | await setPlayground(firstProblemApp.fullPath).then(
22 | () => {
23 | console.log('✅ first problem app set up')
24 | },
25 | error => {
26 | console.error(error)
27 | throw new Error('❌ first problem app setup failed')
28 | },
29 | )
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/epicshop/setup.js:
--------------------------------------------------------------------------------
1 | import { spawnSync } from 'child_process'
2 |
3 | const styles = {
4 | // got these from playing around with what I found from:
5 | // https://github.com/istanbuljs/istanbuljs/blob/0f328fd0896417ccb2085f4b7888dd8e167ba3fa/packages/istanbul-lib-report/lib/file-writer.js#L84-L96
6 | // they're the best I could find that works well for light or dark terminals
7 | success: { open: '\u001b[32;1m', close: '\u001b[0m' },
8 | danger: { open: '\u001b[31;1m', close: '\u001b[0m' },
9 | info: { open: '\u001b[36;1m', close: '\u001b[0m' },
10 | subtitle: { open: '\u001b[2;1m', close: '\u001b[0m' },
11 | }
12 |
13 | function color(modifier, string) {
14 | return styles[modifier].open + string + styles[modifier].close
15 | }
16 |
17 | console.log(color('info', '▶️ Starting workshop setup...'))
18 |
19 | const output = spawnSync('npm --version', { shell: true })
20 | .stdout.toString()
21 | .trim()
22 | const outputParts = output.split('.')
23 | const major = Number(outputParts[0])
24 | const minor = Number(outputParts[1])
25 | if (major < 8 || (major === 8 && minor < 16)) {
26 | console.error(
27 | color(
28 | 'danger',
29 | '🚨 npm version is ' +
30 | output +
31 | ' which is out of date. Please install npm@8.16.0 or greater',
32 | ),
33 | )
34 | throw new Error('npm version is out of date')
35 | }
36 |
37 | const command =
38 | 'npx --yes "https://gist.github.com/kentcdodds/bb452ffe53a5caa3600197e1d8005733" -q'
39 | console.log(
40 | color('subtitle', ' Running the following command: ' + command),
41 | )
42 |
43 | const result = spawnSync(command, { stdio: 'inherit', shell: true })
44 |
45 | if (result.status === 0) {
46 | console.log(color('success', '✅ Workshop setup complete...'))
47 | } else {
48 | process.exit(result.status)
49 | }
50 |
51 | /*
52 | eslint
53 | "no-undef": "off",
54 | "vars-on-top": "off",
55 | */
56 |
--------------------------------------------------------------------------------
/epicshop/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.tsx", "**/*.js"],
3 | "extends": ["@epic-web/config/typescript"],
4 | "compilerOptions": {
5 | "paths": {
6 | "#*": ["./*"]
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/epicshop/update-deps.sh:
--------------------------------------------------------------------------------
1 | set -e
2 |
3 | npx npm-check-updates --dep prod,dev --upgrade --root
4 | cd epicshop && npx npm-check-updates --dep prod,dev --upgrade --root
5 | cd ..
6 | rm -rf node_modules package-lock.json ./epicshop/package-lock.json ./epicshop/node_modules ./exercises/**/node_modules
7 | npm install
8 | npm run setup
9 | npm run typecheck
10 | npm run lint --fix
11 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import defaultConfig from '@epic-web/config/eslint'
2 |
3 | /** @type {import("eslint").Linter.Config} */
4 | export default [
5 | ...defaultConfig,
6 | {
7 | rules: {
8 | // we leave unused vars around for the exercises
9 | 'no-unused-vars': 'off',
10 | '@typescript-eslint/no-unused-vars': 'off',
11 | },
12 | },
13 | ]
14 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/01.problem.use-state/README.mdx:
--------------------------------------------------------------------------------
1 | # useState
2 |
3 |
4 |
5 | 👨💼 We've got you working on a blog search page. Allow me to introduce you to
6 | 🧝♂️ Kellie the coworker who put this together.
7 |
8 | 🧝♂️ Hello! I'm Kellie the coworker and I do things for you sometimes. I've
9 | started up this app that you're going to be working with. There's a search
10 | input, a couple checkboxes, a submit button, and a list of blog posts. You've
11 | been asked to make it so when the user types into the search field it filters
12 | the list of blog posts. And when the checkboxes are checked, we want to update
13 | the filter for the user to include those values. You might find it helpful to
14 | review the code before you get started.
15 |
16 | 👨💼 Thanks Kellie! Ok, so we're going to take this step-by-step. For this first
17 | step, we just want you to create a state variable for the user's query and pass
18 | that along to the ` `.
19 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/01.problem.use-state/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/01.problem.use-state/index.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client'
2 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts'
3 |
4 | function App() {
5 | // 🐨 call useState here and initialize the query with an empty string
6 |
7 | return (
8 |
9 |
32 | {/* 🐨 pass the query state as a prop */}
33 |
34 |
35 | )
36 | }
37 |
38 | function MatchingPosts({ query }: { query: string }) {
39 | const matchingPosts = getMatchingPosts(query)
40 |
41 | return (
42 |
62 | )
63 | }
64 |
65 | const rootEl = document.createElement('div')
66 | document.body.append(rootEl)
67 | createRoot(rootEl).render( )
68 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/01.solution.use-state/README.mdx:
--------------------------------------------------------------------------------
1 | # useState
2 |
3 |
4 |
5 | 👨💼 Super! You've successfully filtered the blog posts based on what the user has
6 | typed. This is where we start getting into why React is so great. It makes doing
7 | stateful dynamic things like this super easy compared to the alternative of
8 | working with the DOM directly. Let's keep going.
9 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/01.solution.use-state/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/01.solution.use-state/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/01.solution.use-state/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts'
4 |
5 | function App() {
6 | const [query, setQuery] = useState('')
7 |
8 | return (
9 |
35 | )
36 | }
37 |
38 | function MatchingPosts({ query }: { query: string }) {
39 | const matchingPosts = getMatchingPosts(query)
40 |
41 | return (
42 |
62 | )
63 | }
64 |
65 | const rootEl = document.createElement('div')
66 | document.body.append(rootEl)
67 | createRoot(rootEl).render( )
68 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/02.problem.control/README.mdx:
--------------------------------------------------------------------------------
1 | # Controlling Inputs
2 |
3 |
4 |
5 | 👨💼 When the user clicks on one of the checkboxes, we want to auto-fill that
6 | value into the search input for them. This means we need to "control" the input
7 | value so we can set it programmatically. We can do this by using the `value`
8 | prop on the input.
9 |
10 | 📜 You can read up more on this in the React docs:
11 | [Controlling an input with a state variable](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable)
12 |
13 | Once we add the query as the value for the input, we'll also want to have a
14 | function responsible for updating the query when the user checks or unchecks the
15 | checkboxes and call that in the `onChange` handler of the checkboxes.
16 |
17 | Good luck!
18 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/02.problem.control/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/02.problem.control/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts'
4 |
5 | function App() {
6 | const [query, setQuery] = useState('')
7 |
8 | // 🐨 make a function called handleCheck that accepts a "tag" string and a "checked" boolean
9 | // 🐨 By calling setQuery, add the tag to the query if checked and remove it if not
10 |
11 | return (
12 |
51 | )
52 | }
53 |
54 | function MatchingPosts({ query }: { query: string }) {
55 | const matchingPosts = getMatchingPosts(query)
56 |
57 | return (
58 |
78 | )
79 | }
80 |
81 | const rootEl = document.createElement('div')
82 | document.body.append(rootEl)
83 | createRoot(rootEl).render( )
84 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/02.solution.control/README.mdx:
--------------------------------------------------------------------------------
1 | # Controlling Inputs
2 |
3 |
4 |
5 | 👨💼 Great! Now you can add/remove each of these tags to the search when they're
6 | checked. Programmatically controlling the input value is why you use the `value`
7 | prop.
8 |
9 | But the feature is incomplete because a user could manually type the tag into
10 | the search input and it would not be reflected in the checkbox. Let's look at
11 | that next.
12 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/02.solution.control/controlled-search.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can select the dog checkbox', async () => {
25 | fireEvent.click(dogCheckbox)
26 | expect(dogCheckbox).toBeChecked()
27 | })
28 |
29 | await testStep(
30 | 'Selecting the checkbox updates the search and results',
31 | async () => {
32 | // Check that the search box value has been updated
33 | expect(searchBox).toHaveValue('dog')
34 |
35 | // Check that the results have been filtered
36 | await dtl.waitFor(async () => {
37 | await screen.findByText(/the joy of owning a dog/i)
38 |
39 | const catResult = screen.queryByText(/caring for your feline friend/i)
40 | expect(catResult).not.toBeInTheDocument()
41 | })
42 | },
43 | )
44 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/02.solution.control/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/02.solution.control/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/02.solution.control/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts'
4 |
5 | function App() {
6 | const [query, setQuery] = useState('')
7 |
8 | function handleCheck(tag: string, checked: boolean) {
9 | const words = query.split(' ')
10 | const newWords = checked ? [...words, tag] : words.filter(w => w !== tag)
11 | setQuery(newWords.filter(Boolean).join(' ').trim())
12 | }
13 |
14 | return (
15 |
56 | )
57 | }
58 |
59 | function MatchingPosts({ query }: { query: string }) {
60 | const matchingPosts = getMatchingPosts(query)
61 |
62 | return (
63 |
83 | )
84 | }
85 |
86 | const rootEl = document.createElement('div')
87 | document.body.append(rootEl)
88 | createRoot(rootEl).render( )
89 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/03.problem.derive/README.mdx:
--------------------------------------------------------------------------------
1 | # Derive State
2 |
3 |
4 |
5 | 🦉 Often, it can be easy to think you need to keep track of two elements of
6 | state when you really only need one. For example, let's say you have a counter
7 | that will display the number of times a user has clicked a button and also it
8 | will display whether that number is odd or even. You might be tempted to write
9 | the following code:
10 |
11 | ```tsx
12 | import { useState } from 'react'
13 |
14 | export default function Counter() {
15 | const [count, setCount] = useState(0)
16 | const [isEven, setIsEven] = useState(true)
17 |
18 | function handleClick() {
19 | const newCount = count + 1
20 | setCount(newCount)
21 | setIsEven(newCount % 2 === 0)
22 | }
23 |
24 | return (
25 |
26 |
{count}
27 |
{isEven ? 'Even' : 'Odd'}
28 |
Increment
29 |
30 | )
31 | }
32 | ```
33 |
34 | This code works, but it's not ideal because it's keeping track of two pieces of
35 | state when it only needs to keep track of one. Imagine if we had multiple places
36 | where the count could be changed. We'd have to remember to update the `isEven`
37 | state in all of those places. This is a recipe for bugs.
38 |
39 | Instead, we can derive the `isEven` state from the `count` state:
40 |
41 | ```tsx
42 | import { useState } from 'react'
43 |
44 | export default function Counter() {
45 | const [count, setCount] = useState(0)
46 |
47 | function handleClick() {
48 | const newCount = count + 1
49 | setCount(newCount)
50 | }
51 |
52 | // this is the derived state
53 | const isEven = count % 2 === 0
54 |
55 | return (
56 |
57 |
{count}
58 |
{isEven ? 'Even' : 'Odd'}
59 |
Increment
60 |
61 | )
62 | }
63 | ```
64 |
65 | This is a much better solution because we only have to keep track of the `count`
66 | state. The `isEven` state is derived from the `count` state. This means we don't
67 | have to worry about keeping the `isEven` state in sync with the `count` state.
68 |
69 | 👨💼 Thanks Olivia! So what we want to do in this step is derive the checkboxes'
70 | `checked` state based on whether the query contains the word they represent.
71 |
72 | Give that a shot.
73 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/03.problem.derive/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/03.problem.derive/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts'
4 |
5 | function App() {
6 | const [query, setQuery] = useState('')
7 | // 🐨 move the words variable from handleCheck to here
8 | // 🦉 this is deriving state!
9 |
10 | // 🐨 create a dogChecked variable that is whether words includes "dog"
11 | // and do the same for "cat" and "caterpillar"
12 | // 🦉 this is deriving state from derived state!
13 |
14 | function handleCheck(tag: string, checked: boolean) {
15 | // 🐨 move the words variable up to just below the useState call
16 | const words = query.split(' ')
17 | const newWords = checked ? [...words, tag] : words.filter(w => w !== tag)
18 | setQuery(newWords.filter(Boolean).join(' ').trim())
19 | }
20 |
21 | return (
22 |
66 | )
67 | }
68 |
69 | function MatchingPosts({ query }: { query: string }) {
70 | const matchingPosts = getMatchingPosts(query)
71 |
72 | return (
73 |
93 | )
94 | }
95 |
96 | const rootEl = document.createElement('div')
97 | document.body.append(rootEl)
98 | createRoot(rootEl).render( )
99 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/03.solution.derive/README.mdx:
--------------------------------------------------------------------------------
1 | # Derive State
2 |
3 |
4 |
5 | 👨💼 Awesome work! It looks really cool to have the checkboxes check and uncheck
6 | automatically as you type! We're now controlling both all the inputs on here
7 | all based on a single element of state. Well done.
8 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/03.solution.derive/controlled-checkbox.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can search for a checkbox value', async () => {
25 | fireEvent.change(searchBox, { target: { value: 'dog' } })
26 | })
27 |
28 | await testStep('checkbox is checked automatically', async () => {
29 | expect(dogCheckbox).toBeChecked()
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/03.solution.derive/controlled-search.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can select the dog checkbox', async () => {
25 | fireEvent.click(dogCheckbox)
26 | expect(dogCheckbox).toBeChecked()
27 | })
28 |
29 | await testStep(
30 | 'Selecting the checkbox updates the search and results',
31 | async () => {
32 | // Check that the search box value has been updated
33 | expect(searchBox).toHaveValue('dog')
34 |
35 | // Check that the results have been filtered
36 | await dtl.waitFor(async () => {
37 | await screen.findByText(/the joy of owning a dog/i)
38 |
39 | const catResult = screen.queryByText(/caring for your feline friend/i)
40 | expect(catResult).not.toBeInTheDocument()
41 | })
42 | },
43 | )
44 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/03.solution.derive/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/03.solution.derive/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/03.solution.derive/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts'
4 |
5 | function App() {
6 | const [query, setQuery] = useState('')
7 | const words = query.split(' ')
8 |
9 | const dogChecked = words.includes('dog')
10 | const catChecked = words.includes('cat')
11 | const caterpillarChecked = words.includes('caterpillar')
12 |
13 | function handleCheck(tag: string, checked: boolean) {
14 | const newWords = checked ? [...words, tag] : words.filter(w => w !== tag)
15 | setQuery(newWords.filter(Boolean).join(' ').trim())
16 | }
17 |
18 | return (
19 |
63 | )
64 | }
65 |
66 | function MatchingPosts({ query }: { query: string }) {
67 | const matchingPosts = getMatchingPosts(query)
68 |
69 | return (
70 |
90 | )
91 | }
92 |
93 | const rootEl = document.createElement('div')
94 | document.body.append(rootEl)
95 | createRoot(rootEl).render( )
96 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/04.problem.init/README.mdx:
--------------------------------------------------------------------------------
1 | # Initialize State
2 |
3 |
4 |
5 | 👨💼 We want users to be able to share a link to this page with a prefilled
6 | search. For example:
7 | [`https://www.example.com/search?query=cat+dog`](/app/playground?query=cat+dog).
8 |
9 | Right now, this won't work, because we don't have a way to initialize the
10 | state of the search box from the URL. Let's fix that.
11 |
12 | The `useState` hook supports an initial value. Right now we're just passing
13 | `''` as the initial value, but we can use the `URLSearchParams` API to get the
14 | query string from the URL and use that as the initial value.
15 |
16 | ```tsx
17 | const params = new URLSearchParams(window.location.search)
18 | const initialQuery = params.get('query') ?? ''
19 | ```
20 |
21 | Then you can pass that `initialQuery` to `useState`. Give that a shot and then
22 | the link above should work!
23 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/04.problem.init/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/04.solution.init/README.mdx:
--------------------------------------------------------------------------------
1 | # Initialize State
2 |
3 |
4 |
5 | 👨💼 Great! Now our users can share links to this page that initialize the search
6 | to something specific.
7 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/04.solution.init/controlled-checkbox.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can search for a checkbox value', async () => {
25 | fireEvent.change(searchBox, { target: { value: 'dog' } })
26 | })
27 |
28 | await testStep('checkbox is checked automatically', async () => {
29 | expect(dogCheckbox).toBeChecked()
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/04.solution.init/controlled-search.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can select the dog checkbox', async () => {
25 | fireEvent.click(dogCheckbox)
26 | expect(dogCheckbox).toBeChecked()
27 | })
28 |
29 | await testStep(
30 | 'Selecting the checkbox updates the search and results',
31 | async () => {
32 | // Check that the search box value has been updated
33 | expect(searchBox).toHaveValue('dog')
34 |
35 | // Check that the results have been filtered
36 | await dtl.waitFor(async () => {
37 | await screen.findByText(/the joy of owning a dog/i)
38 |
39 | const catResult = screen.queryByText(/caring for your feline friend/i)
40 | expect(catResult).not.toBeInTheDocument()
41 | })
42 | },
43 | )
44 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/04.solution.init/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/04.solution.init/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/04.solution.init/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts'
4 |
5 | function App() {
6 | // NOTE: this will not work with server rendering, but in a real app you can
7 | // use react-router's useSearchParams instead
8 | const params = new URLSearchParams(window.location.search)
9 | const [query, setQuery] = useState(params.get('query') ?? '')
10 | const words = query.split(' ')
11 |
12 | const dogChecked = words.includes('dog')
13 | const catChecked = words.includes('cat')
14 | const caterpillarChecked = words.includes('caterpillar')
15 |
16 | function handleCheck(tag: string, checked: boolean) {
17 | const newWords = checked ? [...words, tag] : words.filter(w => w !== tag)
18 | setQuery(newWords.filter(Boolean).join(' ').trim())
19 | }
20 |
21 | return (
22 |
66 | )
67 | }
68 |
69 | function MatchingPosts({ query }: { query: string }) {
70 | const matchingPosts = getMatchingPosts(query)
71 |
72 | return (
73 |
93 | )
94 | }
95 |
96 | const rootEl = document.createElement('div')
97 | document.body.append(rootEl)
98 | createRoot(rootEl).render( )
99 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/04.solution.init/search-params.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | window.history.pushState({}, '', '?query=dog')
5 |
6 | await import('./index.tsx')
7 |
8 | await testStep(
9 | 'The search box is initialized with URL query parameter',
10 | async () => {
11 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
12 | expect(searchBox).toHaveValue('dog')
13 | },
14 | )
15 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/05.problem.cb/README.mdx:
--------------------------------------------------------------------------------
1 | # Init Callback
2 |
3 |
4 |
5 | 🦉 There's one more thing you should know about `useState` initialization and
6 | that is a small performance optimization. `useState` can accept a function.
7 |
8 | You may recall from earlier we mentioned that the first argument to `useState`
9 | is only used during the initial render. It's not used on subsequent renders.
10 | This is because the initial value is only used when the component is first
11 | rendered. After that, the value is managed by React and you use the updater
12 | function to update it.
13 |
14 | But imagine a situation where calculating that initial value were
15 | computationally expensive. It would be a waste to compute the initial value for
16 | all but the initial render right? That's where the function form of `useState`
17 | comes in.
18 |
19 | Let's imagine we have a function that calculates the initial value and it's
20 | computationally expensive:
21 |
22 | ```tsx
23 | const [val, setVal] = useState(calculateInitialValue())
24 | ```
25 |
26 | This will work just fine, but it's not ideal. The `calculateInitialValue` will
27 | be called on every render, even though it's only needed for the initial render.
28 | So instead of calling the function, we can just pass it:
29 |
30 | ```tsx
31 | const [val, setVal] = useState(calculateInitialValue)
32 | ```
33 |
34 | Typically doing this is unnecessary, but it's good to know about in case you
35 | need it.
36 |
37 | So
38 |
39 | ```tsx
40 | // This will call getQueryParam on every render, undermining our optimization! 😵
41 | const [query, setQuery] = useState(getQueryParam())
42 |
43 | // This will _only_ call getQueryParam on init. Great! ✅
44 | const [query, setQuery] = useState(getQueryParam)
45 | ```
46 |
47 | You're going to be making the `getQueryParam` function. Got it? Great, let's go!
48 |
49 |
50 | 🚨 Note, we can't reasonably test whether you're doing this right so the tests
51 | are passing from the get-go, but you'll know you didn't break anything if the
52 | tests are still working when you're finished.
53 |
54 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/05.problem.cb/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/05.solution.cb/README.mdx:
--------------------------------------------------------------------------------
1 | # Init Callback
2 |
3 |
4 |
5 | 👨💼 Great! This isn't 100% necessary as a performance optimization, but it's easy
6 | and doesn't hurt readability so we may as well!
7 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/05.solution.cb/controlled-checkbox.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can search for a checkbox value', async () => {
25 | fireEvent.change(searchBox, { target: { value: 'dog' } })
26 | })
27 |
28 | await testStep('checkbox is checked automatically', async () => {
29 | expect(dogCheckbox).toBeChecked()
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/05.solution.cb/controlled-search.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can select the dog checkbox', async () => {
25 | fireEvent.click(dogCheckbox)
26 | expect(dogCheckbox).toBeChecked()
27 | })
28 |
29 | await testStep(
30 | 'Selecting the checkbox updates the search and results',
31 | async () => {
32 | // Check that the search box value has been updated
33 | expect(searchBox).toHaveValue('dog')
34 |
35 | // Check that the results have been filtered
36 | await dtl.waitFor(async () => {
37 | await screen.findByText(/the joy of owning a dog/i)
38 |
39 | const catResult = screen.queryByText(/caring for your feline friend/i)
40 | expect(catResult).not.toBeInTheDocument()
41 | })
42 | },
43 | )
44 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/05.solution.cb/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/05.solution.cb/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/05.solution.cb/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import { generateGradient, getMatchingPosts } from '#shared/blog-posts'
4 |
5 | function getQueryParam() {
6 | const params = new URLSearchParams(window.location.search)
7 | return params.get('query') ?? ''
8 | }
9 |
10 | function App() {
11 | const [query, setQuery] = useState(getQueryParam)
12 | const words = query.split(' ')
13 |
14 | const dogChecked = words.includes('dog')
15 | const catChecked = words.includes('cat')
16 | const caterpillarChecked = words.includes('caterpillar')
17 |
18 | function handleCheck(tag: string, checked: boolean) {
19 | const newWords = checked ? [...words, tag] : words.filter(w => w !== tag)
20 | setQuery(newWords.filter(Boolean).join(' ').trim())
21 | }
22 |
23 | return (
24 |
68 | )
69 | }
70 |
71 | function MatchingPosts({ query }: { query: string }) {
72 | const matchingPosts = getMatchingPosts(query)
73 |
74 | return (
75 |
95 | )
96 | }
97 |
98 | const rootEl = document.createElement('div')
99 | document.body.append(rootEl)
100 | createRoot(rootEl).render( )
101 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/05.solution.cb/search-params.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen } = dtl
3 |
4 | const currentPath = window.location.pathname
5 | window.history.pushState({}, '', `${currentPath}?query=dog`)
6 |
7 | await import('./index.tsx')
8 |
9 | await testStep(
10 | 'The search box is initialized with URL query parameter',
11 | async () => {
12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
13 | expect(searchBox).toHaveValue('dog')
14 | },
15 | )
16 |
--------------------------------------------------------------------------------
/exercises/01.managing-ui-state/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Managing UI State
2 |
3 |
4 |
5 | 👨💼 Great work! You now know how to manage client-side component state in a React
6 | application!
7 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/01.problem.effects/README.mdx:
--------------------------------------------------------------------------------
1 | # useEffect
2 |
3 |
4 |
5 | 👨💼 We want the query to update as the query params change through a
6 | [`popstate`](https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event)
7 | event. Once you added the `useEffect` hook, you then have to prevent the default
8 | full refresh of the page when the user clicks the back/forward button.
9 |
10 | 🧝♂️ I added a new utility called `setGlobalSearchParams` which allows us to set
11 | the URL search params without triggering a full-page refresh. So whenever the
12 | user clicks "submit," we update the URL search params. The trouble is, when they
13 | hit the back button, the search doesn't stay synchronized with the URL (yet).
14 |
15 | You can take a look at my changes for
16 | details on what I did.
17 |
18 | 👨💼 Thanks Kellie. So what we want you to do is make it so when the user hits the
19 | back button in their browser and the search params are updated, we update the input
20 | value.
21 |
22 | The only way to be notified of a back/forward event is to listen for the
23 | `popstate` event. You can do this by adding an event listener to the `window`
24 | object.
25 |
26 | ```tsx
27 | window.addEventListener('popstate', () => {
28 | // update the input value
29 | })
30 | ```
31 |
32 | To test whether you got this right follow these steps:
33 |
34 | - Go to (in a new tab)
35 | - Add " cat" in the input
36 | - Click "submit"
37 | - Click the back button in your browser
38 | - The input value should now be "dog"
39 |
40 | That last step will be broken and that's what you should fix in this step.
41 |
42 |
43 | Spoiler alert: there's going to be a bug with our implementation that we'll
44 | fix in the next step, so if you notice a bug... we'll get to it 😅
45 |
46 |
47 | One more thing, we're going to need to retrieve the query param in two places.
48 | Right now we get it to initialize our state, but we'll also want to get it when
49 | the `popstate` event fires. So you'll be extracting that logic to a small
50 | function and we'll pass that as a function to our `useState` (using the lazy
51 | initializer as before).
52 |
53 | In our case the performance difference is so small it's not really worth it, but
54 | since we're making a function anyway, you can simply pass the function to
55 | `useState` directly:
56 |
57 | ```tsx
58 | // both of these work just fine:
59 | const [query, setQuery] = useState(getQueryParam())
60 | const [query, setQuery] = useState(getQueryParam)
61 | ```
62 |
63 | You're going to be making the `getQueryParam` function. Got it? Great, let's go!
64 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/01.problem.effects/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/01.solution.effects/README.mdx:
--------------------------------------------------------------------------------
1 | # useEffect
2 |
3 |
4 |
5 | 👨💼 Great! Now you know how to make sure side-effects like global event listeners
6 | can be registered from within a React component and how to integrate that with
7 | the state of your React component. But we've still got a bug, so let's get to
8 | that.
9 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/01.solution.effects/controlled-checkbox.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can search for a checkbox value', async () => {
25 | fireEvent.change(searchBox, { target: { value: 'dog' } })
26 | })
27 |
28 | await testStep('checkbox is checked automatically', async () => {
29 | expect(dogCheckbox).toBeChecked()
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/01.solution.effects/controlled-search.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can select the dog checkbox', async () => {
25 | fireEvent.click(dogCheckbox)
26 | expect(dogCheckbox).toBeChecked()
27 | })
28 |
29 | await testStep(
30 | 'Selecting the checkbox updates the search and results',
31 | async () => {
32 | // Check that the search box value has been updated
33 | expect(searchBox).toHaveValue('dog')
34 |
35 | // Check that the results have been filtered
36 | await dtl.waitFor(async () => {
37 | await screen.findByText(/the joy of owning a dog/i)
38 |
39 | const catResult = screen.queryByText(/caring for your feline friend/i)
40 | expect(catResult).not.toBeInTheDocument()
41 | })
42 | },
43 | )
44 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/01.solution.effects/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/01.solution.effects/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/01.solution.effects/popstate.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | const currentPath = window.location.pathname
5 | window.history.pushState({}, '', `${currentPath}?query=dog`)
6 |
7 | await import('./index.tsx')
8 |
9 | await testStep(
10 | 'The search box is initialized with URL query parameter',
11 | async () => {
12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
13 | expect(searchBox).toHaveValue('dog')
14 | },
15 | )
16 |
17 | // wait for the event handler to be set up
18 | // for some reason it takes a bit
19 | await new Promise(resolve => setTimeout(resolve, 100))
20 |
21 | await testStep(
22 | 'The search box updates when popstate event is triggered',
23 | async () => {
24 | // Simulate navigation to a new URL
25 | const currentPath = window.location.pathname
26 | window.history.pushState({}, '', `${currentPath}?query=cat`)
27 |
28 | // Trigger popstate event
29 | fireEvent.popState(window)
30 |
31 | // Check if the search box value is updated
32 | await dtl.waitFor(async () =>
33 | expect(
34 | await screen.findByRole('searchbox', { name: /search/i }),
35 | ).toHaveValue('cat'),
36 | )
37 | },
38 | )
39 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/01.solution.effects/search-params.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | window.history.pushState({}, '', '?query=dog')
5 |
6 | await import('./index.tsx')
7 |
8 | await testStep(
9 | 'The search box is initialized with URL query parameter',
10 | async () => {
11 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
12 | expect(searchBox).toHaveValue('dog')
13 | },
14 | )
15 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/02.problem.cleanup/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/02.solution.cleanup/README.mdx:
--------------------------------------------------------------------------------
1 | # Effect Cleanup
2 |
3 |
4 |
5 | 👨💼 Phew! I'm glad we're not going to run into memory leaks now. Our users
6 | (especially) those on low-end devices will thank us for it!
7 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/02.solution.cleanup/controlled-checkbox.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can search for a checkbox value', async () => {
25 | fireEvent.change(searchBox, { target: { value: 'dog' } })
26 | })
27 |
28 | await testStep('checkbox is checked automatically', async () => {
29 | expect(dogCheckbox).toBeChecked()
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/02.solution.cleanup/controlled-search.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can select the dog checkbox', async () => {
25 | fireEvent.click(dogCheckbox)
26 | expect(dogCheckbox).toBeChecked()
27 | })
28 |
29 | await testStep(
30 | 'Selecting the checkbox updates the search and results',
31 | async () => {
32 | // Check that the search box value has been updated
33 | expect(searchBox).toHaveValue('dog')
34 |
35 | // Check that the results have been filtered
36 | await dtl.waitFor(async () => {
37 | await screen.findByText(/the joy of owning a dog/i)
38 |
39 | const catResult = screen.queryByText(/caring for your feline friend/i)
40 | expect(catResult).not.toBeInTheDocument()
41 | })
42 | },
43 | )
44 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/02.solution.cleanup/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/02.solution.cleanup/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/02.solution.cleanup/memory-leak.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | // Import the component
5 | await import('./index.tsx')
6 |
7 | declare global {
8 | interface Performance {
9 | memory?: {
10 | usedJSHeapSize: number
11 | jsHeapSizeLimit: number
12 | totalJSHeapSize: number
13 | }
14 | }
15 | }
16 |
17 | if (performance.memory) {
18 | async function toggleShowForm(times: number) {
19 | const checkbox = await screen.findByLabelText(/show form/i)
20 | for (let i = 0; i < times; i++) {
21 | fireEvent.click(checkbox)
22 | // Wait for any asynchronous operations to complete
23 | await new Promise(resolve => setTimeout(resolve, 10))
24 | }
25 | }
26 |
27 | await testStep(
28 | 'Memory usage does not increase linearly when toggling showForm',
29 | async () => {
30 | // Check if memory measurement is available
31 | if (!performance.memory) {
32 | console.warn(
33 | 'Memory measurement is not available in this browser. Skipping test.',
34 | )
35 | return
36 | }
37 |
38 | // wait a bit for garbage collection to finish
39 | await new Promise(resolve => setTimeout(resolve, 500))
40 | const initialMemory = performance.memory.usedJSHeapSize
41 |
42 | await toggleShowForm(250)
43 |
44 | // wait a bit for garbage collection to finish
45 | await new Promise(resolve => setTimeout(resolve, 500))
46 |
47 | const finalMemory = performance.memory.usedJSHeapSize
48 |
49 | const initialMemoryMB =
50 | (initialMemory / (1024 * 1024)).toLocaleString() + ' MB'
51 | const finalMemoryMB =
52 | (finalMemory / (1024 * 1024)).toLocaleString() + ' MB'
53 |
54 | const percentageChange = (
55 | ((finalMemory - initialMemory) / initialMemory) *
56 | 100
57 | ).toFixed(2)
58 | expect(
59 | Number(percentageChange),
60 | `🚨 The memory usage increased from ${initialMemoryMB} to ${finalMemoryMB} (a ${percentageChange}% increase)`,
61 | ).toBeLessThan(110)
62 | },
63 | )
64 | } else {
65 | await testStep(
66 | 'Memory measurement is not available in this browser. Skipping test.',
67 | () => {},
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/02.solution.cleanup/popstate.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | const currentPath = window.location.pathname
5 | window.history.pushState({}, '', `${currentPath}?query=dog`)
6 |
7 | await import('./index.tsx')
8 |
9 | await testStep(
10 | 'The search box is initialized with URL query parameter',
11 | async () => {
12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
13 | expect(searchBox).toHaveValue('dog')
14 | },
15 | )
16 |
17 | // wait for the event handler to be set up
18 | // for some reason it takes a bit
19 | await new Promise(resolve => setTimeout(resolve, 100))
20 |
21 | await testStep(
22 | 'The search box updates when popstate event is triggered',
23 | async () => {
24 | // Simulate navigation to a new URL
25 | const currentPath = window.location.pathname
26 | window.history.pushState({}, '', `${currentPath}?query=cat`)
27 |
28 | // Trigger popstate event
29 | fireEvent.popState(window)
30 |
31 | // Check if the search box value is updated
32 | await dtl.waitFor(async () =>
33 | expect(
34 | await screen.findByRole('searchbox', { name: /search/i }),
35 | ).toHaveValue('cat'),
36 | )
37 | },
38 | )
39 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/02.solution.cleanup/search-params.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | window.history.pushState({}, '', '?query=dog')
5 |
6 | await import('./index.tsx')
7 |
8 | await testStep(
9 | 'The search box is initialized with URL query parameter',
10 | async () => {
11 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
12 | expect(searchBox).toHaveValue('dog')
13 | },
14 | )
15 |
--------------------------------------------------------------------------------
/exercises/02.side-effects/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Side-Effects
2 |
3 |
4 |
5 | 👨💼 Great work! You've learned the basics of how to trigger side-effects when our
6 | component is added to the page and how to properly clean up when it's through.
7 | You've not learned everything there is to know about `useEffect` yet (that
8 | dependency array is still a topic we need to get into), but we'll get into the
9 | rest of that soon.
10 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.problem.lift/README.mdx:
--------------------------------------------------------------------------------
1 | # Lift State
2 |
3 |
4 |
5 | 🧝♂️ I refactored the app a bit. In the process I kinda broke stuff and you'll be
6 | required to fix it. Feel free to check the diff
7 | to know what I've done. I pretty much just moved stuff into separate components
8 | to get a little more organization in here.
9 |
10 |
11 | 🦉 Check out [When to break up a component into multiple
12 | components](https://kentcdodds.com/blog/when-to-break-up-a-component-into-multiple-components).
13 |
14 |
15 | 🧝♂️ Oh, also I added a new feature. You can now "like" specific posts by clicking
16 | a little heart on the post. How nice! It's not persisted anywhere yet, so when
17 | you refresh the page the likes all get reset, but it's a nice start!
18 |
19 | 👨💼 Great, now your job is to fix what's broken. You need to move not only the
20 | state, but also we're going to have you move the `useEffect` that sets the query
21 | as well.
22 |
23 | Then you're going to need to pass props to the components as needed. Enjoy!
24 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.problem.lift/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.solution.lift/README.mdx:
--------------------------------------------------------------------------------
1 | # Lift State
2 |
3 |
4 |
5 | 👨💼 Great! It's working now!
6 |
7 | Hmmm... What's that I hear? It's the sound of a new requirement coming in! 😆
8 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.solution.lift/controlled-checkbox.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can search for a checkbox value', async () => {
25 | fireEvent.change(searchBox, { target: { value: 'dog' } })
26 | })
27 |
28 | await testStep('checkbox is checked automatically', async () => {
29 | expect(dogCheckbox).toBeChecked()
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.solution.lift/controlled-search.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can select the dog checkbox', async () => {
25 | fireEvent.click(dogCheckbox)
26 | expect(dogCheckbox).toBeChecked()
27 | })
28 |
29 | await testStep(
30 | 'Selecting the checkbox updates the search and results',
31 | async () => {
32 | // Check that the search box value has been updated
33 | expect(searchBox).toHaveValue('dog')
34 |
35 | // Check that the results have been filtered
36 | await dtl.waitFor(async () => {
37 | await screen.findByText(/the joy of owning a dog/i)
38 |
39 | const catResult = screen.queryByText(/caring for your feline friend/i)
40 | expect(catResult).not.toBeInTheDocument()
41 | })
42 | },
43 | )
44 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.solution.lift/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.solution.lift/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.solution.lift/like-post.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | await testStep('The user can see the posts', async () => {
7 | await screen.findByText(/caring for your feline friend/i)
8 | await screen.findByText(/the joy of owning a dog/i)
9 | })
10 |
11 | const likeButtons = await testStep(
12 | 'The user can see like buttons',
13 | async () => {
14 | const buttons = await screen.findAllByRole('button', {
15 | name: /add favorite/i,
16 | })
17 | expect(buttons.length).toBeGreaterThan(0)
18 | return buttons
19 | },
20 | )
21 |
22 | const totalLikeButtons = likeButtons.length
23 |
24 | await testStep('The user can like a post', async () => {
25 | fireEvent.click(likeButtons[0])
26 | await screen.findByRole('button', { name: /remove favorite/i })
27 | })
28 |
29 | await testStep('The user can unlike a post', async () => {
30 | const unlikeButton = await screen.findByRole('button', {
31 | name: /remove favorite/i,
32 | })
33 | fireEvent.click(unlikeButton)
34 | await dtl.waitFor(() =>
35 | expect(
36 | screen.queryByRole('button', { name: /remove favorite/i }),
37 | ).not.toBeInTheDocument(),
38 | )
39 | const buttons = await screen.findAllByRole('button', {
40 | name: /add favorite/i,
41 | })
42 | expect(buttons.length).toBe(totalLikeButtons)
43 | })
44 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.solution.lift/popstate.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | const currentPath = window.location.pathname
5 | window.history.pushState({}, '', `${currentPath}?query=dog`)
6 |
7 | await import('./index.tsx')
8 |
9 | await testStep(
10 | 'The search box is initialized with URL query parameter',
11 | async () => {
12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
13 | expect(searchBox).toHaveValue('dog')
14 | },
15 | )
16 |
17 | // wait for the event handler to be set up
18 | // for some reason it takes a bit
19 | await new Promise(resolve => setTimeout(resolve, 100))
20 |
21 | await testStep(
22 | 'The search box updates when popstate event is triggered',
23 | async () => {
24 | // Simulate navigation to a new URL
25 | const currentPath = window.location.pathname
26 | window.history.pushState({}, '', `${currentPath}?query=cat`)
27 |
28 | // Trigger popstate event
29 | fireEvent.popState(window)
30 |
31 | // Check if the search box value is updated
32 | await dtl.waitFor(async () =>
33 | expect(
34 | await screen.findByRole('searchbox', { name: /search/i }),
35 | ).toHaveValue('cat'),
36 | )
37 | },
38 | )
39 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/01.solution.lift/search-params.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | window.history.pushState({}, '', '?query=dog')
5 |
6 | await import('./index.tsx')
7 |
8 | await testStep(
9 | 'The search box is initialized with URL query parameter',
10 | async () => {
11 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
12 | expect(searchBox).toHaveValue('dog')
13 | },
14 | )
15 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.problem.lift-array/README.mdx:
--------------------------------------------------------------------------------
1 | # Lift More State
2 |
3 |
4 |
5 | 👨💼 The users loved 🧝♂️ Kellie's like feature so much they want to have the blog
6 | posts sorted by whether they're favorites.
7 |
8 | 🧝♂️ I've already created the sort function. Now you need to add the logic to sort
9 | them based on whether they're favorited. And that means you need to lift some
10 | state...
11 |
12 | 👨💼 Yep. We're going to need you to lift the favorited state from the cards to
13 | the `MatchingPost` component. We'll also need to restructure it slightly as
14 | well. Don't worry. The emoji will guide you. Good luck!
15 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.problem.lift-array/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.solution.lift-array/README.mdx:
--------------------------------------------------------------------------------
1 | # Lift More State
2 |
3 |
4 |
5 | 👨💼 Great job! Unfortunately... I have some bad news. Let's talk about that next.
6 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.solution.lift-array/controlled-checkbox.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can search for a checkbox value', async () => {
25 | fireEvent.change(searchBox, { target: { value: 'dog' } })
26 | })
27 |
28 | await testStep('checkbox is checked automatically', async () => {
29 | expect(dogCheckbox).toBeChecked()
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.solution.lift-array/controlled-search.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can select the dog checkbox', async () => {
25 | fireEvent.click(dogCheckbox)
26 | expect(dogCheckbox).toBeChecked()
27 | })
28 |
29 | await testStep(
30 | 'Selecting the checkbox updates the search and results',
31 | async () => {
32 | // Check that the search box value has been updated
33 | expect(searchBox).toHaveValue('dog')
34 |
35 | // Check that the results have been filtered
36 | await dtl.waitFor(async () => {
37 | await screen.findByText(/the joy of owning a dog/i)
38 |
39 | const catResult = screen.queryByText(/caring for your feline friend/i)
40 | expect(catResult).not.toBeInTheDocument()
41 | })
42 | },
43 | )
44 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.solution.lift-array/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.solution.lift-array/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.solution.lift-array/like-post.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | await testStep('The user can see the posts', async () => {
7 | await screen.findByText(/caring for your feline friend/i)
8 | await screen.findByText(/the joy of owning a dog/i)
9 | })
10 |
11 | const likeButtons = await testStep(
12 | 'The user can see like buttons',
13 | async () => {
14 | const buttons = await screen.findAllByRole('button', {
15 | name: /add favorite/i,
16 | })
17 | expect(buttons.length).toBeGreaterThan(0)
18 | return buttons
19 | },
20 | )
21 |
22 | const totalLikeButtons = likeButtons.length
23 |
24 | await testStep('The user can like a post', async () => {
25 | fireEvent.click(likeButtons[1])
26 | await screen.findByRole('button', { name: /remove favorite/i })
27 | })
28 |
29 | await testStep('The liked post moves to the top', async () => {
30 | const posts = screen.getAllByRole('listitem')
31 | const firstPost = posts[0]
32 | expect(
33 | firstPost,
34 | 'The first post should have a remove favorite button',
35 | ).toContainElement(screen.getByRole('button', { name: /remove favorite/i }))
36 | })
37 |
38 | await testStep('The user can unlike a post', async () => {
39 | const unlikeButton = await screen.findByRole('button', {
40 | name: /remove favorite/i,
41 | })
42 | fireEvent.click(unlikeButton)
43 |
44 | await dtl.waitFor(() =>
45 | expect(
46 | screen.queryByRole('button', { name: /remove favorite/i }),
47 | ).not.toBeInTheDocument(),
48 | )
49 | const buttons = await screen.findAllByRole('button', {
50 | name: /add favorite/i,
51 | })
52 | expect(buttons.length).toBe(totalLikeButtons)
53 | })
54 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.solution.lift-array/popstate.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | const currentPath = window.location.pathname
5 | window.history.pushState({}, '', `${currentPath}?query=dog`)
6 |
7 | await import('./index.tsx')
8 |
9 | await testStep(
10 | 'The search box is initialized with URL query parameter',
11 | async () => {
12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
13 | expect(searchBox).toHaveValue('dog')
14 | },
15 | )
16 |
17 | // wait for the event handler to be set up
18 | // for some reason it takes a bit
19 | await new Promise(resolve => setTimeout(resolve, 100))
20 |
21 | await testStep(
22 | 'The search box updates when popstate event is triggered',
23 | async () => {
24 | // Simulate navigation to a new URL
25 | const currentPath = window.location.pathname
26 | window.history.pushState({}, '', `${currentPath}?query=cat`)
27 |
28 | // Trigger popstate event
29 | fireEvent.popState(window)
30 |
31 | // Check if the search box value is updated
32 | await dtl.waitFor(async () =>
33 | expect(
34 | await screen.findByRole('searchbox', { name: /search/i }),
35 | ).toHaveValue('cat'),
36 | )
37 | },
38 | )
39 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/02.solution.lift-array/search-params.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | window.history.pushState({}, '', '?query=dog')
5 |
6 | await import('./index.tsx')
7 |
8 | await testStep(
9 | 'The search box is initialized with URL query parameter',
10 | async () => {
11 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
12 | expect(searchBox).toHaveValue('dog')
13 | },
14 | )
15 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.problem.colocate/README.mdx:
--------------------------------------------------------------------------------
1 | # Colocate State
2 |
3 |
4 |
5 | 👨💼 Well, the users thought they wanted the articles sorted by whether they were
6 | favorited... But after using the feature, they found it to be jarring and
7 | confusing. So we're going to need you to remove that feature. Kellie already
8 | removed the sorting for you, but she didn't have time to move the state back to
9 | the `Card` component.
10 |
11 | As a community we're pretty good at lifting state. It becomes natural over time.
12 | In fact it's required to make the feature work. But as you notice here, the
13 | functionality we want is already "working" without moving any of the state
14 | around so it's easy to forget to improve the performance and maintainability of
15 | our code by moving state back down (or
16 | [colocate state](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster)).
17 |
18 | So your job is to move the `favorited` state back to the `Card` component.
19 |
20 | When you're finished, the functionality should be no different, but the code
21 | should feel simpler.
22 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.problem.colocate/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.solution.colocate/README.mdx:
--------------------------------------------------------------------------------
1 | # Colocate State
2 |
3 |
4 |
5 | 👨💼 Awesome! You now know how to colocate state! Try to do this as much as
6 | possible in the future.
7 |
8 | 🦉 You may have noticed that by putting the state in the `Card`, you actually
9 | lose the favorited state of the card whenever the card is removed from the page
10 | (like if you do a search that filters out the card). So really the best place
11 | for this state would be in the `App` to ensure it never gets lost (except on
12 | page reloads). Really the best place for this type of state would be in the
13 | database but that's out of scope for this workshop.
14 |
15 | In this exercise, I mostly want you to appreciate how much it simplifies our
16 | `Card` component and the `MatchingPosts` component by moving the state into the
17 | `Card` itself. Additionally, it helps performance because now when the favorite
18 | state changes for a card, React only needs to re-render that card rather than
19 | the entire `MatchingPosts` component.
20 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.solution.colocate/controlled-checkbox.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can search for a checkbox value', async () => {
25 | fireEvent.change(searchBox, { target: { value: 'dog' } })
26 | })
27 |
28 | await testStep('checkbox is checked automatically', async () => {
29 | expect(dogCheckbox).toBeChecked()
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.solution.colocate/controlled-search.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const dogCheckbox = await testStep(
16 | 'The user can see the dog checkbox',
17 | async () => {
18 | const result = await screen.findByRole('checkbox', { name: /dog/i })
19 | expect(result).not.toBeChecked()
20 | return result
21 | },
22 | )
23 |
24 | await testStep('The user can select the dog checkbox', async () => {
25 | fireEvent.click(dogCheckbox)
26 | expect(dogCheckbox).toBeChecked()
27 | })
28 |
29 | await testStep(
30 | 'Selecting the checkbox updates the search and results',
31 | async () => {
32 | // Check that the search box value has been updated
33 | expect(searchBox).toHaveValue('dog')
34 |
35 | // Check that the results have been filtered
36 | await dtl.waitFor(async () => {
37 | await screen.findByText(/the joy of owning a dog/i)
38 |
39 | const catResult = screen.queryByText(/caring for your feline friend/i)
40 | expect(catResult).not.toBeInTheDocument()
41 | })
42 | },
43 | )
44 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.solution.colocate/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const searchBox = await testStep(
7 | 'The user can see the search box',
8 | async () => {
9 | const result = await screen.findByRole('searchbox', { name: /search/i })
10 | expect(result).toHaveValue('')
11 | return result
12 | },
13 | )
14 |
15 | const catResult = await testStep('The user can see the results', async () => {
16 | const result = screen.getByText(/caring for your feline friend/i)
17 | expect(result).toBeInTheDocument()
18 | return result
19 | })
20 |
21 | await testStep('The user can search for a term', async () => {
22 | fireEvent.change(searchBox, { target: { value: 'dog' } })
23 | })
24 |
25 | await testStep('The results are filtered', async () => {
26 | await dtl.waitFor(() => {
27 | expect(catResult).not.toBeInTheDocument()
28 | })
29 | await screen.findByText(/the joy of owning a dog/i)
30 | })
31 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.solution.colocate/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | margin: 40px auto;
8 | max-width: 1024px;
9 | form {
10 | text-align: center;
11 | }
12 | }
13 |
14 | .post-list {
15 | list-style: none;
16 | padding: 0;
17 | display: flex;
18 | gap: 20px;
19 | flex-wrap: wrap;
20 | justify-content: center;
21 | li {
22 | position: relative;
23 | border-radius: 0.5rem;
24 | overflow: hidden;
25 | border: 1px solid #ddd;
26 | width: 320px;
27 | transition: transform 0.2s ease-in-out;
28 | a {
29 | text-decoration: none;
30 | color: unset;
31 | }
32 |
33 | &:hover,
34 | &:has(*:focus),
35 | &:has(*:active) {
36 | transform: translate(0px, -6px);
37 | }
38 |
39 | .post-image {
40 | display: block;
41 | width: 100%;
42 | height: 200px;
43 | }
44 |
45 | button {
46 | position: absolute;
47 | font-size: 1.5rem;
48 | top: 20px;
49 | right: 20px;
50 | background: transparent;
51 | border: none;
52 | outline: none;
53 | &:hover,
54 | &:focus,
55 | &:active {
56 | animation: pulse 1.5s infinite;
57 | }
58 | }
59 |
60 | a {
61 | padding: 10px 10px;
62 | display: flex;
63 | gap: 8px;
64 | flex-direction: column;
65 | h2 {
66 | margin: 0;
67 | font-size: 1.5rem;
68 | font-weight: bold;
69 | }
70 | p {
71 | margin: 0;
72 | font-size: 1rem;
73 | color: #666;
74 | }
75 | }
76 | }
77 | }
78 |
79 | @keyframes pulse {
80 | 0% {
81 | transform: scale(1);
82 | }
83 | 50% {
84 | transform: scale(1.3);
85 | }
86 | 100% {
87 | transform: scale(1);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.solution.colocate/like-post.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | await testStep('The user can see the posts', async () => {
7 | await screen.findByText(/caring for your feline friend/i)
8 | await screen.findByText(/the joy of owning a dog/i)
9 | })
10 |
11 | const likeButtons = await testStep(
12 | 'The user can see like buttons',
13 | async () => {
14 | const buttons = await screen.findAllByRole('button', {
15 | name: /add favorite/i,
16 | })
17 | expect(buttons.length).toBeGreaterThan(0)
18 | return buttons
19 | },
20 | )
21 |
22 | const totalLikeButtons = likeButtons.length
23 |
24 | await testStep('The user can like a post', async () => {
25 | fireEvent.click(likeButtons[1])
26 | await screen.findByRole('button', { name: /remove favorite/i })
27 | })
28 |
29 | await testStep('The liked post does not move to the top', async () => {
30 | const posts = screen.getAllByRole('listitem')
31 | const firstPost = posts[0]
32 | const removeFavoriteButton = screen.getByRole('button', {
33 | name: /remove favorite/i,
34 | })
35 | expect(
36 | firstPost,
37 | 'The first post should have a remove favorite button',
38 | ).not.toContainElement(removeFavoriteButton)
39 | const secondPost = posts[1]
40 | expect(
41 | secondPost,
42 | 'The second post should have a add favorite button',
43 | ).toContainElement(removeFavoriteButton)
44 | })
45 |
46 | await testStep('The user can unlike a post', async () => {
47 | const unlikeButton = await screen.findByRole('button', {
48 | name: /remove favorite/i,
49 | })
50 | fireEvent.click(unlikeButton)
51 |
52 | await dtl.waitFor(() =>
53 | expect(
54 | screen.queryByRole('button', { name: /remove favorite/i }),
55 | ).not.toBeInTheDocument(),
56 | )
57 | const buttons = await screen.findAllByRole('button', {
58 | name: /add favorite/i,
59 | })
60 | expect(buttons.length).toBe(totalLikeButtons)
61 | })
62 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.solution.colocate/popstate.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | const currentPath = window.location.pathname
5 | window.history.pushState({}, '', `${currentPath}?query=dog`)
6 |
7 | await import('./index.tsx')
8 |
9 | await testStep(
10 | 'The search box is initialized with URL query parameter',
11 | async () => {
12 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
13 | expect(searchBox).toHaveValue('dog')
14 | },
15 | )
16 |
17 | // wait for the event handler to be set up
18 | // for some reason it takes a bit
19 | await new Promise(resolve => setTimeout(resolve, 100))
20 |
21 | await testStep(
22 | 'The search box updates when popstate event is triggered',
23 | async () => {
24 | // Simulate navigation to a new URL
25 | const currentPath = window.location.pathname
26 | window.history.pushState({}, '', `${currentPath}?query=cat`)
27 |
28 | // Trigger popstate event
29 | fireEvent.popState(window)
30 |
31 | // Check if the search box value is updated
32 | await dtl.waitFor(async () =>
33 | expect(
34 | await screen.findByRole('searchbox', { name: /search/i }),
35 | ).toHaveValue('cat'),
36 | )
37 | },
38 | )
39 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/03.solution.colocate/search-params.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | window.history.pushState({}, '', '?query=dog')
5 |
6 | await import('./index.tsx')
7 |
8 | await testStep(
9 | 'The search box is initialized with URL query parameter',
10 | async () => {
11 | const searchBox = await screen.findByRole('searchbox', { name: /search/i })
12 | expect(searchBox).toHaveValue('dog')
13 | },
14 | )
15 |
--------------------------------------------------------------------------------
/exercises/03.lifting-state/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Lifting State
2 |
3 |
4 |
5 | 👨💼 Hooray! You've learned how to move state around as needed like a pro!
6 |
--------------------------------------------------------------------------------
/exercises/04.dom/01.problem.ref/README.mdx:
--------------------------------------------------------------------------------
1 | # Refs
2 |
3 |
4 |
5 | 👨💼 Our users want a button they can click to increment a count a bunch of times.
6 | They also like fancy things. So we're going to package it in a fancy way.
7 |
8 | In this exercise we're going to use a completely different example. We're going
9 | to make a ` ` component that renders a div and uses the
10 | [`vanilla-tilt` library](https://micku7zu.github.io/vanilla-tilt.js/) to make it
11 | super fancy.
12 |
13 | The thing is, `vanilla-tilt` works directly with DOM nodes to setup event
14 | handlers and stuff, so we need access to the DOM node. But because we're not the
15 | one calling `document.createElement` (React does) we need React to give it to
16 | us.
17 |
18 | So in this exercise we're going to use a `ref` so React can give us the DOM node
19 | and then we can pass that on to `vanilla-tilt`.
20 |
21 | Additionally, we'll need to clean up after ourselves if this component is
22 | unmounted. Otherwise we'll have event handlers dangling around on DOM nodes that
23 | are no longer in the document which can cause a memory leak.
24 |
25 | The emoji will guide you. Enjoy!
26 |
--------------------------------------------------------------------------------
/exercises/04.dom/01.problem.ref/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | Taken from the vanilla-tilt.js demo site:
3 | https://micku7zu.github.io/vanilla-tilt.js/index.html
4 | */
5 | .tilt-root {
6 | height: 150px;
7 | background-color: red;
8 | width: 200px;
9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%);
10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%);
11 | transform-style: preserve-3d;
12 | will-change: transform;
13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1);
14 | }
15 | .tilt-child {
16 | position: absolute;
17 | width: 50%;
18 | height: 50%;
19 | top: 50%;
20 | left: 50%;
21 | transform: translateZ(30px) translateX(-50%) translateY(-50%);
22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3);
23 | background-color: white;
24 | }
25 | .totally-centered {
26 | width: 100%;
27 | height: 100%;
28 | display: flex;
29 | justify-content: center;
30 | align-items: center;
31 | }
32 |
33 | .count-button {
34 | width: 100%;
35 | height: 100%;
36 | background: transparent;
37 | border: none;
38 | font-size: 3em;
39 | }
40 |
--------------------------------------------------------------------------------
/exercises/04.dom/01.problem.ref/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | // 💰 you'll need this stuff:
4 | // import VanillaTilt from 'vanilla-tilt'
5 | //
6 | // interface HTMLVanillaTiltElement extends HTMLDivElement {
7 | // vanillaTilt?: VanillaTilt
8 | // }
9 | //
10 | // const vanillaTiltOptions = {
11 | // max: 25,
12 | // speed: 400,
13 | // glare: true,
14 | // 'max-glare': 0.5,
15 | // }
16 |
17 | function Tilt({ children }: { children: React.ReactNode }) {
18 | return (
19 |
31 | )
32 | }
33 |
34 | function App() {
35 | const [showTilt, setShowTilt] = useState(true)
36 | const [count, setCount] = useState(0)
37 | return (
38 |
39 |
setShowTilt(s => !s)}>Toggle Visibility
40 | {showTilt ? (
41 |
42 |
43 | setCount(c => c + 1)}
46 | >
47 | {count}
48 |
49 |
50 |
51 | ) : null}
52 |
53 | )
54 | }
55 |
56 | const rootEl = document.createElement('div')
57 | document.body.append(rootEl)
58 | createRoot(rootEl).render( )
59 |
--------------------------------------------------------------------------------
/exercises/04.dom/01.solution.ref/README.mdx:
--------------------------------------------------------------------------------
1 | # Refs
2 |
3 |
4 |
5 | 👨💼 Great job! Now our users have a fancy counter!
6 |
--------------------------------------------------------------------------------
/exercises/04.dom/01.solution.ref/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | Taken from the vanilla-tilt.js demo site:
3 | https://micku7zu.github.io/vanilla-tilt.js/index.html
4 | */
5 | .tilt-root {
6 | height: 150px;
7 | background-color: red;
8 | width: 200px;
9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%);
10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%);
11 | transform-style: preserve-3d;
12 | will-change: transform;
13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1);
14 | }
15 | .tilt-child {
16 | position: absolute;
17 | width: 50%;
18 | height: 50%;
19 | top: 50%;
20 | left: 50%;
21 | transform: translateZ(30px) translateX(-50%) translateY(-50%);
22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3);
23 | background-color: white;
24 | }
25 | .totally-centered {
26 | width: 100%;
27 | height: 100%;
28 | display: flex;
29 | justify-content: center;
30 | align-items: center;
31 | }
32 |
33 | .count-button {
34 | width: 100%;
35 | height: 100%;
36 | background: transparent;
37 | border: none;
38 | font-size: 3em;
39 | }
40 |
--------------------------------------------------------------------------------
/exercises/04.dom/01.solution.ref/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import VanillaTilt from 'vanilla-tilt'
4 |
5 | interface HTMLVanillaTiltElement extends HTMLDivElement {
6 | vanillaTilt?: VanillaTilt
7 | }
8 |
9 | const vanillaTiltOptions = {
10 | max: 25,
11 | speed: 400,
12 | glare: true,
13 | 'max-glare': 0.5,
14 | }
15 |
16 | function Tilt({ children }: { children: React.ReactNode }) {
17 | return (
18 | {
21 | // 🦉 The types show tiltNode can be null. This is for backward
22 | // compatibility reasons and will be removed in the future.
23 | if (!tiltNode) return
24 | VanillaTilt.init(tiltNode, vanillaTiltOptions)
25 | return () => tiltNode.vanillaTilt?.destroy()
26 | }}
27 | >
28 |
{children}
29 |
30 | )
31 | }
32 |
33 | function App() {
34 | const [showTilt, setShowTilt] = useState(true)
35 | const [count, setCount] = useState(0)
36 | return (
37 |
38 |
setShowTilt(s => !s)}>Toggle Visibility
39 | {showTilt ? (
40 |
41 |
42 | setCount(c => c + 1)}
45 | >
46 | {count}
47 |
48 |
49 |
50 | ) : null}
51 |
52 | )
53 | }
54 |
55 | const rootEl = document.createElement('div')
56 | document.body.append(rootEl)
57 | createRoot(rootEl).render( )
58 |
--------------------------------------------------------------------------------
/exercises/04.dom/01.solution.ref/tilt.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 |
3 | import './index.tsx'
4 |
5 | await testStep('VanillaTilt is initialized', async () => {
6 | await dtl.waitFor(() => {
7 | const tiltElement = document.querySelector('.tilt-root')
8 | expect(tiltElement).toHaveProperty('vanillaTilt')
9 | })
10 | })
11 |
--------------------------------------------------------------------------------
/exercises/04.dom/01.solution.ref/toggle.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const toggleButton = await testStep(
7 | 'The user can see the toggle visibility button',
8 | async () => {
9 | const result = await screen.findByRole('button', {
10 | name: /toggle visibility/i,
11 | })
12 | expect(result).toBeInTheDocument()
13 | return result
14 | },
15 | )
16 |
17 | await testStep('The Tilt component is initially visible', async () => {
18 | return dtl.waitFor(() => {
19 | const result = document.querySelector('.tilt-root')
20 | expect(result).toBeInTheDocument()
21 | return result
22 | })
23 | })
24 |
25 | const countButton = await testStep(
26 | 'The count button is visible inside the Tilt component',
27 | async () => {
28 | const result = await screen.findByRole('button', { name: /0/i })
29 | expect(result).toBeInTheDocument()
30 | return result
31 | },
32 | )
33 |
34 | await testStep('The user can increment the count', async () => {
35 | fireEvent.click(countButton)
36 | const updatedButton = await screen.findByRole('button', { name: /1/i })
37 | expect(updatedButton).toBeInTheDocument()
38 | })
39 |
40 | await testStep(
41 | 'The user can toggle the Tilt component visibility',
42 | async () => {
43 | fireEvent.click(toggleButton)
44 | await dtl.waitFor(() => {
45 | expect(document.querySelector('.tilt-root')).not.toBeInTheDocument()
46 | })
47 |
48 | fireEvent.click(toggleButton)
49 | const visibleTiltElement = await dtl.waitFor(() => {
50 | const result = document.querySelector('.tilt-root')
51 | expect(result).toBeInTheDocument()
52 | return result
53 | })
54 | expect(visibleTiltElement).toBeInTheDocument()
55 | },
56 | )
57 |
--------------------------------------------------------------------------------
/exercises/04.dom/02.problem.deps/README.mdx:
--------------------------------------------------------------------------------
1 | # Dependencies
2 |
3 |
4 |
5 | 👨💼 Our users wanted to be able to control `vanilla-tilt` a bit. Some of them
6 | like the speed and glare to look different. So Kellie 🧝♂️ added a form that will
7 | allow them to control those values. This is working great, but something we
8 | noticed is the tilt effect is getting reset whenever you click the count button!
9 |
10 | 🦉 The reason this happens is because the ref callback is called every time the
11 | component is rendered (and the cleanup runs between renders). So we're
12 | re-initializing the tilt effect on every render.
13 |
14 | 👨💼 This is inefficient and it's a jarring experience if the user clicks on the
15 | corner of the count button. We want the effect to only re-initialize when the
16 | options change.
17 |
18 | The trick is we want the effect to re-initialize when the `vanillaTiltOptions`
19 | change, but nothing else. So we can use `useEffect` to do that:
20 |
21 | ```tsx
22 | useEffect(
23 | () => {
24 | // set up stuff
25 | return function cleanup() {
26 | // clean up stuff
27 | }
28 | },
29 | // depend on stuff:
30 | [],
31 | )
32 | ```
33 |
34 | 🦉 React needs to know when it needs to run your effect callback function again.
35 | We do this using the dependency array which is the second argument to
36 | `useEffect`. Whenever values in that array changes, React will call the returned
37 | cleanup function and then invoke the effect callback again.
38 |
39 | By default, if you don't provide a second argument, `useEffect` runs after every
40 | render (similar to what we're currently experiencing with the `ref` callback).
41 | While this is probably the right default for correctness, it's far from optimal
42 | in most `useEffect` cases. If you're not careful, it's easy to end up with
43 | infinite loops (imagine if you're calling `setState` in the effect which
44 | triggers another render, which calls the effect, which calls `setState` and so
45 | on).
46 |
47 | 👨💼 So what we need to do in this step is move our `ref` callback stuff to
48 | `useEffect`, create a `useRef` so we can access the DOM node in the `useEffect`
49 | callback, and let React know that our effect callback depends on the
50 | `vanillaTiltOptions` the user is providing. Let's do that by passing the
51 | `vanillaTiltOptions` in the dependency array.
52 |
53 |
54 | You'll notice an issue when you've finished this step. If you click the button
55 | to increment the count, the tilt effect is still reset! We'll fix this in the
56 | next step.
57 |
58 |
--------------------------------------------------------------------------------
/exercises/04.dom/02.problem.deps/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | Taken from the vanilla-tilt.js demo site:
3 | https://micku7zu.github.io/vanilla-tilt.js/index.html
4 | */
5 | .tilt-root {
6 | height: 150px;
7 | background-color: red;
8 | width: 200px;
9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%);
10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%);
11 | transform-style: preserve-3d;
12 | will-change: transform;
13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1);
14 | }
15 | .tilt-child {
16 | position: absolute;
17 | width: 50%;
18 | height: 50%;
19 | top: 50%;
20 | left: 50%;
21 | transform: translateZ(30px) translateX(-50%) translateY(-50%);
22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3);
23 | background-color: white;
24 | }
25 | .totally-centered {
26 | width: 100%;
27 | height: 100%;
28 | display: flex;
29 | justify-content: center;
30 | align-items: center;
31 | }
32 |
33 | .count-button {
34 | width: 100%;
35 | height: 100%;
36 | background: transparent;
37 | border: none;
38 | font-size: 3em;
39 | }
40 |
--------------------------------------------------------------------------------
/exercises/04.dom/02.solution.deps/README.mdx:
--------------------------------------------------------------------------------
1 | # Dependencies
2 |
3 |
4 |
5 | 👨💼 Great! But... ummm... we didn't actually solve the problem. It's still
6 | getting re-initialized whenever you click the count button. That's not good.
7 | Let's look into why and fix it.
8 |
--------------------------------------------------------------------------------
/exercises/04.dom/02.solution.deps/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | Taken from the vanilla-tilt.js demo site:
3 | https://micku7zu.github.io/vanilla-tilt.js/index.html
4 | */
5 | .tilt-root {
6 | height: 150px;
7 | background-color: red;
8 | width: 200px;
9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%);
10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%);
11 | transform-style: preserve-3d;
12 | will-change: transform;
13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1);
14 | }
15 | .tilt-child {
16 | position: absolute;
17 | width: 50%;
18 | height: 50%;
19 | top: 50%;
20 | left: 50%;
21 | transform: translateZ(30px) translateX(-50%) translateY(-50%);
22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3);
23 | background-color: white;
24 | }
25 | .totally-centered {
26 | width: 100%;
27 | height: 100%;
28 | display: flex;
29 | justify-content: center;
30 | align-items: center;
31 | }
32 |
33 | .count-button {
34 | width: 100%;
35 | height: 100%;
36 | background: transparent;
37 | border: none;
38 | font-size: 3em;
39 | }
40 |
--------------------------------------------------------------------------------
/exercises/04.dom/02.solution.deps/tilt.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | import type VanillaTilt from 'vanilla-tilt'
3 | const { screen, fireEvent, waitFor } = dtl
4 |
5 | interface HTMLVanillaTiltElement extends HTMLDivElement {
6 | vanillaTilt?: VanillaTilt
7 | }
8 |
9 | import './index.tsx'
10 |
11 | const tiltElement = await testStep('Initialize tilt element', async () => {
12 | const result = await waitFor(() => {
13 | const element = document.querySelector('.tilt-root')
14 | expect(element).toBeInTheDocument()
15 | return element
16 | })
17 | await waitFor(() => {
18 | expect(result).toHaveProperty('vanillaTilt')
19 | })
20 | return result as HTMLVanillaTiltElement
21 | })
22 |
23 | await testStep('Find count button', async () => {
24 | const button = await screen.findByRole('button', { name: /0/i })
25 | expect(button).toBeInTheDocument()
26 | return button as HTMLButtonElement
27 | })
28 |
29 | const maxInput = await testStep('Find max input', async () => {
30 | const input = (await screen.findByLabelText('Max:')) as HTMLInputElement
31 | expect(input).toBeInTheDocument()
32 | return input as HTMLInputElement
33 | })
34 |
35 | await testStep('Tilt effect resets when options change', async () => {
36 | const initialVanillaTilt = tiltElement.vanillaTilt
37 | fireEvent.change(maxInput, { target: { value: '30' } })
38 | await waitFor(() => {
39 | expect(tiltElement.vanillaTilt).not.toBe(initialVanillaTilt)
40 | })
41 | })
42 |
43 | await testStep('Tilt effect uses updated options', async () => {
44 | const newMax = 35
45 | fireEvent.change(maxInput, { target: { value: newMax.toString() } })
46 | await waitFor(() => {
47 | // @ts-expect-error this is not exposed
48 | expect(tiltElement.vanillaTilt?.settings.max).toBe(newMax)
49 | })
50 | })
51 |
--------------------------------------------------------------------------------
/exercises/04.dom/02.solution.deps/toggle.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const toggleButton = await testStep(
7 | 'The user can see the toggle visibility button',
8 | async () => {
9 | const result = await screen.findByRole('button', {
10 | name: /toggle visibility/i,
11 | })
12 | expect(result).toBeInTheDocument()
13 | return result
14 | },
15 | )
16 |
17 | await testStep('The Tilt component is initially visible', async () => {
18 | return dtl.waitFor(() => {
19 | const result = document.querySelector('.tilt-root')
20 | expect(result).toBeInTheDocument()
21 | return result
22 | })
23 | })
24 |
25 | const countButton = await testStep(
26 | 'The count button is visible inside the Tilt component',
27 | async () => {
28 | const result = await screen.findByRole('button', { name: /0/i })
29 | expect(result).toBeInTheDocument()
30 | return result
31 | },
32 | )
33 |
34 | await testStep('The user can increment the count', async () => {
35 | fireEvent.click(countButton)
36 | const updatedButton = await screen.findByRole('button', { name: /1/i })
37 | expect(updatedButton).toBeInTheDocument()
38 | })
39 |
40 | await testStep(
41 | 'The user can toggle the Tilt component visibility',
42 | async () => {
43 | fireEvent.click(toggleButton)
44 | await dtl.waitFor(() => {
45 | expect(document.querySelector('.tilt-root')).not.toBeInTheDocument()
46 | })
47 |
48 | fireEvent.click(toggleButton)
49 | const visibleTiltElement = await dtl.waitFor(() => {
50 | const result = document.querySelector('.tilt-root')
51 | expect(result).toBeInTheDocument()
52 | return result
53 | })
54 | expect(visibleTiltElement).toBeInTheDocument()
55 | },
56 | )
57 |
--------------------------------------------------------------------------------
/exercises/04.dom/03.problem.primitives/README.mdx:
--------------------------------------------------------------------------------
1 | # Primitive Dependencies
2 |
3 |
4 |
5 | 👨💼 Our users are annoyed. Whenever they click the incrementing button in the
6 | middle, the tilt effect is reset. You can reproduce this more easily by clicking
7 | one of the corners of the button.
8 |
9 | Moving things into a `useEffect` was supposed to help this because we can be
10 | explicit about which dependencies trigger the `cleanup` and effect to be run
11 | again. But we're still having the problem.
12 |
13 | If you add a `console.log` to the `useEffect`, you'll notice that it runs even
14 | when the button is clicked, even if the actual options are unchanged. The reason
15 | is because the `options` object actually _did_ change! This is because the
16 | `options` object is a new object every time the component renders. This is
17 | because of the way we're using the `...` spread operator to collect the options
18 | into a single (brand new) object. This means that the dependency array will
19 | always be different and the effect will always run!
20 |
21 | `useEffect` iterates through each of our dependencies and checks whether they
22 | have changed and it uses `Object.is` to do so (this is effectively the same
23 | as `===`). This means that even if two objects have the same properties, they
24 | will not be considered equal if they are different objects.
25 |
26 | ```tsx
27 | const options1 = { glare: true, max: 25, 'max-glare': 0.5, speed: 400 }
28 | const options2 = { glare: true, max: 25, 'max-glare': 0.5, speed: 400 }
29 | Object.is(options1, options2) // false!!
30 | ```
31 |
32 | So the easiest way to fix this is by switching from using an object to using the
33 | primitive values directly. This way, the dependency array will only change when
34 | the actual values change.
35 |
36 | So please update the `useEffect` to use the primitive values directly. Thanks!
37 |
--------------------------------------------------------------------------------
/exercises/04.dom/03.problem.primitives/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | Taken from the vanilla-tilt.js demo site:
3 | https://micku7zu.github.io/vanilla-tilt.js/index.html
4 | */
5 | .tilt-root {
6 | height: 150px;
7 | background-color: red;
8 | width: 200px;
9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%);
10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%);
11 | transform-style: preserve-3d;
12 | will-change: transform;
13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1);
14 | }
15 | .tilt-child {
16 | position: absolute;
17 | width: 50%;
18 | height: 50%;
19 | top: 50%;
20 | left: 50%;
21 | transform: translateZ(30px) translateX(-50%) translateY(-50%);
22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3);
23 | background-color: white;
24 | }
25 | .totally-centered {
26 | width: 100%;
27 | height: 100%;
28 | display: flex;
29 | justify-content: center;
30 | align-items: center;
31 | }
32 |
33 | .count-button {
34 | width: 100%;
35 | height: 100%;
36 | background: transparent;
37 | border: none;
38 | font-size: 3em;
39 | }
40 |
--------------------------------------------------------------------------------
/exercises/04.dom/03.solution.primitives/README.mdx:
--------------------------------------------------------------------------------
1 | # Primitive Dependencies
2 |
3 |
4 |
5 | 👨💼 This is probably one of the more annoying parts about dependency arrays.
6 | Luckily modern React applications don't need to reach for `useEffect` for many
7 | use cases (thanks to frameworks like Remix), but it's important for you to
8 | understand.
9 |
--------------------------------------------------------------------------------
/exercises/04.dom/03.solution.primitives/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | Taken from the vanilla-tilt.js demo site:
3 | https://micku7zu.github.io/vanilla-tilt.js/index.html
4 | */
5 | .tilt-root {
6 | height: 150px;
7 | background-color: red;
8 | width: 200px;
9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%);
10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%);
11 | transform-style: preserve-3d;
12 | will-change: transform;
13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1);
14 | }
15 | .tilt-child {
16 | position: absolute;
17 | width: 50%;
18 | height: 50%;
19 | top: 50%;
20 | left: 50%;
21 | transform: translateZ(30px) translateX(-50%) translateY(-50%);
22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3);
23 | background-color: white;
24 | }
25 | .totally-centered {
26 | width: 100%;
27 | height: 100%;
28 | display: flex;
29 | justify-content: center;
30 | align-items: center;
31 | }
32 |
33 | .count-button {
34 | width: 100%;
35 | height: 100%;
36 | background: transparent;
37 | border: none;
38 | font-size: 3em;
39 | }
40 |
--------------------------------------------------------------------------------
/exercises/04.dom/03.solution.primitives/tilt.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | import type VanillaTilt from 'vanilla-tilt'
3 | const { screen, fireEvent, waitFor } = dtl
4 |
5 | interface HTMLVanillaTiltElement extends HTMLDivElement {
6 | vanillaTilt?: VanillaTilt
7 | }
8 |
9 | import './index.tsx'
10 |
11 | const tiltElement = await testStep('Initialize tilt element', async () => {
12 | const result = await waitFor(() => {
13 | const element = document.querySelector('.tilt-root')
14 | expect(element).toBeInTheDocument()
15 | return element
16 | })
17 | await waitFor(() => {
18 | expect(result).toHaveProperty('vanillaTilt')
19 | })
20 | return result as HTMLVanillaTiltElement
21 | })
22 |
23 | const countButton = await testStep('Find count button', async () => {
24 | const button = await screen.findByRole('button', { name: /0/i })
25 | expect(button).toBeInTheDocument()
26 | return button as HTMLButtonElement
27 | })
28 |
29 | const maxInput = await testStep('Find max input', async () => {
30 | const input = (await screen.findByLabelText('Max:')) as HTMLInputElement
31 | expect(input).toBeInTheDocument()
32 | return input as HTMLInputElement
33 | })
34 |
35 | await testStep('Tilt effect persists after count increment', async () => {
36 | const initialVanillaTilt = tiltElement.vanillaTilt
37 | fireEvent.click(countButton)
38 | await screen.findByRole('button', { name: /1/i })
39 | expect(tiltElement.vanillaTilt, 'vanilla tilt was reinitialized').toBe(
40 | initialVanillaTilt,
41 | )
42 | })
43 |
44 | await testStep('Tilt effect resets when options change', async () => {
45 | const initialVanillaTilt = tiltElement.vanillaTilt
46 | fireEvent.change(maxInput, { target: { value: '30' } })
47 | await waitFor(() => {
48 | expect(tiltElement.vanillaTilt).not.toBe(initialVanillaTilt)
49 | })
50 | })
51 |
52 | await testStep('Tilt effect uses updated options', async () => {
53 | const newMax = 35
54 | fireEvent.change(maxInput, { target: { value: newMax.toString() } })
55 | await waitFor(() => {
56 | // @ts-expect-error this is not exposed
57 | expect(tiltElement.vanillaTilt?.settings.max).toBe(newMax)
58 | })
59 | })
60 |
--------------------------------------------------------------------------------
/exercises/04.dom/03.solution.primitives/toggle.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent } = dtl
3 |
4 | import './index.tsx'
5 |
6 | const toggleButton = await testStep(
7 | 'The user can see the toggle visibility button',
8 | async () => {
9 | const result = await screen.findByRole('button', {
10 | name: /toggle visibility/i,
11 | })
12 | expect(result).toBeInTheDocument()
13 | return result
14 | },
15 | )
16 |
17 | await testStep('The Tilt component is initially visible', async () => {
18 | return dtl.waitFor(() => {
19 | const result = document.querySelector('.tilt-root')
20 | expect(result).toBeInTheDocument()
21 | return result
22 | })
23 | })
24 |
25 | const countButton = await testStep(
26 | 'The count button is visible inside the Tilt component',
27 | async () => {
28 | const result = await screen.findByRole('button', { name: /0/i })
29 | expect(result).toBeInTheDocument()
30 | return result
31 | },
32 | )
33 |
34 | await testStep('The user can increment the count', async () => {
35 | fireEvent.click(countButton)
36 | const updatedButton = await screen.findByRole('button', { name: /1/i })
37 | expect(updatedButton).toBeInTheDocument()
38 | })
39 |
40 | await testStep(
41 | 'The user can toggle the Tilt component visibility',
42 | async () => {
43 | fireEvent.click(toggleButton)
44 | await dtl.waitFor(() => {
45 | expect(document.querySelector('.tilt-root')).not.toBeInTheDocument()
46 | })
47 |
48 | fireEvent.click(toggleButton)
49 | const visibleTiltElement = await dtl.waitFor(() => {
50 | const result = document.querySelector('.tilt-root')
51 | expect(result).toBeInTheDocument()
52 | return result
53 | })
54 | expect(visibleTiltElement).toBeInTheDocument()
55 | },
56 | )
57 |
--------------------------------------------------------------------------------
/exercises/04.dom/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # DOM Side-Effects
2 |
3 |
4 |
5 | 👨💼 You did well! Now you know how to properly integrate third party DOM
6 | libraries with your React app. You can apply this same knowledge to other
7 | libraries that help you interact with other browser APIs as well. Good job!
8 |
--------------------------------------------------------------------------------
/exercises/05.unique-ids/01.problem.use-id/README.mdx:
--------------------------------------------------------------------------------
1 | # useId
2 |
3 |
4 |
5 | 🧝♂️ I've made a few changes to our `vanilla-tilt` app to try and make a reusable
6 | `Field` component. But there's a problem. When I try to click on the label for
7 | a field, it doesn't focus the input. This is because we're missing an `id`.
8 |
9 | I don't want to just use the `name` attribute as the `id` because I don't want
10 | to run the risk of having multiple elements with the same `id` on the page if
11 | we render multiple `Field` components with the same `name`. And I don't want to
12 | have to pass in an `id` prop every time I use the `Field` component. Though, if
13 | they do pass an `id` we should use the `id` they passed instead of a generated
14 | one (maybe they want a very specific `id` for some reason and we should support
15 | both!).
16 |
17 | As usual, you can check my work if you like.
18 |
19 | 👨💼 So your job is to fix the label association. You'll notice that the checkbox
20 | is working as expected already because it's not using the `Field` component due
21 | to its unique structure.
22 |
23 | So, let's handle the `id` in the `Field` component.
24 |
--------------------------------------------------------------------------------
/exercises/05.unique-ids/01.problem.use-id/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | Taken from the vanilla-tilt.js demo site:
3 | https://micku7zu.github.io/vanilla-tilt.js/index.html
4 | */
5 | .tilt-root {
6 | height: 150px;
7 | background-color: red;
8 | width: 200px;
9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%);
10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%);
11 | transform-style: preserve-3d;
12 | will-change: transform;
13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1);
14 | }
15 | .tilt-child {
16 | position: absolute;
17 | width: 50%;
18 | height: 50%;
19 | top: 50%;
20 | left: 50%;
21 | transform: translateZ(30px) translateX(-50%) translateY(-50%);
22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3);
23 | background-color: white;
24 | }
25 | .totally-centered {
26 | width: 100%;
27 | height: 100%;
28 | display: flex;
29 | justify-content: center;
30 | align-items: center;
31 | }
32 |
33 | .count-button {
34 | width: 100%;
35 | height: 100%;
36 | background: transparent;
37 | border: none;
38 | font-size: 3em;
39 | }
40 |
--------------------------------------------------------------------------------
/exercises/05.unique-ids/01.solution.use-id/README.mdx:
--------------------------------------------------------------------------------
1 | # useId
2 |
3 |
4 |
5 | 👨💼 Great, now our labels are properly associated and that makes our users happy.
6 |
--------------------------------------------------------------------------------
/exercises/05.unique-ids/01.solution.use-id/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | Taken from the vanilla-tilt.js demo site:
3 | https://micku7zu.github.io/vanilla-tilt.js/index.html
4 | */
5 | .tilt-root {
6 | height: 150px;
7 | background-color: red;
8 | width: 200px;
9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%);
10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%);
11 | transform-style: preserve-3d;
12 | will-change: transform;
13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1);
14 | }
15 | .tilt-child {
16 | position: absolute;
17 | width: 50%;
18 | height: 50%;
19 | top: 50%;
20 | left: 50%;
21 | transform: translateZ(30px) translateX(-50%) translateY(-50%);
22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3);
23 | background-color: white;
24 | }
25 | .totally-centered {
26 | width: 100%;
27 | height: 100%;
28 | display: flex;
29 | justify-content: center;
30 | align-items: center;
31 | }
32 |
33 | .count-button {
34 | width: 100%;
35 | height: 100%;
36 | background: transparent;
37 | border: none;
38 | font-size: 3em;
39 | }
40 |
--------------------------------------------------------------------------------
/exercises/05.unique-ids/01.solution.use-id/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useId, useRef, useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import VanillaTilt from 'vanilla-tilt'
4 |
5 | function Field({
6 | label,
7 | ...inputProps
8 | }: {
9 | label: string
10 | } & React.ComponentProps<'input'>) {
11 | const generatedId = useId()
12 | const id = inputProps.id ?? generatedId
13 | return (
14 |
15 | {label}
16 |
17 |
18 | )
19 | }
20 |
21 | interface HTMLVanillaTiltElement extends HTMLDivElement {
22 | vanillaTilt?: VanillaTilt
23 | }
24 |
25 | function Tilt({
26 | children,
27 | max = 25,
28 | speed = 400,
29 | glare = true,
30 | maxGlare = 0.5,
31 | }: {
32 | children: React.ReactNode
33 | max?: number
34 | speed?: number
35 | glare?: boolean
36 | maxGlare?: number
37 | }) {
38 | const tiltRef = useRef(null)
39 |
40 | useEffect(() => {
41 | const { current: tiltNode } = tiltRef
42 | if (!tiltNode) return
43 | const vanillaTiltOptions = {
44 | max,
45 | speed,
46 | glare,
47 | 'max-glare': maxGlare,
48 | }
49 | VanillaTilt.init(tiltNode, vanillaTiltOptions)
50 | return () => tiltNode.vanillaTilt?.destroy()
51 | }, [glare, max, maxGlare, speed])
52 |
53 | return (
54 |
57 | )
58 | }
59 |
60 | function App() {
61 | const [count, setCount] = useState(0)
62 | const [options, setOptions] = useState({
63 | max: 25,
64 | speed: 400,
65 | glare: true,
66 | maxGlare: 0.5,
67 | })
68 | return (
69 |
70 |
97 |
98 |
99 |
100 | setCount(c => c + 1)}>
101 | {count}
102 |
103 |
104 |
105 |
106 | )
107 | }
108 |
109 | const rootEl = document.createElement('div')
110 | document.body.append(rootEl)
111 | createRoot(rootEl).render( )
112 |
--------------------------------------------------------------------------------
/exercises/05.unique-ids/01.solution.use-id/tilt.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | import type VanillaTilt from 'vanilla-tilt'
3 | const { screen, fireEvent, waitFor } = dtl
4 |
5 | interface HTMLVanillaTiltElement extends HTMLDivElement {
6 | vanillaTilt?: VanillaTilt
7 | }
8 |
9 | import './index.tsx'
10 |
11 | const tiltElement = await testStep('Initialize tilt element', async () => {
12 | const result = await waitFor(() => {
13 | const element = document.querySelector('.tilt-root')
14 | expect(element).toBeInTheDocument()
15 | return element
16 | })
17 | await waitFor(() => {
18 | expect(result).toHaveProperty('vanillaTilt')
19 | })
20 | return result as HTMLVanillaTiltElement
21 | })
22 |
23 | const countButton = await testStep('Find count button', async () => {
24 | const button = await screen.findByRole('button', { name: /0/i })
25 | expect(button).toBeInTheDocument()
26 | return button as HTMLButtonElement
27 | })
28 |
29 | const maxInput = await testStep('Find max input', async () => {
30 | const input = (await screen.findByLabelText('Max')) as HTMLInputElement
31 | expect(input).toBeInTheDocument()
32 | return input as HTMLInputElement
33 | })
34 |
35 | await testStep('Tilt effect persists after count increment', async () => {
36 | const initialVanillaTilt = tiltElement.vanillaTilt
37 | fireEvent.click(countButton)
38 | await screen.findByRole('button', { name: /1/i })
39 | expect(tiltElement.vanillaTilt, 'vanilla tilt was reinitialized').toBe(
40 | initialVanillaTilt,
41 | )
42 | })
43 |
44 | await testStep('Tilt effect resets when options change', async () => {
45 | const initialVanillaTilt = tiltElement.vanillaTilt
46 | fireEvent.change(maxInput, { target: { value: '30' } })
47 | await waitFor(() => {
48 | expect(tiltElement.vanillaTilt).not.toBe(initialVanillaTilt)
49 | })
50 | })
51 |
52 | await testStep('Tilt effect uses updated options', async () => {
53 | const newMax = 35
54 | fireEvent.change(maxInput, { target: { value: newMax.toString() } })
55 | await waitFor(() => {
56 | // @ts-expect-error this is not exposed
57 | expect(tiltElement.vanillaTilt?.settings.max).toBe(newMax)
58 | })
59 | })
60 |
--------------------------------------------------------------------------------
/exercises/05.unique-ids/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Unique IDs
2 |
3 |
4 |
5 | 👨💼 Great work! Now you know how to make globally unique IDs for your React apps!
6 |
--------------------------------------------------------------------------------
/exercises/05.unique-ids/README.mdx:
--------------------------------------------------------------------------------
1 | # Unique IDs
2 |
3 |
4 |
5 | To build accessible forms, you need to ensure that each input element has a
6 | globally unique `id` attribute, and that the corresponding label element has a
7 | `for` attribute that matches the input's `id`. This allows screen readers to
8 | associate the label with the input, making it easier for users to understand the
9 | form's structure and purpose. Additionally, it allows users to click on the
10 | label to focus the input, which can be especially helpful for users with motor
11 | impairments (or like, for everyone you know?).
12 |
13 | This gets challenging with reusable components, especially when they're used
14 | multiple times on the same page. You can't just hardcode an `id` value, because
15 | then you'd have multiple elements with the same `id`, which is invalid HTML. You
16 | could use a random number or string, but then you'd have to manage that
17 | yourself, and it wouldn't be consistent between renders. And if you want to
18 | server render your app, you'd have to make sure the ID that's generated on the
19 | client matches the one that was generated on the server to avoid bugs which is
20 | a pain.
21 |
22 | This is where the `useId` hook comes into play.
23 |
24 | The `useId` hook generates a unique and stable identifier (ID) that you can use
25 | for DOM elements.
26 |
27 | Here's an example of how you can use the `useId` hook in a form component:
28 |
29 | ```tsx
30 | function FormField() {
31 | const id = useId()
32 | return (
33 |
34 | Name:
35 |
36 |
37 | )
38 | }
39 | ```
40 |
41 | In this example, `useId` generates a unique ID that links the label to the
42 | input, ensuring that screen readers and other assistive technologies can
43 | correctly identify the form field relationship.
44 |
45 | Unlike `useState` or `useEffect`, `useId` does not accept any arguments and
46 | returns a single string value. There's no setter or updater function because the
47 | ID it provides is meant to be constant and unique throughout the component's
48 | lifecycle.
49 |
50 | It's especially useful in server-side rendering (SSR) contexts because it
51 | ensures consistency between server-generated IDs and client-side generated ones,
52 | avoiding hydration mismatches.
53 |
54 | Remember, the main use of `useId` is for accessibility and managing
55 | relationships between different DOM elements, like labels and inputs. It helps
56 | keep your UI predictable and accessible without having to manage unique IDs
57 | yourself.
58 |
59 | One important thing to call out is that you should never use `useId` to generate
60 | IDs for non-DOM elements, like keys in a list or unique keys for React elements.
61 | Those IDs should come from your data, not from `useId`.
62 |
63 | 📜 Check [the `useId` docs](https://react.dev/reference/react/useId) for more
64 | info. (There's this interesting `identifierPrefix` feature you'll probably never
65 | use too! 😅)
66 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/01.problem.set-state-callback/README.mdx:
--------------------------------------------------------------------------------
1 | # setState callback
2 |
3 |
4 |
5 | 👨💼 Our users want to play tic-tac-toe.
6 |
7 |
8 | If you've gone through React's official tutorial, this was originally lifted
9 | from that.
10 |
11 |
12 | You're going to need some managed state and some derived state. Remember from
13 | exercise 1:
14 |
15 | - **Managed State:** State that you need to explicitly manage
16 | - **Derived State:** State that you can calculate based on other state
17 |
18 | `squares` is the managed state and it's the state of the board in a
19 | single-dimensional array:
20 |
21 | ```
22 | [
23 | 'X', 'O', 'X',
24 | 'X', 'O', 'O',
25 | 'X', 'X', 'O'
26 | ]
27 | ```
28 |
29 | This will start out as an empty array because it's the start of the game.
30 |
31 | `nextValue` will be either the string `X` or `O` and is derived state which you
32 | can determine based on the value of `squares`. We can determine whose turn it is
33 | based on how many "X" and "O" squares there are. We've written this out for you
34 | in a `calculateNextValue` function in the `tic-tac-toe-utils.tsx` file.
35 |
36 | `winner` will be either the string `X` or `O` and is derived state which can
37 | also be determined based on the value of `squares` and we've provided a
38 | `calculateWinner` function you can use to get that value.
39 |
40 | If you want to try this exercise on beast mode then you can ignore
41 | `calculateNextValue` and `calculateWinner` and write your own version of those
42 | utilities.
43 |
44 | Another important thing you'll need to do for this step of the exercise is to
45 | use the callback version of `setState` to ensure that the state is updated
46 | correctly. This is because the state is updated based on the previous state and
47 | you want to make sure that you're always using the most up-to-date state.
48 |
49 | ```tsx
50 | setCount(currentCount => {
51 | return currentCount + 1
52 | })
53 | ```
54 |
55 | Finally, something you need to know about state in React is it's important that
56 | you not mutate state directly. So instead of setting `squares[0] = 'X'` you will
57 | need to make a copy of the array with the modifications, for example, using
58 | [`with`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/with):
59 |
60 | ```tsx
61 | const newSquares = squares.with(index, 'X')
62 | ```
63 |
64 | The emoji should guide you well! Enjoy the exercise!
65 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/01.problem.set-state-callback/index.css:
--------------------------------------------------------------------------------
1 | .game {
2 | font:
3 | 14px 'Century Gothic',
4 | Futura,
5 | sans-serif;
6 | margin: 20px;
7 | min-height: 260px;
8 | }
9 |
10 | .game ol,
11 | .game ul {
12 | padding-left: 30px;
13 | }
14 |
15 | .board-row:after {
16 | clear: both;
17 | content: '';
18 | display: table;
19 | }
20 |
21 | .status {
22 | margin-bottom: 10px;
23 | }
24 |
25 | .restart {
26 | margin-top: 10px;
27 | }
28 |
29 | .square {
30 | background: #fff;
31 | border: 1px solid #999;
32 | float: left;
33 | font-size: 24px;
34 | font-weight: bold;
35 | line-height: 34px;
36 | height: 34px;
37 | margin-right: -1px;
38 | margin-top: -1px;
39 | padding: 0;
40 | text-align: center;
41 | width: 34px;
42 | }
43 |
44 | .square:focus {
45 | outline: none;
46 | background: #ddd;
47 | }
48 |
49 | .game {
50 | display: flex;
51 | flex-direction: row;
52 | }
53 |
54 | .game-info {
55 | margin-left: 20px;
56 | min-width: 190px;
57 | }
58 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/01.problem.set-state-callback/index.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client'
2 | // 💰 here are some handy utilities for you:
3 | // import {
4 | // calculateNextValue,
5 | // calculateStatus,
6 | // calculateWinner,
7 | // type Squares,
8 | // } from '#shared/tic-tac-toe-utils'
9 |
10 | const defaultState = Array(9).fill(null)
11 |
12 | function Board() {
13 | // 🐨 squares is the state for this component. Add useState for squares
14 | // 🦺 you can use the Squares type for the useState generic
15 | const squares = defaultState
16 |
17 | // 🐨 We'll need the following bits of derived state:
18 | // - nextValue ('X' or 'O')
19 | // - winner ('X', 'O', or null)
20 | // - status (`Winner: ${winner}`, `Scratch: Cat's game`, or `Next player: ${nextValue}`)
21 | // 💰 I've written the calculations for you! So you can use my utilities
22 | // from the imports above to create these variables
23 |
24 | // This is the function your square click handler will call. `square` should
25 | // be an index. So if they click the center square, this will be `4`.
26 | function selectSquare(index: number) {
27 | // 🐨 first, if there's already winner or there's already a value at the
28 | // given square index (like someone clicked a square that's already been
29 | // clicked), then return early so we don't make any state changes
30 | //
31 | // 🐨 call setSquares and pass a callback
32 | // which accepts the "previousSquares", and does this:
33 | // 🐨 make a copy of the squares array with the updated value
34 | // 💰 previousSquares.with(index, nextValue) will do it!
35 | //
36 | // 🐨 return your copy of the squares
37 | }
38 |
39 | function restart() {
40 | // 🐨 reset the squares by calling setSquares with an array of empty squares
41 | // 💰 you can use the defaultState variable
42 | }
43 |
44 | function renderSquare(i: number) {
45 | return (
46 | selectSquare(i)}>
47 | {squares[i]}
48 |
49 | )
50 | }
51 |
52 | return (
53 |
54 | {/* 🐨 put the status in the div below */}
55 |
STATUS
56 |
57 | {renderSquare(0)}
58 | {renderSquare(1)}
59 | {renderSquare(2)}
60 |
61 |
62 | {renderSquare(3)}
63 | {renderSquare(4)}
64 | {renderSquare(5)}
65 |
66 |
67 | {renderSquare(6)}
68 | {renderSquare(7)}
69 | {renderSquare(8)}
70 |
71 |
72 | restart
73 |
74 |
75 | )
76 | }
77 |
78 | function App() {
79 | return (
80 |
85 | )
86 | }
87 |
88 | const rootEl = document.createElement('div')
89 | document.body.append(rootEl)
90 | createRoot(rootEl).render( )
91 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/01.solution.set-state-callback/README.mdx:
--------------------------------------------------------------------------------
1 | # Real World Review: Tic Tac Toe
2 |
3 |
4 |
5 | 👨💼 Awesome! Now our users can play tic-tac-toe! They're thrilled. But you know,
6 | they kinda want more... As is often the case. We call this "job security!" 😆
7 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/01.solution.set-state-callback/board-game.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent, waitFor } = dtl
3 |
4 | await import('./index.tsx')
5 |
6 | function getSquares() {
7 | return waitFor(() => {
8 | const squares = document.querySelectorAll('button.square')
9 | expect(squares).toHaveLength(9)
10 | return squares
11 | })
12 | }
13 |
14 | await testStep('Initial board state', getSquares)
15 |
16 | const statusElement = await testStep('Find status element', async () => {
17 | const status = await screen.findByText(/Next player: X/)
18 | expect(status).toBeInTheDocument()
19 | return status
20 | })
21 |
22 | await testStep('Play a game', async () => {
23 | const squares = await getSquares()
24 |
25 | // X plays
26 | fireEvent.click(squares[0])
27 | await waitFor(() => {
28 | expect(squares[0]).toHaveTextContent('X')
29 | })
30 | expect(statusElement).toHaveTextContent('Next player: O')
31 |
32 | // O plays
33 | fireEvent.click(squares[4])
34 | await waitFor(() => {
35 | expect(squares[4]).toHaveTextContent('O')
36 | })
37 | expect(statusElement).toHaveTextContent('Next player: X')
38 |
39 | // X plays
40 | fireEvent.click(squares[1])
41 | // O plays
42 | fireEvent.click(squares[5])
43 | // X plays and wins
44 | fireEvent.click(squares[2])
45 |
46 | await waitFor(() => {
47 | expect(statusElement).toHaveTextContent('Winner: X')
48 | })
49 | })
50 |
51 | await testStep('Restart game', async () => {
52 | const restartButton = await screen.findByRole('button', { name: /restart/i })
53 | fireEvent.click(restartButton)
54 |
55 | await waitFor(async () => {
56 | const squares = await getSquares()
57 | expect(squares).toHaveLength(9)
58 | expect(statusElement).toHaveTextContent('Next player: X')
59 | })
60 | })
61 |
62 | await testStep('Cannot play on occupied square', async () => {
63 | const squares = await getSquares()
64 |
65 | fireEvent.click(squares[0])
66 | await waitFor(() => {
67 | expect(squares[0]).toHaveTextContent('X')
68 | })
69 |
70 | fireEvent.click(squares[0])
71 | await waitFor(() => {
72 | expect(squares[0]).toHaveTextContent('X')
73 | expect(statusElement).toHaveTextContent('Next player: O')
74 | })
75 | })
76 |
77 | await testStep('Game ends in a draw', async () => {
78 | const restartButton = await screen.findByRole('button', { name: /restart/i })
79 | fireEvent.click(restartButton)
80 | await new Promise(resolve => setTimeout(resolve, 10))
81 |
82 | const squares = await getSquares()
83 | const moves = [0, 1, 2, 4, 3, 5, 7, 6, 8]
84 |
85 | for (const move of moves) {
86 | fireEvent.click(squares[move])
87 | await new Promise(resolve => setTimeout(resolve, 10))
88 | }
89 |
90 | await waitFor(() => {
91 | expect(statusElement).toHaveTextContent(`Cat's game`)
92 | })
93 | })
94 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/01.solution.set-state-callback/index.css:
--------------------------------------------------------------------------------
1 | .game {
2 | font:
3 | 14px 'Century Gothic',
4 | Futura,
5 | sans-serif;
6 | margin: 20px;
7 | min-height: 260px;
8 | }
9 |
10 | .game ol,
11 | .game ul {
12 | padding-left: 30px;
13 | }
14 |
15 | .board-row:after {
16 | clear: both;
17 | content: '';
18 | display: table;
19 | }
20 |
21 | .status {
22 | margin-bottom: 10px;
23 | }
24 |
25 | .restart {
26 | margin-top: 10px;
27 | }
28 |
29 | .square {
30 | background: #fff;
31 | border: 1px solid #999;
32 | float: left;
33 | font-size: 24px;
34 | font-weight: bold;
35 | line-height: 34px;
36 | height: 34px;
37 | margin-right: -1px;
38 | margin-top: -1px;
39 | padding: 0;
40 | text-align: center;
41 | width: 34px;
42 | }
43 |
44 | .square:focus {
45 | outline: none;
46 | background: #ddd;
47 | }
48 |
49 | .game {
50 | display: flex;
51 | flex-direction: row;
52 | }
53 |
54 | .game-info {
55 | margin-left: 20px;
56 | min-width: 190px;
57 | }
58 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/01.solution.set-state-callback/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import {
4 | calculateNextValue,
5 | calculateStatus,
6 | calculateWinner,
7 | type Squares,
8 | } from '#shared/tic-tac-toe-utils'
9 |
10 | const defaultState = Array(9).fill(null)
11 |
12 | function Board() {
13 | const [squares, setSquares] = useState(defaultState)
14 |
15 | const nextValue = calculateNextValue(squares)
16 | const winner = calculateWinner(squares)
17 | const status = calculateStatus(winner, squares, nextValue)
18 |
19 | function selectSquare(index: number) {
20 | if (winner || squares[index]) return
21 | setSquares(previousSquares => previousSquares.with(index, nextValue))
22 | }
23 |
24 | function restart() {
25 | setSquares(defaultState)
26 | }
27 |
28 | function renderSquare(i: number) {
29 | return (
30 | selectSquare(i)}>
31 | {squares[i]}
32 |
33 | )
34 | }
35 |
36 | return (
37 |
38 |
{status}
39 |
40 | {renderSquare(0)}
41 | {renderSquare(1)}
42 | {renderSquare(2)}
43 |
44 |
45 | {renderSquare(3)}
46 | {renderSquare(4)}
47 | {renderSquare(5)}
48 |
49 |
50 | {renderSquare(6)}
51 | {renderSquare(7)}
52 | {renderSquare(8)}
53 |
54 |
55 | restart
56 |
57 |
58 | )
59 | }
60 |
61 | function App() {
62 | return (
63 |
68 | )
69 | }
70 |
71 | const rootEl = document.createElement('div')
72 | document.body.append(rootEl)
73 | createRoot(rootEl).render( )
74 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/02.problem.local-storage/README.mdx:
--------------------------------------------------------------------------------
1 | # Preserve State in localStorage
2 |
3 |
4 |
5 | 👨💼 Our customers want to be able to close the tab in the middle of a game and
6 | then resume the game later. Can you store the game's state in `localStorage`?
7 |
8 | I think you can! You're going to need to use `useEffect` to coordinate things
9 | with `localStorage`. Luckily for us, `localStorage` is synchronous, so
10 | initializing our state from `localStorage` is straightforward (you should
11 | definitely use the callback form of `useState` for initialization though!).
12 |
13 | For keeping the squares up-to-date in `localStorage`, you'll want to use
14 | `useEffect` with a dependency array that includes the squares.
15 |
16 | 📜 If you need to learn a bit about the `localStorage` API, you can check out the
17 | [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage).
18 |
19 |
20 | 🚨 Note this exercise depends on `localStorage` and so the tests could
21 | interfer with your work by changing the `localStorage` you're working with.
22 |
23 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/02.problem.local-storage/index.css:
--------------------------------------------------------------------------------
1 | .game {
2 | font:
3 | 14px 'Century Gothic',
4 | Futura,
5 | sans-serif;
6 | margin: 20px;
7 | min-height: 260px;
8 | }
9 |
10 | .game ol,
11 | .game ul {
12 | padding-left: 30px;
13 | }
14 |
15 | .board-row:after {
16 | clear: both;
17 | content: '';
18 | display: table;
19 | }
20 |
21 | .status {
22 | margin-bottom: 10px;
23 | }
24 |
25 | .restart {
26 | margin-top: 10px;
27 | }
28 |
29 | .square {
30 | background: #fff;
31 | border: 1px solid #999;
32 | float: left;
33 | font-size: 24px;
34 | font-weight: bold;
35 | line-height: 34px;
36 | height: 34px;
37 | margin-right: -1px;
38 | margin-top: -1px;
39 | padding: 0;
40 | text-align: center;
41 | width: 34px;
42 | }
43 |
44 | .square:focus {
45 | outline: none;
46 | background: #ddd;
47 | }
48 |
49 | .game {
50 | display: flex;
51 | flex-direction: row;
52 | }
53 |
54 | .game-info {
55 | margin-left: 20px;
56 | min-width: 190px;
57 | }
58 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/02.problem.local-storage/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import {
4 | calculateNextValue,
5 | calculateStatus,
6 | calculateWinner,
7 | type Squares,
8 | } from '#shared/tic-tac-toe-utils'
9 |
10 | const defaultState = Array(9).fill(null)
11 | // 🐨 create a variable for the key you'll use for storing the squares
12 | // 💰 'squares' should work well.
13 | function Board() {
14 | // 🐨 use the callback form for useState. The callback should:
15 | // 1. get the value from localStorage using the key you created above
16 | // 2. parse the JSON from that value
17 | // 3. return the parsed value (or the default value if there isn't one)
18 | // 💯 for extra credit, handle situations where the value doesn't exist or fails to parse
19 | const [squares, setSquares] = useState(Array(9).fill(null))
20 |
21 | // 🐨 add a useEffect here that updates the local storage value of the squares
22 | // 💰 you should stringify the squares using JSON.stringify because local storage only supports strings
23 |
24 | const nextValue = calculateNextValue(squares)
25 | const winner = calculateWinner(squares)
26 | const status = calculateStatus(winner, squares, nextValue)
27 |
28 | function selectSquare(index: number) {
29 | if (winner || squares[index]) return
30 | setSquares(previousSquares => previousSquares.with(index, nextValue))
31 | }
32 |
33 | function restart() {
34 | setSquares(defaultState)
35 | }
36 |
37 | function renderSquare(i: number) {
38 | return (
39 | selectSquare(i)}>
40 | {squares[i]}
41 |
42 | )
43 | }
44 |
45 | return (
46 |
47 |
{status}
48 |
49 | {renderSquare(0)}
50 | {renderSquare(1)}
51 | {renderSquare(2)}
52 |
53 |
54 | {renderSquare(3)}
55 | {renderSquare(4)}
56 | {renderSquare(5)}
57 |
58 |
59 | {renderSquare(6)}
60 | {renderSquare(7)}
61 | {renderSquare(8)}
62 |
63 |
64 | restart
65 |
66 |
67 | )
68 | }
69 |
70 | function App() {
71 | return (
72 |
77 | )
78 | }
79 |
80 | const rootEl = document.createElement('div')
81 | document.body.append(rootEl)
82 | createRoot(rootEl).render( )
83 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/02.solution.local-storage/README.mdx:
--------------------------------------------------------------------------------
1 | # Preserve State in localStorage
2 |
3 |
4 |
5 | 👨💼 Stellar work! Now our users can refresh the page and they'll be right back
6 | where they started...
7 |
8 | But wouldn't you know it... the users have another feature request for your job
9 | security 🥰
10 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/02.solution.local-storage/index.css:
--------------------------------------------------------------------------------
1 | .game {
2 | font:
3 | 14px 'Century Gothic',
4 | Futura,
5 | sans-serif;
6 | margin: 20px;
7 | min-height: 260px;
8 | }
9 |
10 | .game ol,
11 | .game ul {
12 | padding-left: 30px;
13 | }
14 |
15 | .board-row:after {
16 | clear: both;
17 | content: '';
18 | display: table;
19 | }
20 |
21 | .status {
22 | margin-bottom: 10px;
23 | }
24 |
25 | .restart {
26 | margin-top: 10px;
27 | }
28 |
29 | .square {
30 | background: #fff;
31 | border: 1px solid #999;
32 | float: left;
33 | font-size: 24px;
34 | font-weight: bold;
35 | line-height: 34px;
36 | height: 34px;
37 | margin-right: -1px;
38 | margin-top: -1px;
39 | padding: 0;
40 | text-align: center;
41 | width: 34px;
42 | }
43 |
44 | .square:focus {
45 | outline: none;
46 | background: #ddd;
47 | }
48 |
49 | .game {
50 | display: flex;
51 | flex-direction: row;
52 | }
53 |
54 | .game-info {
55 | margin-left: 20px;
56 | min-width: 190px;
57 | }
58 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/02.solution.local-storage/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import {
4 | calculateNextValue,
5 | calculateStatus,
6 | calculateWinner,
7 | type Squares,
8 | } from '#shared/tic-tac-toe-utils'
9 |
10 | const defaultState = Array(9).fill(null)
11 |
12 | const localStorageKey = 'squares'
13 | function Board() {
14 | const [squares, setSquares] = useState(() => {
15 | let localStorageValue
16 | try {
17 | localStorageValue = JSON.parse(
18 | window.localStorage.getItem(localStorageKey) ?? 'null',
19 | )
20 | } catch {
21 | // something is wrong in localStorage, so don't use it
22 | }
23 | return localStorageValue && Array.isArray(localStorageValue)
24 | ? localStorageValue
25 | : defaultState
26 | })
27 |
28 | useEffect(() => {
29 | window.localStorage.setItem(localStorageKey, JSON.stringify(squares))
30 | }, [squares])
31 |
32 | const nextValue = calculateNextValue(squares)
33 | const winner = calculateWinner(squares)
34 | const status = calculateStatus(winner, squares, nextValue)
35 |
36 | function selectSquare(index: number) {
37 | if (winner || squares[index]) return
38 | setSquares(previousSquares => previousSquares.with(index, nextValue))
39 | }
40 |
41 | function restart() {
42 | setSquares(defaultState)
43 | }
44 |
45 | function renderSquare(i: number) {
46 | return (
47 | selectSquare(i)}>
48 | {squares[i]}
49 |
50 | )
51 | }
52 |
53 | return (
54 |
55 |
{status}
56 |
57 | {renderSquare(0)}
58 | {renderSquare(1)}
59 | {renderSquare(2)}
60 |
61 |
62 | {renderSquare(3)}
63 | {renderSquare(4)}
64 | {renderSquare(5)}
65 |
66 |
67 | {renderSquare(6)}
68 | {renderSquare(7)}
69 | {renderSquare(8)}
70 |
71 |
72 | restart
73 |
74 |
75 | )
76 | }
77 |
78 | function App() {
79 | return (
80 |
85 | )
86 | }
87 |
88 | const rootEl = document.createElement('div')
89 | document.body.append(rootEl)
90 | createRoot(rootEl).render( )
91 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/02.solution.local-storage/local-storage.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test'
2 | const { screen, fireEvent, waitFor } = dtl
3 |
4 | const localStorageKey = 'squares'
5 | const initialState = ['X', null, 'O', null, 'X', null, null, null, null]
6 | window.localStorage.setItem(localStorageKey, JSON.stringify(initialState))
7 |
8 | // Dynamically import the game component
9 | await import('./index.tsx')
10 |
11 | await testStep('Game initializes from localStorage', async () => {
12 | await waitFor(() => {
13 | const squares = document.querySelectorAll('button.square')
14 | expect(squares[0]).toHaveTextContent('X')
15 | expect(squares[2]).toHaveTextContent('O')
16 | expect(squares[4]).toHaveTextContent('X')
17 | })
18 | })
19 |
20 | await testStep('Game updates localStorage after a move', async () => {
21 | // Make a move
22 | const squares = document.querySelectorAll('button.square')
23 | fireEvent.click(squares[1])
24 |
25 | // Verify localStorage is updated
26 | await waitFor(() => {
27 | const storedState = JSON.parse(
28 | window.localStorage.getItem(localStorageKey) || '[]',
29 | )
30 | expect(storedState).toEqual([
31 | 'X',
32 | 'O',
33 | 'O',
34 | null,
35 | 'X',
36 | null,
37 | null,
38 | null,
39 | null,
40 | ])
41 | })
42 | })
43 |
44 | await testStep('Restart button clears localStorage', async () => {
45 | const restartButton = await screen.findByRole('button', { name: /restart/i })
46 | fireEvent.click(restartButton)
47 |
48 | // Check if localStorage is cleared
49 | await waitFor(() => {
50 | const storedState = JSON.parse(
51 | window.localStorage.getItem(localStorageKey) || '[]',
52 | )
53 | expect(storedState).toEqual([
54 | null,
55 | null,
56 | null,
57 | null,
58 | null,
59 | null,
60 | null,
61 | null,
62 | null,
63 | ])
64 | })
65 |
66 | // Check if the board is reset
67 | const squares = document.querySelectorAll('button.square')
68 | expect(squares).toHaveLength(9)
69 | })
70 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/03.problem.history/README.mdx:
--------------------------------------------------------------------------------
1 | # Add Game History Feature
2 |
3 |
4 |
5 | 👨💼 Sometimes users are playing tic-tac-toe against their children and their kids
6 | want to change their move when they lose 😆 So we're going to add a feature that
7 | allows users to go backward through the game and change moves.
8 |
9 | As usual, you can check the solution to
10 | check what the final experience should be like.
11 |
12 | 🧝♂️ I made a couple changes you can check here if
13 | you'd like. I mostly just refactored things a bit to get it ready for your work.
14 | Should be a fun feature to add! Enjoy!
15 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/03.problem.history/index.css:
--------------------------------------------------------------------------------
1 | .game {
2 | font:
3 | 14px 'Century Gothic',
4 | Futura,
5 | sans-serif;
6 | margin: 20px;
7 | min-height: 260px;
8 | }
9 |
10 | .game ol,
11 | .game ul {
12 | padding-left: 30px;
13 | }
14 |
15 | .board-row:after {
16 | clear: both;
17 | content: '';
18 | display: table;
19 | }
20 |
21 | .status {
22 | margin-bottom: 10px;
23 | }
24 |
25 | .restart {
26 | margin-top: 10px;
27 | }
28 |
29 | .square {
30 | background: #fff;
31 | border: 1px solid #999;
32 | float: left;
33 | font-size: 24px;
34 | font-weight: bold;
35 | line-height: 34px;
36 | height: 34px;
37 | margin-right: -1px;
38 | margin-top: -1px;
39 | padding: 0;
40 | text-align: center;
41 | width: 34px;
42 | }
43 |
44 | .square:focus {
45 | outline: none;
46 | background: #ddd;
47 | }
48 |
49 | .game {
50 | display: flex;
51 | flex-direction: row;
52 | }
53 |
54 | .game-info {
55 | margin-left: 20px;
56 | min-width: 190px;
57 | }
58 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/03.solution.history/README.mdx:
--------------------------------------------------------------------------------
1 | # Add Game History Feature
2 |
3 |
4 |
5 | 👨💼 Hey, great job! Our users are really happy with what you've put together 👏
6 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/03.solution.history/index.css:
--------------------------------------------------------------------------------
1 | .game {
2 | font:
3 | 14px 'Century Gothic',
4 | Futura,
5 | sans-serif;
6 | margin: 20px;
7 | min-height: 260px;
8 | }
9 |
10 | .game ol,
11 | .game ul {
12 | padding-left: 30px;
13 | }
14 |
15 | .board-row:after {
16 | clear: both;
17 | content: '';
18 | display: table;
19 | }
20 |
21 | .status {
22 | margin-bottom: 10px;
23 | }
24 |
25 | .restart {
26 | margin-top: 10px;
27 | }
28 |
29 | .square {
30 | background: #fff;
31 | border: 1px solid #999;
32 | float: left;
33 | font-size: 24px;
34 | font-weight: bold;
35 | line-height: 34px;
36 | height: 34px;
37 | margin-right: -1px;
38 | margin-top: -1px;
39 | padding: 0;
40 | text-align: center;
41 | width: 34px;
42 | }
43 |
44 | .square:focus {
45 | outline: none;
46 | background: #ddd;
47 | }
48 |
49 | .game {
50 | display: flex;
51 | flex-direction: row;
52 | }
53 |
54 | .game-info {
55 | margin-left: 20px;
56 | min-width: 190px;
57 | }
58 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Tic Tac Toe
2 |
3 |
4 |
5 | 👨💼 Congratulations! You now have a great handle on `useState` and `useEffect`.
6 |
--------------------------------------------------------------------------------
/exercises/06.tic-tac-toe/README.mdx:
--------------------------------------------------------------------------------
1 | # Tic Tac Toe
2 |
3 |
4 |
5 | This exercise is just to give you more practice with `useState` and `useEffect`.
6 | For the most part, this is just review, but there is one thing we'll be doing
7 | in this exercise we haven't done yet with `useState` and that is, it can
8 | actually accept a function. For example:
9 |
10 | ```tsx
11 | const [count, setCount] = useState(0)
12 |
13 | // then in a click event handler or something:
14 | setCount(count + 1)
15 |
16 | // but this is the exact same thing:
17 | setCount(previousCount => previousCount + 1)
18 | ```
19 |
20 | Because there are two ways to do the same thing, it's nice to know why/when
21 | you'd use one over the other. Here's the "rule":
22 |
23 | **If your new value for state is calculated based on the previous value of
24 | state, use the function form. Otherwise, either works fine.**
25 |
26 | For a deeper dive on this, read
27 | [useState lazy initialization and function updates](https://kentcdodds.com/blog/use-state-lazy-initialization-and-function-updates).
28 |
29 | This one's a bit tougher than some of the other exercises, so make sure you're
30 | well hydrated and do a bit of stretching before starting this.
31 |
--------------------------------------------------------------------------------
/exercises/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # React Hooks 🎣
2 |
3 |
4 |
5 | 👨💼 You've done an awesome job. Now you understand the foundational hooks with
6 | React and you're prepared to learn some of the more advanced hooks that React
7 | has to offer. Great job!
8 |
--------------------------------------------------------------------------------
/exercises/README.mdx:
--------------------------------------------------------------------------------
1 | # React Hooks 🎣
2 |
3 |
4 |
5 | 👨💼 Hello! I'm Peter the Product manager and I'm here to help guide you through
6 | this workshop. I'll be there to tell you what our users want and to help you
7 | understand the requirements of the tasks you'll be working on.
8 |
9 | React hooks are a critical part of React development. You can think of them as
10 | atoms to the React component molecule. Everything outside of basic rendering
11 | can be done with hooks.
12 |
13 | In this workshop, we'll learn how to use hooks to manage state, synchronize
14 | side-effects, generate unique ids, and more. There's a lot to hooks and we'll
15 | get into more advanced use cases in a future workshop. In this one you'll get
16 | a solid understanding of the basic hooks and how to use them.
17 |
18 | So let's get started!
19 |
20 | 🎵 Check out the workshop theme song! 🎶
21 |
22 |
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-hooks",
3 | "private": true,
4 | "epicshop": {
5 | "title": "React Hooks 🎣",
6 | "subtitle": "Understand the building blocks of React",
7 | "githubRepo": "https://github.com/epicweb-dev/react-hooks",
8 | "stackBlitzConfig": {
9 | "view": "editor"
10 | },
11 | "product": {
12 | "host": "www.epicreact.dev",
13 | "slug": "react-hooks",
14 | "displayName": "EpicReact.dev",
15 | "displayNameShort": "Epic React",
16 | "logo": "/logo.svg",
17 | "discordChannelId": "1285244676286189569",
18 | "discordTags": [
19 | "1285246046498328627",
20 | "1285245663281549448"
21 | ]
22 | },
23 | "onboardingVideo": "https://www.epicweb.dev/tips/get-started-with-the-epic-workshop-app-for-react",
24 | "instructor": {
25 | "name": "Kent C. Dodds",
26 | "avatar": "/images/instructor.png",
27 | "𝕏": "kentcdodds"
28 | }
29 | },
30 | "type": "module",
31 | "imports": {
32 | "#*": "./*"
33 | },
34 | "scripts": {
35 | "postinstall": "cd ./epicshop && npm install",
36 | "start": "npx --prefix ./epicshop epicshop start",
37 | "dev": "npx --prefix ./epicshop epicshop start",
38 | "setup": "node ./epicshop/setup.js",
39 | "setup:custom": "node ./epicshop/setup-custom.js",
40 | "lint": "eslint .",
41 | "format": "prettier --write .",
42 | "typecheck": "tsc -b"
43 | },
44 | "keywords": [],
45 | "author": "Kent C. Dodds (https://kentcdodds.com/)",
46 | "license": "GPL-3.0-only",
47 | "dependencies": {
48 | "react": "19.0.0",
49 | "react-dom": "19.0.0",
50 | "vanilla-tilt": "^1.8.1"
51 | },
52 | "devDependencies": {
53 | "@epic-web/config": "^1.16.3",
54 | "@epic-web/workshop-utils": "^5.20.1",
55 | "@testing-library/user-event": "^14.5.2",
56 | "@types/react": "^19.0.0",
57 | "@types/react-dom": "^19.0.0",
58 | "eslint": "^9.16.0",
59 | "npm-run-all": "^4.1.5",
60 | "prettier": "^3.4.2",
61 | "typescript": "^5.7.2"
62 | },
63 | "engines": {
64 | "node": ">=20",
65 | "npm": ">=8.16.0",
66 | "git": ">=2.18.0"
67 | },
68 | "prettier": "@epic-web/config/prettier",
69 | "prettierIgnore": [
70 | "node_modules",
71 | "**/build/**",
72 | "**/public/build/**",
73 | ".env",
74 | "**/package.json",
75 | "**/tsconfig.json",
76 | "**/package-lock.json",
77 | "**/playwright-report/**"
78 | ]
79 | }
80 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/public/hook-flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/public/hook-flow.png
--------------------------------------------------------------------------------
/public/images/instructor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/public/images/instructor.png
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/public/og/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/public/og/background.png
--------------------------------------------------------------------------------
/public/react-app-lifecycle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/react-hooks/742b1a864d82f882b885dd6b20a6001a59848985/public/react-app-lifecycle.png
--------------------------------------------------------------------------------
/shared/tic-tac-toe-utils.tsx:
--------------------------------------------------------------------------------
1 | export type Player = 'X' | 'O'
2 | export type Squares = Array
3 |
4 | export type GameState = {
5 | history: Array
6 | currentStep: number
7 | }
8 |
9 | function isSquare(value: unknown): value is null | 'X' | 'O' {
10 | return value === null || value === 'X' || value === 'O'
11 | }
12 |
13 | function isArray(value: unknown): value is Array {
14 | return Array.isArray(value)
15 | }
16 |
17 | function isSquaresArray(value: unknown): value is Squares {
18 | if (!isArray(value)) return false
19 | return value.length === 9 && value.every(isSquare)
20 | }
21 |
22 | function isHistory(value: unknown): value is Array {
23 | if (!isArray(value)) return false
24 | if (!value.every(isSquaresArray)) return false
25 | return true
26 | }
27 |
28 | function isStep(value: unknown): value is number {
29 | return (
30 | typeof value === 'number' &&
31 | Number.isInteger(value) &&
32 | value >= 0 &&
33 | value <= 9
34 | )
35 | }
36 |
37 | export function isValidGameState(value: unknown): value is GameState {
38 | return (
39 | typeof value === 'object' &&
40 | value !== null &&
41 | isHistory((value as any).history) &&
42 | isStep((value as any).currentStep)
43 | )
44 | }
45 |
46 | export function calculateStatus(
47 | winner: null | string,
48 | squares: Squares,
49 | nextValue: Player,
50 | ) {
51 | return winner
52 | ? `Winner: ${winner}`
53 | : squares.every(Boolean)
54 | ? `Scratch: Cat's game`
55 | : `Next player: ${nextValue}`
56 | }
57 |
58 | export function calculateNextValue(squares: Squares): Player {
59 | const xSquaresCount = squares.filter(r => r === 'X').length
60 | const oSquaresCount = squares.filter(r => r === 'O').length
61 | return oSquaresCount === xSquaresCount ? 'X' : 'O'
62 | }
63 |
64 | export function calculateWinner(squares: Squares): Player | null {
65 | const lines = [
66 | [0, 1, 2],
67 | [3, 4, 5],
68 | [6, 7, 8],
69 | [0, 3, 6],
70 | [1, 4, 7],
71 | [2, 5, 8],
72 | [0, 4, 8],
73 | [2, 4, 6],
74 | ]
75 | for (let i = 0; i < lines.length; i++) {
76 | const line = lines[i]
77 | if (!line) continue
78 | const [a, b, c] = line
79 | if (a === undefined || b === undefined || c === undefined) continue
80 |
81 | const player = squares[a]
82 | if (player && player === squares[b] && player === squares[c]) {
83 | return player
84 | }
85 | }
86 | return null
87 | }
88 |
--------------------------------------------------------------------------------
/shared/utils.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Sets the search parameters of the current URL.
3 | *
4 | * @param {Record} params - The search parameters to set.
5 | * @param {Object} options - Additional options for setting the search parameters.
6 | * @param {boolean} options.replace - Whether to replace the current URL in the history or not.
7 | * @returns {URLSearchParams} - The updated search parameters.
8 | */
9 | export function setGlobalSearchParams(
10 | params: Record,
11 | options: { replace?: boolean } = {},
12 | ) {
13 | const searchParams = new URLSearchParams(window.location.search)
14 | for (const [key, value] of Object.entries(params)) {
15 | if (!value) searchParams.delete(key)
16 | else searchParams.set(key, value)
17 | }
18 | const newUrl = [window.location.pathname, searchParams.toString()]
19 | .filter(Boolean)
20 | .join('?')
21 | if (options.replace) {
22 | window.history.replaceState({}, '', newUrl)
23 | } else {
24 | window.history.pushState({}, '', newUrl)
25 | }
26 | return searchParams
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.tsx"],
3 | "extends": ["@epic-web/config/typescript"],
4 | "compilerOptions": {
5 | "lib": ["ES2023", "DOM"],
6 | // keep things easy for the exercises
7 | "noUncheckedIndexedAccess": false,
8 | "paths": {
9 | "#*": ["./*"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------