├── .gitattributes
├── epicshop
├── .diffignore
├── .npmrc
├── tsconfig.json
├── post-set-playground.js
├── in-browser-tests.spec.js
├── update-deps.sh
├── package.json
├── Dockerfile
├── setup-custom.js
├── playwright.config.js
├── fly.yaml
├── fix-watch.js
├── setup.js
└── fix.js
├── reset.d.ts
├── .npmrc
├── exercises
├── 04.slots
│ ├── 03.problem.prop
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── text-field.tsx
│ │ ├── app.tsx
│ │ ├── README.mdx
│ │ ├── slots.tsx
│ │ └── toggle.tsx
│ ├── 01.problem.context
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── text-field.tsx
│ │ ├── app.tsx
│ │ ├── slots.tsx
│ │ ├── toggle.tsx
│ │ └── README.mdx
│ ├── 01.solution.context
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── README.mdx
│ │ ├── text-field.tsx
│ │ ├── app.tsx
│ │ ├── slots.tsx
│ │ ├── toggle.tsx
│ │ └── index.test.tsx
│ ├── 02.problem.generic
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── text-field.tsx
│ │ ├── slots.tsx
│ │ ├── app.tsx
│ │ ├── README.mdx
│ │ └── toggle.tsx
│ ├── 02.solution.generic
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── README.mdx
│ │ ├── text-field.tsx
│ │ ├── app.tsx
│ │ ├── slots.tsx
│ │ ├── index.test.tsx
│ │ └── toggle.tsx
│ ├── 03.solution.prop
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── text-field.tsx
│ │ ├── README.mdx
│ │ ├── app.tsx
│ │ ├── toggle.tsx
│ │ ├── slots.tsx
│ │ └── index.test.tsx
│ ├── FINISHED.mdx
│ └── README.mdx
├── 05.prop-getters
│ ├── 02.problem.getters
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── app.tsx
│ │ ├── toggle.tsx
│ │ └── README.mdx
│ ├── 01.problem.collections
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── toggle.tsx
│ │ ├── app.tsx
│ │ └── README.mdx
│ ├── 01.solution.collections
│ │ ├── index.css
│ │ ├── toggle.test.tsx
│ │ ├── custom-button.test.tsx
│ │ ├── index.tsx
│ │ ├── toggle.tsx
│ │ ├── README.mdx
│ │ └── app.tsx
│ ├── 02.solution.getters
│ │ ├── index.css
│ │ ├── toggle.test.tsx
│ │ ├── custom-button.test.tsx
│ │ ├── index.tsx
│ │ ├── README.mdx
│ │ ├── app.tsx
│ │ ├── toggle.tsx
│ │ └── forwards-props.test.tsx
│ ├── FINISHED.mdx
│ └── README.mdx
├── 07.state-reducer
│ ├── 01.problem.reducer
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── README.mdx
│ │ ├── app.tsx
│ │ └── toggle.tsx
│ ├── 01.solution.reducer
│ │ ├── index.css
│ │ ├── toggle.test.tsx
│ │ ├── index.tsx
│ │ ├── README.mdx
│ │ ├── app.tsx
│ │ ├── toggle.tsx
│ │ └── click-limit.test.tsx
│ ├── 02.problem.default
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── README.mdx
│ │ ├── app.tsx
│ │ └── toggle.tsx
│ ├── 02.solution.default
│ │ ├── index.css
│ │ ├── toggle.test.tsx
│ │ ├── index.tsx
│ │ ├── exporting-toggle-reducer.test.tsx
│ │ ├── README.mdx
│ │ ├── app.tsx
│ │ ├── toggle.tsx
│ │ └── click-limit.test.tsx
│ ├── FINISHED.mdx
│ └── README.mdx
├── 08.control-props
│ ├── 01.problem.control
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── README.mdx
│ │ └── app.tsx
│ ├── 01.solution.control
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── README.mdx
│ │ ├── uncontrolled.test.tsx
│ │ ├── synchronized.test.tsx
│ │ ├── app.tsx
│ │ └── toggle.tsx
│ ├── FINISHED.mdx
│ └── README.mdx
├── 03.compound-components
│ ├── 01.problem.context
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── app.tsx
│ │ ├── toggle.tsx
│ │ └── README.mdx
│ ├── 01.solution.context
│ │ ├── index.css
│ │ ├── toggle.test.tsx
│ │ ├── index.tsx
│ │ ├── app.tsx
│ │ ├── README.mdx
│ │ └── toggle.tsx
│ ├── 02.problem.validation
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── app.tsx
│ │ ├── README.mdx
│ │ └── toggle.tsx
│ ├── 02.solution.validation
│ │ ├── index.css
│ │ ├── toggle.test.tsx
│ │ ├── index.tsx
│ │ ├── app.tsx
│ │ ├── README.mdx
│ │ ├── toggle.tsx
│ │ └── validation.test.tsx
│ ├── FINISHED.mdx
│ └── README.mdx
├── 06.state-initializers
│ ├── 01.problem.initial
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── app.tsx
│ │ ├── README.mdx
│ │ └── toggle.tsx
│ ├── 01.solution.initial
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── README.mdx
│ │ ├── app.tsx
│ │ ├── index.test.tsx
│ │ └── toggle.tsx
│ ├── 02.problem.stability
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── app.tsx
│ │ ├── README.mdx
│ │ └── toggle.tsx
│ ├── 02.solution.stability
│ │ ├── index.css
│ │ ├── README.mdx
│ │ ├── index.tsx
│ │ ├── app.tsx
│ │ ├── toggle.tsx
│ │ └── index.test.tsx
│ ├── FINISHED.mdx
│ └── README.mdx
├── 02.latest-ref
│ ├── FINISHED.mdx
│ ├── 01.solution.ref
│ │ ├── README.mdx
│ │ ├── increments.test.tsx
│ │ ├── step-change.test.tsx
│ │ └── index.tsx
│ └── 01.problem.ref
│ │ ├── index.tsx
│ │ └── README.mdx
├── FINISHED.mdx
├── 01.composition
│ ├── FINISHED.mdx
│ ├── 01.solution.compose
│ │ ├── README.mdx
│ │ ├── ui-still-works.test.tsx
│ │ ├── index.css
│ │ └── index.tsx
│ ├── 01.problem.compose
│ │ ├── README.mdx
│ │ ├── index.css
│ │ └── index.tsx
│ └── README.mdx
└── README.mdx
├── public
├── favicon.ico
├── img
│ ├── ski.png
│ ├── kody.png
│ ├── soccer.png
│ ├── onewheel.png
│ └── snowboard.png
├── og
│ └── background.png
├── images
│ └── instructor.png
├── manifest.json
├── favicon.svg
├── switch.styles.css
└── logo.svg
├── .vscode
├── extensions.json
└── settings.kcd.json
├── tsconfig.json
├── LICENSE.md
├── shared
├── types.d.ts
├── switch.tsx
├── utils.tsx
├── sports.tsx
└── toggle.test.tsx
├── .gitignore
├── eslint.config.js
├── .github
└── workflows
│ └── validate.yml
├── package.json
└── README.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/epicshop/.diffignore:
--------------------------------------------------------------------------------
1 | tsconfig.json
2 | *.test.*
--------------------------------------------------------------------------------
/reset.d.ts:
--------------------------------------------------------------------------------
1 | import '@epic-web/config/reset.d.ts'
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 | registry=https://registry.npmjs.org/
3 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.problem.prop/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/epicshop/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 | registry=https://registry.npmjs.org/
3 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.problem.context/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.solution.context/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.problem.generic/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.solution.generic/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.solution.prop/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.problem.getters/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.problem.collections/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.solution.collections/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.solution.getters/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.problem.reducer/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.solution.reducer/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.problem.default/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.solution.default/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.problem.control/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.solution.control/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.problem.context/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.solution.context/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.problem.validation/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.problem.initial/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.solution.initial/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.problem.stability/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.solution.stability/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.solution.validation/index.css:
--------------------------------------------------------------------------------
1 | @import '/switch.styles.css';
2 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-patterns/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/img/ski.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-patterns/HEAD/public/img/ski.png
--------------------------------------------------------------------------------
/public/img/kody.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-patterns/HEAD/public/img/kody.png
--------------------------------------------------------------------------------
/public/img/soccer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-patterns/HEAD/public/img/soccer.png
--------------------------------------------------------------------------------
/public/img/onewheel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-patterns/HEAD/public/img/onewheel.png
--------------------------------------------------------------------------------
/public/img/snowboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-patterns/HEAD/public/img/snowboard.png
--------------------------------------------------------------------------------
/public/og/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-patterns/HEAD/public/og/background.png
--------------------------------------------------------------------------------
/public/images/instructor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/advanced-react-patterns/HEAD/public/images/instructor.png
--------------------------------------------------------------------------------
/epicshop/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.tsx", "**/*.js"],
3 | "extends": ["@epic-web/config/typescript"]
4 | }
5 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.solution.getters/toggle.test.tsx:
--------------------------------------------------------------------------------
1 | import { verifySimpleToggle } from '#shared/toggle.test.tsx'
2 | import '.'
3 |
4 | await verifySimpleToggle()
5 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.solution.reducer/toggle.test.tsx:
--------------------------------------------------------------------------------
1 | import { verifySimpleToggle } from '#shared/toggle.test.tsx'
2 | import '.'
3 |
4 | await verifySimpleToggle()
5 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.solution.default/toggle.test.tsx:
--------------------------------------------------------------------------------
1 | import { verifySimpleToggle } from '#shared/toggle.test.tsx'
2 | import '.'
3 |
4 | await verifySimpleToggle()
5 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode",
5 | "VisualStudioExptTeam.vscodeintellicode"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.solution.collections/toggle.test.tsx:
--------------------------------------------------------------------------------
1 | import { verifySimpleToggle } from '#shared/toggle.test.tsx'
2 | import '.'
3 |
4 | await verifySimpleToggle()
5 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.solution.context/toggle.test.tsx:
--------------------------------------------------------------------------------
1 | import { verifySimpleToggleWithText } from '#shared/toggle.test.tsx'
2 | import '.'
3 |
4 | await verifySimpleToggleWithText()
5 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.solution.validation/toggle.test.tsx:
--------------------------------------------------------------------------------
1 | import { verifySimpleToggleWithText } from '#shared/toggle.test.tsx'
2 | import '.'
3 |
4 | await verifySimpleToggleWithText()
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.tsx"],
3 | "extends": ["@epic-web/config/typescript"],
4 | "compilerOptions": {
5 | "paths": {
6 | "#shared/*": ["./shared/*"]
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/exercises/02.latest-ref/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Latest Ref
2 |
3 |
4 |
5 | 👨💼 Great job! You now know the latest ref pattern.
6 |
--------------------------------------------------------------------------------
/exercises/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Advanced React Patterns 🤯
2 |
3 |
4 |
5 | 👨💼 Congratulations. You've made it to the end. Awesome work!
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.problem.context/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.solution.context/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.problem.generic/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.solution.generic/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.problem.prop/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.solution.prop/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/exercises/01.composition/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Composition
2 |
3 |
4 |
5 | 👨💼 Great work! You now know how to create layout components to reduce prop
6 | drilling!
7 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.solution.getters/custom-button.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/dom'
2 | import { verifyIsToggle } from '#shared/toggle.test.tsx'
3 | import '.'
4 |
5 | await verifyIsToggle(await screen.findByLabelText('custom-button'))
6 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.problem.collections/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.solution.collections/custom-button.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/dom'
2 | import { verifyIsToggle } from '#shared/toggle.test.tsx'
3 | import '.'
4 |
5 | await verifyIsToggle(await screen.findByLabelText('custom-button'))
6 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.problem.getters/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.solution.getters/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.solution.stability/README.mdx:
--------------------------------------------------------------------------------
1 | # Stability
2 |
3 |
4 |
5 | 👨💼 Great. Now we've got some stability with our state initializer. Well done!
6 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.problem.reducer/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.solution.reducer/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.problem.default/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.solution.default/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.problem.control/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.solution.control/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.problem.context/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.solution.context/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.solution.collections/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.problem.initial/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.solution.initial/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.problem.stability/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.solution.stability/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.problem.validation/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.solution.validation/index.tsx:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom/client'
2 | import { App } from './app.tsx'
3 |
4 | const rootEl = document.createElement('div')
5 | document.body.append(rootEl)
6 | ReactDOM.createRoot(rootEl).render( )
7 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.solution.context/README.mdx:
--------------------------------------------------------------------------------
1 | # Slot Context
2 |
3 |
4 |
5 | 👨💼 That's a great start! Now we have that in place, I think we can use this in
6 | our toggle component!
7 |
--------------------------------------------------------------------------------
/exercises/04.slots/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Slots
2 |
3 |
4 |
5 | 👨💼 This is a really nice pattern if you find yourself building a library of
6 | components that have a lot of common components. Great job!
7 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # State Reducer
2 |
3 |
4 |
5 | 👨💼 Great job! You've learned one of the simplest yet most powerful inversion of
6 | control tricks in the React biz.
7 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.solution.default/exporting-toggle-reducer.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { toggleReducer } from './toggle.tsx'
3 |
4 | await testStep('toggleReducer is exported', () => {
5 | expect(toggleReducer).to.be.a('function')
6 | })
7 |
--------------------------------------------------------------------------------
/shared/types.d.ts:
--------------------------------------------------------------------------------
1 | type SportData = {
2 | id: string
3 | name: string
4 | image: string
5 | color: string
6 | tricks: Array<{
7 | name: string
8 | type: string
9 | points: number
10 | }>
11 | }
12 |
13 | type User = { name: string; image: string }
14 |
15 | export { SportData, User }
16 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.solution.initial/README.mdx:
--------------------------------------------------------------------------------
1 | # Initialize Toggle
2 |
3 |
4 |
5 | 👨💼 Great job! Now we can initilize and reset the toggle component! But we've
6 | discovered a bug. Let's look at that next.
7 |
--------------------------------------------------------------------------------
/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/update-deps.sh:
--------------------------------------------------------------------------------
1 | npx npm-check-updates --dep prod,dev --upgrade --root
2 | cd epicshop && npx npm-check-updates --dep prod,dev --upgrade --root
3 | cd ..
4 | rm -rf node_modules package-lock.json ./epicshop/package-lock.json ./epicshop/node_modules ./exercises/**/node_modules
5 | npm install
6 | npm run setup
7 | npm run typecheck
8 | npm run lint --fix
9 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.solution.generic/README.mdx:
--------------------------------------------------------------------------------
1 | # Generic Slot Components
2 |
3 |
4 |
5 | 👨💼 Great! Now we can reuse the `Label` component in our set of `Toggle` compound
6 | components. Let's go a step further with a generic `Text` component.
7 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.solution.collections/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | export function useToggle() {
4 | const [on, setOn] = useState(false)
5 | const toggle = () => setOn(!on)
6 |
7 | return {
8 | on,
9 | toggle,
10 | togglerProps: {
11 | 'aria-checked': on,
12 | onClick: toggle,
13 | },
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | workspace/
4 | **/.cache/
5 | **/build/
6 | **/public/build
7 | **/playwright-report
8 | **/test-results
9 | data.db
10 | /playground
11 | **/tsconfig.tsbuildinfo
12 |
13 | # in a real app you'd want to not commit the .env
14 | # file as well, but since this is for a workshop
15 | # we're going to keep them around.
16 | # .env
17 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.solution.collections/README.mdx:
--------------------------------------------------------------------------------
1 | # Prop Collections
2 |
3 |
4 |
5 | 👨💼 Prop collections are great for handling common use cases for your hooks, but
6 | there's a subtle but important limitation with them that we should address next.
7 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.problem.context/app.tsx:
--------------------------------------------------------------------------------
1 | import { Toggle, ToggleOn, ToggleOff, ToggleButton } from './toggle.tsx'
2 |
3 | export function App() {
4 | return (
5 |
6 |
7 | The button is on
8 | The button is off
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.solution.context/app.tsx:
--------------------------------------------------------------------------------
1 | import { Toggle, ToggleOn, ToggleOff, ToggleButton } from './toggle.tsx'
2 |
3 | export function App() {
4 | return (
5 |
6 |
7 | The button is on
8 | The button is off
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.problem.validation/app.tsx:
--------------------------------------------------------------------------------
1 | import { Toggle, ToggleButton, ToggleOff, ToggleOn } from './toggle.tsx'
2 |
3 | export function App() {
4 | return (
5 |
6 |
7 | The button is on
8 | The button is off
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.solution.validation/app.tsx:
--------------------------------------------------------------------------------
1 | import { Toggle, ToggleButton, ToggleOff, ToggleOn } from './toggle.tsx'
2 |
3 | export function App() {
4 | return (
5 |
6 |
7 | The button is on
8 | The button is off
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Advanced React Patterns",
3 | "name": "Advanced React Patterns",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#1675ff",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Prop Collections and Getters
2 |
3 |
4 |
5 | 👨💼 Great work! This is a highly flexible pattern that gives consumers a great
6 | deal of control over how the component is rendered while also handling common
7 | cases with ease.
8 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.problem.collections/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | export function useToggle() {
4 | const [on, setOn] = useState(false)
5 | const toggle = () => setOn(!on)
6 |
7 | // 🐨 Add a property called `togglerProps`. It should be an object that has
8 | // `aria-checked` and `onClick` properties.
9 | return { on, toggle, togglerProps: {} }
10 | }
11 |
--------------------------------------------------------------------------------
/exercises/08.control-props/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Control Props
2 |
3 |
4 |
5 | 👨💼 Great work! You now know how you can allow developers to control your
6 | internal state by using control props. What makes this tricky is supporting both
7 | controlled and uncontrolled and you managed it. Good job!
8 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import defaultConfig from '@epic-web/config/eslint'
2 |
3 | /** @type {import("eslint").Linter.Config} */
4 | export default [
5 | { ignores: ['**/babel-standalone.js'] },
6 | ...defaultConfig,
7 | {
8 | rules: {
9 | // we leave unused vars around for the exercises
10 | 'no-unused-vars': 'off',
11 | '@typescript-eslint/no-unused-vars': 'off',
12 | },
13 | },
14 | ]
15 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.solution.initial/app.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from '#shared/switch.tsx'
2 | import { useToggle } from './toggle.tsx'
3 |
4 | export function App() {
5 | const { on, getTogglerProps, reset } = useToggle({ initialOn: true })
6 | return (
7 |
8 |
9 |
10 | Reset
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/epicshop/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "scripts": {
4 | "start": "cd .. && npm run start",
5 | "test": "playwright test"
6 | },
7 | "dependencies": {
8 | "@epic-web/config": "^1.16.3",
9 | "@epic-web/workshop-app": "^6.47.1",
10 | "@epic-web/workshop-utils": "^6.47.1",
11 | "@playwright/test": "^1.49.0",
12 | "cross-env": "^7.0.3",
13 | "epicshop": "^6.47.1"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.problem.collections/app.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from '#shared/switch.tsx'
2 | import { useToggle } from './toggle.tsx'
3 |
4 | export function App() {
5 | const { on, togglerProps } = useToggle()
6 | return (
7 |
8 |
9 |
10 |
11 | {on ? 'on' : 'off'}
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.solution.collections/app.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from '#shared/switch.tsx'
2 | import { useToggle } from './toggle.tsx'
3 |
4 | export function App() {
5 | const { on, togglerProps } = useToggle()
6 | return (
7 |
8 |
9 |
10 |
11 | {on ? 'on' : 'off'}
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.solution.getters/README.mdx:
--------------------------------------------------------------------------------
1 | # Prop Getters
2 |
3 |
4 |
5 | 👨💼 Great, now users don't have to worry about whether they're overriding us or
6 | we're overriding them and everything can be an implementation detail which we
7 | can change as needed without worrying about breaking people's expectations.
8 | Composition FTW!
9 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.solution.reducer/README.mdx:
--------------------------------------------------------------------------------
1 | # State Reducer
2 |
3 |
4 |
5 | 👨💼 This is a powerful example of inversion of control that allows users to
6 | overwrite our entire reducer. But it could be exhausting to users to have to
7 | duplicate most of our reducer just to change a few things. So let's address that
8 | common scenario next.
9 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.solution.context/README.mdx:
--------------------------------------------------------------------------------
1 | # Compound Components
2 |
3 |
4 |
5 | 👨💼 This is an extremely powerful feature that gives us nice and declarative APIs
6 | for our reusable components. However, it's possible people could be using it
7 | wrong so let's explore a way to help people avoid using our API incorrectly
8 | next.
9 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.solution.control/README.mdx:
--------------------------------------------------------------------------------
1 | # Control Props
2 |
3 |
4 |
5 | 👨💼 Phew! That was a tough one! But it's extremely powerful. It wouldn't be hard
6 | if we just wanted to lift the state. What makes it hard is wanting to have a
7 | single component that can not only manage its own state, but also have that
8 | state be managed externally as well.
9 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.solution.context/text-field.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { SlotContext } from './slots'
3 |
4 | export function TextField({
5 | id,
6 | children,
7 | }: {
8 | id?: string
9 | children: React.ReactNode
10 | }) {
11 | const generatedId = useId()
12 | id ??= generatedId
13 |
14 | const slots = {
15 | label: { htmlFor: id },
16 | input: { id },
17 | }
18 |
19 | return {children}
20 | }
21 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.problem.generic/text-field.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { SlotContext } from './slots'
3 |
4 | export function TextField({
5 | id,
6 | children,
7 | }: {
8 | id?: string
9 | children: React.ReactNode
10 | }) {
11 | const generatedId = useId()
12 | id ??= generatedId
13 |
14 | const slots = {
15 | label: { htmlFor: id },
16 | input: { id },
17 | }
18 |
19 | return {children}
20 | }
21 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.solution.generic/text-field.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { SlotContext } from './slots'
3 |
4 | export function TextField({
5 | id,
6 | children,
7 | }: {
8 | id?: string
9 | children: React.ReactNode
10 | }) {
11 | const generatedId = useId()
12 | id ??= generatedId
13 |
14 | const slots = {
15 | label: { htmlFor: id },
16 | input: { id },
17 | }
18 |
19 | return {children}
20 | }
21 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.problem.prop/text-field.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { SlotContext } from './slots'
3 |
4 | export function TextField({
5 | id,
6 | children,
7 | }: {
8 | id?: string
9 | children: React.ReactNode
10 | }) {
11 | const generatedId = useId()
12 | id ??= generatedId
13 |
14 | const slots = {
15 | label: { htmlFor: id },
16 | input: { id },
17 | }
18 |
19 | return {children}
20 | }
21 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.solution.prop/text-field.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { SlotContext } from './slots'
3 |
4 | export function TextField({
5 | id,
6 | children,
7 | }: {
8 | id?: string
9 | children: React.ReactNode
10 | }) {
11 | const generatedId = useId()
12 | id ??= generatedId
13 |
14 | const slots = {
15 | label: { htmlFor: id },
16 | input: { id },
17 | }
18 |
19 | return {children}
20 | }
21 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # Compound Components
2 |
3 |
4 |
5 | 👨💼 Great work! You now know how to create one of the best composition APIs for
6 | UI component libraries. The vast majority of component libraries employ this
7 | pattern and even if you don't build your own, you'll be much better able to use
8 | these libraries because you understand how they work. Good job!
9 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.problem.control/README.mdx:
--------------------------------------------------------------------------------
1 | # Control Props
2 |
3 |
4 |
5 | 👨💼 In this exercise, we've created a ` ` component which can accept a
6 | prop called `on` and another called `onChange`. These work similar to the
7 | `value` and `onChange` props of ` `. Your job is to make those props
8 | actually control the state of `on` and call the `onChange` with the suggested
9 | changes.
10 |
--------------------------------------------------------------------------------
/exercises/01.composition/01.solution.compose/README.mdx:
--------------------------------------------------------------------------------
1 | # Composition and Layout Components
2 |
3 |
4 |
5 | 👨💼 In this one we didn't actually change any visual behavior (the test was
6 | passing before your changes). But we hopefully demonstrated how restructuring
7 | your components can make it easier to maintain and help you avoid the prop
8 | drilling problem and reduce the amount you feel you need to use context.
9 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.solution.validation/README.mdx:
--------------------------------------------------------------------------------
1 | # Compound Components Validation
2 |
3 |
4 |
5 | 👨💼 Runtime validation isn't the best (it would be better if we could enforce
6 | this statically via TypeScript), but unfortunately it's the best we can do with
7 | the composition model offered by React. That said, it's unlikely people will
8 | mess this up now that we have this runtime validation in place.
9 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.problem.context/text-field.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 |
3 | export function TextField({
4 | id,
5 | children,
6 | }: {
7 | id?: string
8 | children: React.ReactNode
9 | }) {
10 | const generatedId = useId()
11 | id ??= generatedId
12 |
13 | // 🐨 create a slots object that has props for both label and input slots
14 | // 💰 the label should provide an htmlFor prop and the input should provide an id
15 |
16 | // 🐨 wrap this in a SlotContext with the value set to the slots object
17 | return children
18 | }
19 |
--------------------------------------------------------------------------------
/exercises/02.latest-ref/01.solution.ref/README.mdx:
--------------------------------------------------------------------------------
1 | # Latest Ref
2 |
3 |
4 |
5 | 👨💼 Great work. If you've got a bit more time and you haven't already read this
6 | post, I suggest you give these a read:
7 |
8 | - [How React Uses Closures to Avoid Bugs](https://epicreact.dev/how-react-uses-closures-to-avoid-bugs)
9 | - [The Latest Ref Pattern in React](https://epicreact.dev/the-latest-ref-pattern-in-react)
10 |
11 | With great power comes great responsibility.
12 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.solution.prop/README.mdx:
--------------------------------------------------------------------------------
1 | # Slot Prop
2 |
3 |
4 |
5 | 👨💼 Great! Now we can use the same component for multiple slots!
6 |
7 | One thing to note is that there's not a great way to have good type safety on
8 | these props, though if you're creative, you could add nice runtime errors,
9 | [for example](https://github.com/adobe/react-spectrum/blob/3db98d2d378f977a88d94e9f2501feca8ef8ce51/packages/react-aria-components/src/utils.tsx#L169-L181).
10 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/FINISHED.mdx:
--------------------------------------------------------------------------------
1 | # State Initializer
2 |
3 |
4 |
5 | 👨💼 Great job! You now know what to do to properly handle the initialization (and
6 | reset) of your state.
7 |
8 | 🦉 Keep in mind that the `key` prop can also be used as a way to reset the state
9 | of a component, and it works pretty well. However it does require that
10 | everything is unmounted and remounted, which may not be what you want depending
11 | on the situation.
12 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.solution.getters/app.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from '#shared/switch.tsx'
2 | import { useToggle } from './toggle.tsx'
3 |
4 | export function App() {
5 | const { on, getTogglerProps } = useToggle()
6 | return (
7 |
8 |
9 |
10 | console.info('onButtonClick'),
14 | id: 'custom-button-id',
15 | })}
16 | >
17 | {on ? 'on' : 'off'}
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.problem.initial/app.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from '#shared/switch.tsx'
2 | import { useToggle } from './toggle.tsx'
3 |
4 | export function App() {
5 | // 🐨 add an initialOn option (set it to true) and get the reset callback from useToggle
6 | const { on, getTogglerProps } = useToggle()
7 | // 💣 delete this reset callback in favor of what you get from useToggle
8 | const reset = () => {}
9 | return (
10 |
11 |
12 |
13 | Reset
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.problem.stability/app.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 | import { useToggle } from './toggle.tsx'
4 |
5 | export function App() {
6 | const [initialOn, setInitialOn] = useState(true)
7 | const { on, getTogglerProps, reset } = useToggle({ initialOn })
8 | return (
9 |
10 | setInitialOn(o => !o)}>
11 | initialOn is: {initialOn ? 'true' : 'false'}
12 |
13 |
14 |
15 | Reset
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.solution.stability/app.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 | import { useToggle } from './toggle.tsx'
4 |
5 | export function App() {
6 | const [initialOn, setInitialOn] = useState(true)
7 | const { on, getTogglerProps, reset } = useToggle({ initialOn })
8 | return (
9 |
10 | setInitialOn(o => !o)}>
11 | initialOn is: {initialOn ? 'true' : 'false'}
12 |
13 |
14 |
15 | Reset
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.solution.prop/app.tsx:
--------------------------------------------------------------------------------
1 | import { Input, Label, Switch, Text } from './slots.tsx'
2 | import { TextField } from './text-field.tsx'
3 | import { Toggle } from './toggle.tsx'
4 |
5 | export function App() {
6 | return (
7 |
8 |
9 |
10 | Party mode
11 |
12 | Let's party 🥳
13 | Sad town 😭
14 |
15 |
16 |
17 |
18 |
19 | Venue
20 |
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.solution.generic/app.tsx:
--------------------------------------------------------------------------------
1 | import { Input, Label } from './slots.tsx'
2 | import { TextField } from './text-field.tsx'
3 | import { Toggle, ToggleButton, ToggleOff, ToggleOn } from './toggle.tsx'
4 |
5 | export function App() {
6 | return (
7 |
8 |
9 |
10 | Party mode
11 |
12 | Let's party 🥳
13 | Sad town 😭
14 |
15 |
16 |
17 |
18 |
19 | Venue
20 |
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.solution.control/uncontrolled.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render, screen } from '@testing-library/react'
3 | import { userEvent } from '@testing-library/user-event'
4 | import { Toggle } from './toggle.tsx'
5 |
6 | await testStep('can render Toggle with no props', () => {
7 | render( )
8 | })
9 |
10 | await testStep('toggle still operates like a toggle', async () => {
11 | const toggle = screen.getByRole('switch')
12 | expect(toggle).to.have.attr('aria-checked', 'false')
13 | await userEvent.click(toggle)
14 | expect(toggle).to.have.attr('aria-checked', 'true')
15 | })
16 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.problem.initial/README.mdx:
--------------------------------------------------------------------------------
1 | # Initialize Toggle
2 |
3 |
4 |
5 | 👨💼 Our toggle component should be able to be customizable for the initial state
6 | and reset to the initial state.
7 |
8 | 🧝♂️ I've updated the toggle component to use a reducer instead of `useState`. If
9 | you'd like to back up, and do that yourself in the playground, be my guest. Or
10 | you can check my work instead.
11 |
12 | 👨💼 Please add a case in our reducer for the `reset` logic, and add an option to
13 | our `useToggle` hook for setting the initialOn state.
14 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.problem.getters/app.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from '#shared/switch.tsx'
2 | import { useToggle } from './toggle.tsx'
3 |
4 | export function App() {
5 | // 💣 delete this:
6 | const getTogglerProps = (props: any) => props
7 | // 🐨 destructure the getTogglerProps function from useToggle
8 | const { on } = useToggle()
9 | return (
10 |
11 |
12 |
13 | console.info('onButtonClick'),
17 | id: 'custom-button-id',
18 | })}
19 | >
20 | {on ? 'on' : 'off'}
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.solution.context/app.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { Input, Label } from './slots.tsx'
3 | import { TextField } from './text-field.tsx'
4 | import { Toggle, ToggleButton, ToggleOff, ToggleOn } from './toggle.tsx'
5 |
6 | export function App() {
7 | const partyModeId = useId()
8 | return (
9 |
10 |
11 |
12 | Party mode
13 |
14 | Let's party 🥳
15 | Sad town 😭
16 |
17 |
18 |
19 |
20 |
21 | Venue
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.problem.getters/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | export function useToggle() {
4 | const [on, setOn] = useState(false)
5 | const toggle = () => setOn(!on)
6 |
7 | // 🐨 create a function called getTogglerProps that accepts an object
8 | // of props and returns an object of props that includes 'aria-checked' and onClick.
9 |
10 | // 💰 Make sure to handle the case where the user provides their own
11 | // 'aria-checked' and 'onClick' props (as well as if they don't or if they
12 | // provide more props).
13 |
14 | return {
15 | on,
16 | toggle,
17 | // 🐨 swap togglerProps with getTogglerProps
18 | togglerProps: {
19 | 'aria-checked': on,
20 | onClick: toggle,
21 | },
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.solution.getters/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | function callAll>(
4 | ...fns: Array<((...args: Args) => unknown) | undefined>
5 | ) {
6 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
7 | }
8 |
9 | export function useToggle() {
10 | const [on, setOn] = useState(false)
11 | const toggle = () => setOn(!on)
12 |
13 | function getTogglerProps({
14 | onClick,
15 | ...props
16 | }: {
17 | onClick?: React.ComponentProps<'button'>['onClick']
18 | } & Props) {
19 | return {
20 | 'aria-checked': on,
21 | onClick: callAll(onClick, toggle),
22 | ...props,
23 | }
24 | }
25 |
26 | return {
27 | on,
28 | toggle,
29 | getTogglerProps,
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.solution.default/README.mdx:
--------------------------------------------------------------------------------
1 | # Default State Reducer
2 |
3 |
4 |
5 | 👨💼 It's a pretty simple, yet powerful solution. Inversion of control with good
6 | defaults allows our component users to have a lot more power and will reduce the
7 | amount of feature requests you get for these components by a lot, I promise.
8 |
9 | 🦉 An important thing to note is that once you do this your internal state
10 | becomes a part of your public API. So if you change the name of an internal
11 | state variable, then that will be a breaking change for your users. So be
12 | careful with this.
13 |
14 | 🕷 With great power comes great responsibility.
15 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.problem.prop/app.tsx:
--------------------------------------------------------------------------------
1 | import { Input, Label } from './slots.tsx'
2 | import { TextField } from './text-field.tsx'
3 | import { Toggle, ToggleButton, ToggleOff, ToggleOn } from './toggle.tsx'
4 |
5 | export function App() {
6 | return (
7 |
8 |
9 |
10 | Party mode
11 | {/* 🐨 switch this for the Switch slot component */}
12 |
13 | {/* 🐨 change these to the Text slot component with appropriate slot props */}
14 | Let's party 🥳
15 | Sad town 😭
16 |
17 |
18 |
19 |
20 |
21 | Venue
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.problem.default/README.mdx:
--------------------------------------------------------------------------------
1 | # Default State Reducer
2 |
3 |
4 |
5 | 👨💼 Our `toggleReducer` is pretty simple, so it's not a huge pain for people to
6 | implement their own. However, in a more realistic scenario, people may struggle
7 | with having to basically re-implement our entire reducer which could be pretty
8 | complex.
9 |
10 | So for the this step, we're going to `export` the default reducer so people can
11 | use that inside their own reducers as needed.
12 |
13 | Go ahead and do this by changing the `toggleStateReducer` function inside the
14 | ` ` example to use the default reducer instead of having to re-implement
15 | what to do when the action type is `'reset'`.
16 |
--------------------------------------------------------------------------------
/epicshop/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:24-bookworm-slim as base
2 |
3 | RUN apt-get update && apt-get install -y git
4 |
5 | ENV EPICSHOP_GITHUB_REPO=https://github.com/epicweb-dev/advanced-react-patterns
6 | ENV EPICSHOP_CONTEXT_CWD="/myapp/workshop-content"
7 | ENV EPICSHOP_HOME_DIR="/myapp/.epicshop"
8 | ENV EPICSHOP_DEPLOYED="true"
9 | ENV EPICSHOP_DISABLE_WATCHER="true"
10 | ENV FLY="true"
11 | ENV PORT="8080"
12 | ENV NODE_ENV="production"
13 |
14 | WORKDIR /myapp
15 |
16 | # Clone the workshop repo during build time, excluding database files
17 | RUN git clone --depth 1 ${EPICSHOP_GITHUB_REPO} ${EPICSHOP_CONTEXT_CWD}
18 |
19 | ADD . .
20 |
21 | RUN npm install --omit=dev
22 |
23 | RUN cd ${EPICSHOP_CONTEXT_CWD} && \
24 | npx epicshop warm
25 |
26 | CMD cd ${EPICSHOP_CONTEXT_CWD} && \
27 | npx epicshop start
28 |
--------------------------------------------------------------------------------
/exercises/01.composition/01.problem.compose/README.mdx:
--------------------------------------------------------------------------------
1 | # Composition and Layout Components
2 |
3 |
4 |
5 | 👨💼 In this exercise we've got a simple user interface with several components
6 | necessitating passing state and updaters around. We're going to restructure
7 | things so we pass react elements instead of state and updaters. We might be
8 | going a _tiny_ bit overboard, but the goal is for this to be instructive for
9 | you.
10 |
11 | By the time you're done, the whole UI should look and function exactly as
12 | before, but you'll get a sense for how to use this pattern. The tests will be
13 | there just for you to verify you haven't broken anything that should be working
14 | if you want to use them.
15 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.solution.prop/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useId, useState } from 'react'
2 | import { SlotContext } from './slots'
3 |
4 | export function Toggle({
5 | id,
6 | children,
7 | }: {
8 | id?: string
9 | children: React.ReactNode
10 | }) {
11 | const [on, setOn] = useState(false)
12 | const generatedId = useId()
13 | id ??= generatedId
14 |
15 | const toggle = () => setOn(!on)
16 |
17 | const labelProps = { htmlFor: id }
18 | const onTextProps = { hidden: on ? undefined : true }
19 | const offTextProps = { hidden: on ? true : undefined }
20 | const switchProps = { id, on, onClick: toggle }
21 |
22 | return (
23 |
31 | {children}
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.problem.generic/slots.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use } from 'react'
2 |
3 | type Slots = Record>
4 | export const SlotContext = createContext({})
5 |
6 | function useSlotProps(props: Props, slot: string): Props {
7 | const slots = use(SlotContext)
8 |
9 | // a more proper "mergeProps" function is in order here
10 | // to handle things like merging event handlers better.
11 | // we'll get to that a bit in a later exercise.
12 | return { ...slots[slot], slot, ...props } as Props
13 | }
14 |
15 | export function Label(props: React.ComponentProps<'label'>) {
16 | props = useSlotProps(props, 'label')
17 | return
18 | }
19 |
20 | export function Input(props: React.ComponentProps<'input'>) {
21 | props = useSlotProps(props, 'input')
22 | return
23 | }
24 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.solution.context/slots.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use } from 'react'
2 |
3 | type Slots = Record>
4 | export const SlotContext = createContext({})
5 |
6 | function useSlotProps(props: Props, slot: string): Props {
7 | const slots = use(SlotContext)
8 |
9 | // a more proper "mergeProps" function is in order here
10 | // to handle things like merging event handlers better.
11 | // we'll get to that a bit in a later exercise.
12 | return { ...slots[slot], slot, ...props } as Props
13 | }
14 |
15 | export function Label(props: React.ComponentProps<'label'>) {
16 | props = useSlotProps(props, 'label')
17 | return
18 | }
19 |
20 | export function Input(props: React.ComponentProps<'input'>) {
21 | props = useSlotProps(props, 'input')
22 | return
23 | }
24 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.solution.generic/slots.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use } from 'react'
2 |
3 | type Slots = Record>
4 | export const SlotContext = createContext({})
5 |
6 | function useSlotProps(props: Props, slot: string): Props {
7 | const slots = use(SlotContext)
8 |
9 | // a more proper "mergeProps" function is in order here
10 | // to handle things like merging event handlers better.
11 | // we'll get to that a bit in a later exercise.
12 | return { ...slots[slot], slot, ...props } as Props
13 | }
14 |
15 | export function Label(props: React.ComponentProps<'label'>) {
16 | props = useSlotProps(props, 'label')
17 | return
18 | }
19 |
20 | export function Input(props: React.ComponentProps<'input'>) {
21 | props = useSlotProps(props, 'input')
22 | return
23 | }
24 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.solution.getters/forwards-props.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { screen, waitFor } from '@testing-library/dom'
3 | import { userEvent } from '@testing-library/user-event'
4 | import '.'
5 |
6 | const customButton = await testStep('Custom button is rendred', () =>
7 | screen.findByLabelText('custom-button'),
8 | )
9 | await testStep('Custom button has id attribute', async () => {
10 | expect(customButton).to.have.attr('id', 'custom-button-id')
11 | })
12 |
13 | const consoleInfo = console.info
14 | const infos = []
15 | console.info = (...args) => infos.push(args)
16 |
17 | await userEvent.click(customButton)
18 |
19 | await testStep(
20 | 'Clicking the custom-button calls the onClick handler which calls console.info',
21 | () => waitFor(() => infos.length === 1),
22 | )
23 |
24 | console.info = consoleInfo
25 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.problem.generic/app.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { Input, Label } from './slots.tsx'
3 | import { TextField } from './text-field.tsx'
4 | import { Toggle, ToggleButton, ToggleOff, ToggleOn } from './toggle.tsx'
5 |
6 | export function App() {
7 | // 💣 delete this variable
8 | const partyModeId = useId()
9 | return (
10 |
11 |
12 |
13 | {/* 🐨 switch this label for the Label component from ./slots.tsx */}
14 | Party mode
15 | {/* 🐨 remove this id prop */}
16 |
17 | Let's party 🥳
18 | Sad town 😭
19 |
20 |
21 |
22 |
23 |
24 | Venue
25 |
26 |
27 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.problem.context/app.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { Input, Label } from './slots.tsx'
3 | import { TextField } from './text-field.tsx'
4 | import { Toggle, ToggleButton, ToggleOff, ToggleOn } from './toggle.tsx'
5 |
6 | export function App() {
7 | const partyModeId = useId()
8 | return (
9 |
10 |
11 |
12 | Party mode
13 |
14 | Let's party 🥳
15 | Sad town 😭
16 |
17 |
18 |
19 |
20 | {/* 🦉 feel free to test the id customization by passing an id here */}
21 |
22 | {/* 🦉 feel free to test the prop merging by passing props here */}
23 | Venue
24 |
25 |
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/01.problem.collections/README.mdx:
--------------------------------------------------------------------------------
1 | # Prop Collections
2 |
3 |
4 |
5 | 🧝♂️ I've removed the work we did with the Slot pattern and we're also going to be
6 | focusing the next few exercises on the hook. So we've simplified things a little
7 | bit to give us a better focus on the hook.
8 |
9 | 👨💼 In our simple example, this isn't too much for folks to remember, but in more
10 | complex components, the list of props that need to be applied to elements can be
11 | extensive, so it can be a good idea to take the common use cases for our hook
12 | and/or components and make objects of props that people can simply spread across
13 | the UI they render.
14 |
15 | In this exercise you need to create a `togglerProps` object that has all the
16 | props people would typically need applied to a toggle button.
17 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.problem.context/slots.tsx:
--------------------------------------------------------------------------------
1 | // 🦺 create a Slots type that's just an object of objects
2 | // 💰 type Slots = Record>
3 | // 🐨 create and export a SlotContext with that type and default it to an empty object
4 |
5 | // 🐨 create a useSlotProps hook which:
6 | // 1. accepts props (any type) and slot (string)
7 | // 2. gets the slots from the SlotContext
8 | // 3. gets the props from the slot by its name
9 | // 4. returns the merged props with the slot and given props
10 |
11 | export function Label(props: React.ComponentProps<'label'>) {
12 | // 🐨 get the props from useSlotProps for a slot called "label" and apply those to the label
13 | return
14 | }
15 |
16 | export function Input(props: React.ComponentProps<'input'>) {
17 | // 🐨 get the props from useSlotProps for a slot called "input" and apply those to the input
18 | return
19 | }
20 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.problem.generic/README.mdx:
--------------------------------------------------------------------------------
1 | # Generic Slot Components
2 |
3 |
4 |
5 | 👨💼 You'll notice our party mode toggle button is using `useId` to properly
6 | associate the toggle button with its label. We'd like to make that implicit and
7 | reuse the `Label` component for the `Toggle` as well.
8 |
9 | Please update the `Toggle` component in to
10 | render a `SlotContext` provider (in addition to the `ToggleContext` provider
11 | it's already rendering) so it can provide props for a `label` slot (the slot
12 | name for a `Label`). You'll also want to put the `id` in the `ToggleContext` so
13 | the `ToggleButton` can grab it.
14 |
15 | Once you're finished with that, you can remove the manual `id`/`htmlFor` props
16 | in the file and the `id` should be provided
17 | automatically.
18 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.problem.stability/README.mdx:
--------------------------------------------------------------------------------
1 | # Stability
2 |
3 |
4 |
5 | 👨💼 We've noticed that if someone passes an `initialOn` that's based on state,
6 | then calling `reset` will sometimes change the state to the wrong value based on
7 | what the `initialOn` was set to at the time the component was initialized.
8 |
9 | This is confusing and we want to make certain to avoid it.
10 |
11 | 🧝♂️ I've put together a simple example of this for you to experiment with. You'll
12 | notice we now have a button for toggling the `initialOn` state which we pass as
13 | an option to `useToggle`. So if you toggle the `initialOn` state and then click
14 | the reset button, you'll notice it resets to the current `initialOn` state, not
15 | the original one.
16 |
17 | 👨💼 This is a little confusing for users of the `useToggle` hook, so please fix
18 | this issue! Thanks!
19 |
--------------------------------------------------------------------------------
/exercises/README.mdx:
--------------------------------------------------------------------------------
1 | # Advanced React Patterns 🤯
2 |
3 |
4 |
5 | 👨💼 Hello, my name is Peter the Product Manager. I'm here to help you get
6 | oriented and to give you your assignments for this workshop!
7 |
8 | We're going to cover a lot of ground in this workshop. Our users have some very
9 | specific requirements of us. In this workshop you'll be playing the role of a
10 | product developer in some exercises and a component library author in others.
11 |
12 | Many of the patterns you'll be learning are useful in both roles. Though you'll
13 | find most of these patterns are more useful when building reusable abstractions.
14 |
15 | Let's get started!
16 |
17 | 🎵 Check out the workshop theme song! 🎶
18 |
19 |
23 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.problem.reducer/README.mdx:
--------------------------------------------------------------------------------
1 | # State Reducer
2 |
3 |
4 |
5 | 👨💼 In this exercise, we want to prevent the toggle from updating the toggle
6 | state after it's been clicked 4 times in a row before resetting. We could easily
7 | add that logic to our reducer, but instead we're going to apply a computer
8 | science pattern called "Inversion of Control" where we effectively say: "Here
9 | you go! You have complete control over how this thing works. It's now your
10 | responsibility."
11 |
12 | Your job is to enable people to provide a custom `reducer` so they can have
13 | complete control over how state updates happen in our ` ` component.
14 |
15 |
16 | As an aside, before React Hooks were a thing, this was pretty tricky to
17 | implement and resulted in pretty weird code, but with useReducer, this is WAY
18 | better. I ❤️ hooks. 😍
19 |
20 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/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 { warm } from 'epicshop/warm'
8 | import fsExtra from 'fs-extra'
9 |
10 | await warm()
11 |
12 | const allApps = await getApps()
13 | const problemApps = allApps.filter(isProblemApp)
14 |
15 | if (!process.env.SKIP_PLAYGROUND) {
16 | const firstProblemApp = problemApps[0]
17 | if (firstProblemApp) {
18 | console.log('🛝 setting up the first problem app...')
19 | const playgroundPath = path.join(process.cwd(), 'playground')
20 | if (await fsExtra.exists(playgroundPath)) {
21 | console.log('🗑 deleting existing playground app')
22 | await fsExtra.remove(playgroundPath)
23 | }
24 | await setPlayground(firstProblemApp.fullPath).then(
25 | () => {
26 | console.log('✅ first problem app set up')
27 | },
28 | (error) => {
29 | console.error(error)
30 | throw new Error('❌ first problem app setup failed')
31 | },
32 | )
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/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 | workers: process.env.CI ? 1 : undefined,
14 | outputDir: path.join(tmpDir, 'playwright-test-output'),
15 | reporter: [
16 | [
17 | 'html',
18 | { open: 'never', outputFolder: path.join(tmpDir, 'playwright-report') },
19 | ],
20 | ],
21 | use: {
22 | baseURL: `http://localhost:${PORT}/`,
23 | trace: 'retain-on-failure',
24 | },
25 |
26 | projects: [
27 | {
28 | name: 'chromium',
29 | use: { ...devices['Desktop Chrome'] },
30 | },
31 | ],
32 |
33 | webServer: {
34 | command: 'cd .. && npm start',
35 | port: Number(PORT),
36 | reuseExistingServer: !process.env.CI,
37 | stdout: 'pipe',
38 | stderr: 'pipe',
39 | env: { PORT },
40 | },
41 | })
42 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.solution.initial/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render, screen } from '@testing-library/react'
3 | import { userEvent } from '@testing-library/user-event'
4 | import { App } from './app.tsx'
5 |
6 | await testStep('can render the app', () => {
7 | render( )
8 | })
9 |
10 | await testStep('Toggle is rendered and initially on', async () => {
11 | const toggleElement = await screen.findByRole('switch')
12 | expect(toggleElement).toBeChecked()
13 | })
14 |
15 | await testStep('Toggle can be turned off', async () => {
16 | const toggleElement = await screen.findByRole('switch')
17 | await userEvent.click(toggleElement)
18 | expect(toggleElement).not.toBeChecked()
19 | })
20 |
21 | await testStep('Clicking reset turns the toggle back on', async () => {
22 | const resetButton = await screen.findByRole('button', { name: /reset/i })
23 | await userEvent.click(resetButton)
24 | const toggleElement = await screen.findByRole('switch')
25 | expect(toggleElement).toBeChecked()
26 | })
27 |
--------------------------------------------------------------------------------
/exercises/02.latest-ref/01.solution.ref/increments.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { waitFor, within } from '@testing-library/dom'
3 | import { userEvent } from '@testing-library/user-event'
4 | import '.'
5 |
6 | const screen = within(document.body)
7 | const button = await testStep('The counter button should start at 0', () =>
8 | screen.findByRole('button', { name: '0' }),
9 | )
10 |
11 | await testStep('The spinbutton (Step) value should start at 1', async () => {
12 | const spinButton = await screen.findByRole('spinbutton', { name: /step/i })
13 | expect(spinButton).to.have.value('1')
14 | })
15 |
16 | await userEvent.click(button)
17 | await testStep(
18 | 'The count should not change until the debounce period is over',
19 | () => {
20 | expect(button).to.have.text('0')
21 | },
22 | )
23 |
24 | await testStep(
25 | 'After the debounce period the count should be updated by the step amount of 1',
26 | () =>
27 | waitFor(
28 | () => {
29 | expect(button).to.have.text('1')
30 | },
31 | { timeout: 3100 },
32 | ),
33 | )
34 |
--------------------------------------------------------------------------------
/exercises/02.latest-ref/01.solution.ref/step-change.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { fireEvent, waitFor, within } from '@testing-library/dom'
3 | import { userEvent } from '@testing-library/user-event'
4 | import '.'
5 |
6 | const screen = within(document.body)
7 |
8 | const button = await testStep('The counter button should start at 0', () =>
9 | screen.findByRole('button', { name: '0' }),
10 | )
11 |
12 | const step = await testStep(
13 | 'The spinbutton (Step) value should start at 1',
14 | async () => {
15 | const spinButton = await screen.findByRole('spinbutton', { name: /step/i })
16 | expect(spinButton).to.have.value('1')
17 | return spinButton
18 | },
19 | )
20 |
21 | await userEvent.click(button)
22 | fireEvent.change(step, { target: { value: '2' } })
23 | await userEvent.click(button)
24 |
25 | await testStep(
26 | `Clicking the button then increasing the step before the timer runs out should make the count increase by the new step value.`,
27 | () =>
28 | waitFor(() => expect(button).to.have.text('2'), {
29 | timeout: 3100,
30 | }),
31 | )
32 |
--------------------------------------------------------------------------------
/shared/switch.tsx:
--------------------------------------------------------------------------------
1 | // STOP! You should not have to change anything in this file to
2 | // make it through the workshop. If tests are failing because of
3 | // this switch not having properties set correctly, then the
4 | // problem is probably in your implementation. Tip: Check
5 | // your `render` method or the `getTogglerProps` method
6 | // (if we've gotten to that part)
7 |
8 | export function Switch({
9 | on,
10 | className = '',
11 | 'aria-label': ariaLabel,
12 | onClick,
13 | ...props
14 | }: { on: boolean } & React.ComponentProps<'button'>) {
15 | const btnClassName = [
16 | className,
17 | 'toggle-btn',
18 | on ? 'toggle-btn-on' : 'toggle-btn-off',
19 | ]
20 | .filter(Boolean)
21 | .join(' ')
22 |
23 | return (
24 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/epicshop/fly.yaml:
--------------------------------------------------------------------------------
1 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
2 | #
3 |
4 | app: 'epicweb-dev-advanced-react-patterns'
5 | primary_region: sjc
6 | kill_signal: SIGINT
7 | kill_timeout: 5s
8 | swap_size_mb: 512
9 |
10 | experimental:
11 | auto_rollback: true
12 |
13 | attached:
14 | secrets: {}
15 |
16 | services:
17 | - processes:
18 | - app
19 | protocol: tcp
20 | internal_port: 8080
21 |
22 | ports:
23 | - port: 80
24 |
25 | handlers:
26 | - http
27 | force_https: true
28 | - port: 443
29 |
30 | handlers:
31 | - tls
32 | - http
33 |
34 | concurrency:
35 | type: connections
36 | hard_limit: 100
37 | soft_limit: 80
38 |
39 | tcp_checks:
40 | - interval: 15s
41 | timeout: 2s
42 | grace_period: 1s
43 |
44 | http_checks:
45 | - interval: 10s
46 | timeout: 2s
47 | grace_period: 5s
48 | method: get
49 | path: /resources/healthcheck
50 | protocol: http
51 | tls_skip_verify: false
52 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.solution.default/app.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 | import { toggleReducer, useToggle } from './toggle.tsx'
4 |
5 | export function App() {
6 | const [timesClicked, setTimesClicked] = useState(0)
7 | const clickedTooMuch = timesClicked >= 4
8 |
9 | const { on, getTogglerProps, getResetterProps } = useToggle({
10 | reducer(state, action) {
11 | if (action.type === 'toggle' && clickedTooMuch) {
12 | return state
13 | }
14 | return toggleReducer(state, action)
15 | },
16 | })
17 |
18 | return (
19 |
20 |
setTimesClicked(count => count + 1),
24 | })}
25 | />
26 | {clickedTooMuch ? (
27 |
28 | Whoa, you clicked too much!
29 |
30 |
31 | ) : timesClicked > 0 ? (
32 | Click count: {timesClicked}
33 | ) : null}
34 | setTimesClicked(0) })}>
35 | Reset
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.solution.context/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use, useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 |
4 | type ToggleValue = { on: boolean; toggle: () => void }
5 | const ToggleContext = createContext(null)
6 |
7 | export function Toggle({ children }: { children: React.ReactNode }) {
8 | const [on, setOn] = useState(false)
9 | const toggle = () => setOn(!on)
10 |
11 | return {children}
12 | }
13 |
14 | export function ToggleOn({ children }: { children: React.ReactNode }) {
15 | const { on } = use(ToggleContext)!
16 | return <>{on ? children : null}>
17 | }
18 |
19 | export function ToggleOff({ children }: { children: React.ReactNode }) {
20 | const { on } = use(ToggleContext)!
21 | return <>{on ? null : children}>
22 | }
23 |
24 | type ToggleButtonProps = Omit, 'on'> & {
25 | on?: boolean
26 | }
27 | export function ToggleButton({ ...props }: ToggleButtonProps) {
28 | const { on, toggle } = use(ToggleContext)!
29 | return
30 | }
31 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.solution.control/synchronized.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render, screen } from '@testing-library/react'
3 | import { userEvent } from '@testing-library/user-event'
4 | import { App } from './app.tsx'
5 |
6 | await testStep('can render the app', () => {
7 | render( )
8 | })
9 |
10 | const [toggleButton1, toggleButton2] = await testStep(
11 | 'both buttons are rendered',
12 | () => {
13 | const buttons = screen.getAllByRole('switch')
14 | if (buttons.length < 2) {
15 | throw new Error('Could not find both toggle buttons')
16 | }
17 | return buttons as [HTMLElement, HTMLElement]
18 | },
19 | )
20 |
21 | await testStep('clicking the first button toggles both', async () => {
22 | await userEvent.click(toggleButton1)
23 | expect(toggleButton1).to.have.attr('aria-checked', 'true')
24 | expect(toggleButton2).to.have.attr('aria-checked', 'true')
25 | })
26 |
27 | await testStep('clicking the second button toggles both', async () => {
28 | await userEvent.click(toggleButton2)
29 | expect(toggleButton1).to.have.attr('aria-checked', 'false')
30 | expect(toggleButton2).to.have.attr('aria-checked', 'false')
31 | })
32 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.problem.validation/README.mdx:
--------------------------------------------------------------------------------
1 | # Compound Components Validation
2 |
3 |
4 |
5 | 👨💼 Change to this (temporarily):
6 |
7 | ```tsx
8 | import { ToggleButton } from './toggle'
9 |
10 | export const App = () =>
11 | ```
12 |
13 | Why doesn't that work (it's not supposed to, but can you explain why)? Can you
14 | figure out a way to give the developer a better error message that explains what
15 | they're doing wrong and how to fix it?
16 |
17 | 🚨 The tests will tell you in the message for the error you must throw when the
18 | context is undefined.
19 |
20 | 🦺 Additionally, this is where we can make TypeScript happier (TypeScript knew
21 | about the problem we'd run into in this step of the exercise!). Remember,
22 | TypeScript isn't making your life terrible. It's just showing you how terrible
23 | your life already is 😂 In this exercise, we're going to make our lives better.
24 |
25 | (You can go ahead and undo the change to if you'd
26 | like. The tests will let you know that you've gotten it right).
27 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.solution.reducer/app.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 | import { useToggle } from './toggle.tsx'
4 |
5 | export function App() {
6 | const [timesClicked, setTimesClicked] = useState(0)
7 | const clickedTooMuch = timesClicked >= 4
8 |
9 | const { on, getTogglerProps, getResetterProps } = useToggle({
10 | reducer(state, action) {
11 | switch (action.type) {
12 | case 'toggle': {
13 | if (clickedTooMuch) {
14 | return state
15 | }
16 | return { on: !state.on }
17 | }
18 | case 'reset': {
19 | return { on: false }
20 | }
21 | }
22 | },
23 | })
24 |
25 | return (
26 |
27 |
setTimesClicked(count => count + 1),
31 | })}
32 | />
33 | {clickedTooMuch ? (
34 |
35 | Whoa, you clicked too much!
36 |
37 |
38 | ) : timesClicked > 0 ? (
39 | Click count: {timesClicked}
40 | ) : null}
41 | setTimesClicked(0) })}>
42 | Reset
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.problem.reducer/app.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 | import { useToggle } from './toggle.tsx'
4 |
5 | export function App() {
6 | const [timesClicked, setTimesClicked] = useState(0)
7 | const clickedTooMuch = timesClicked >= 4
8 |
9 | const { on, getTogglerProps, getResetterProps } = useToggle({
10 | // 🐨 create a reducer function here that accepts the state and action
11 | // It should do almost the same thing the regular reducer does in
12 | // ./toggle.tsx except in the action.type === 'toggle' case, it should check
13 | // whether the toggle has been clicked too much and if it has then it should
14 | // just return the state rather than make a new state object.
15 | })
16 |
17 | return (
18 |
19 |
setTimesClicked(count => count + 1),
23 | })}
24 | />
25 | {clickedTooMuch ? (
26 |
27 | Whoa, you clicked too much!
28 |
29 |
30 | ) : timesClicked > 0 ? (
31 | Click count: {timesClicked}
32 | ) : null}
33 | setTimesClicked(0) })}>
34 | Reset
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.problem.control/app.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Toggle, type ToggleAction, type ToggleState } from './toggle.tsx'
3 |
4 | export function App() {
5 | const [bothOn, setBothOn] = useState(false)
6 | const [timesClicked, setTimesClicked] = useState(0)
7 |
8 | function handleToggleChange(state: ToggleState, action: ToggleAction) {
9 | if (action.type === 'toggle' && timesClicked > 4) {
10 | return
11 | }
12 | setBothOn(state.on)
13 | setTimesClicked(c => c + 1)
14 | }
15 |
16 | function handleResetClick() {
17 | setBothOn(false)
18 | setTimesClicked(0)
19 | }
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | {timesClicked > 4 ? (
28 |
29 | Whoa, you clicked too much!
30 |
31 |
32 | ) : (
33 |
Click count: {timesClicked}
34 | )}
35 |
Reset
36 |
37 |
38 |
Uncontrolled Toggle:
39 |
41 | console.info('Uncontrolled Toggle onChange', ...args)
42 | }
43 | />
44 |
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.solution.control/app.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Toggle, type ToggleAction, type ToggleState } from './toggle.tsx'
3 |
4 | export function App() {
5 | const [bothOn, setBothOn] = useState(false)
6 | const [timesClicked, setTimesClicked] = useState(0)
7 |
8 | function handleToggleChange(state: ToggleState, action: ToggleAction) {
9 | if (action.type === 'toggle' && timesClicked > 4) {
10 | return
11 | }
12 | setBothOn(state.on)
13 | setTimesClicked(c => c + 1)
14 | }
15 |
16 | function handleResetClick() {
17 | setBothOn(false)
18 | setTimesClicked(0)
19 | }
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | {timesClicked > 4 ? (
28 |
29 | Whoa, you clicked too much!
30 |
31 |
32 | ) : (
33 |
Click count: {timesClicked}
34 | )}
35 |
Reset
36 |
37 |
38 |
Uncontrolled Toggle:
39 |
41 | console.info('Uncontrolled Toggle onChange', ...args)
42 | }
43 | />
44 |
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.problem.context/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use, useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 |
4 | type ToggleValue = { on: boolean; toggle: () => void }
5 | const ToggleContext = createContext(null)
6 |
7 | export function Toggle({ children }: { children: React.ReactNode }) {
8 | const [on, setOn] = useState(false)
9 | const toggle = () => setOn(!on)
10 |
11 | return {children}
12 | }
13 |
14 | function useToggle() {
15 | const context = use(ToggleContext)
16 | if (!context) {
17 | throw new Error(
18 | 'Cannot find ToggleContext. All Toggle components must be rendered within ',
19 | )
20 | }
21 | return context
22 | }
23 |
24 | export function ToggleOn({ children }: { children: React.ReactNode }) {
25 | const { on } = useToggle()
26 | return <>{on ? children : null}>
27 | }
28 |
29 | export function ToggleOff({ children }: { children: React.ReactNode }) {
30 | const { on } = useToggle()
31 | return <>{on ? null : children}>
32 | }
33 |
34 | type ToggleButtonProps = Omit, 'on'> & {
35 | on?: boolean
36 | }
37 | export function ToggleButton({ ...props }: ToggleButtonProps) {
38 | const { on, toggle } = useToggle()
39 | return
40 | }
41 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.solution.context/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use, useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 |
4 | type ToggleValue = { on: boolean; toggle: () => void }
5 | const ToggleContext = createContext(null)
6 |
7 | export function Toggle({ children }: { children: React.ReactNode }) {
8 | const [on, setOn] = useState(false)
9 | const toggle = () => setOn(!on)
10 |
11 | return {children}
12 | }
13 |
14 | function useToggle() {
15 | const context = use(ToggleContext)
16 | if (!context) {
17 | throw new Error(
18 | 'Cannot find ToggleContext. All Toggle components must be rendered within ',
19 | )
20 | }
21 | return context
22 | }
23 |
24 | export function ToggleOn({ children }: { children: React.ReactNode }) {
25 | const { on } = useToggle()
26 | return <>{on ? children : null}>
27 | }
28 |
29 | export function ToggleOff({ children }: { children: React.ReactNode }) {
30 | const { on } = useToggle()
31 | return <>{on ? null : children}>
32 | }
33 |
34 | type ToggleButtonProps = Omit, 'on'> & {
35 | on?: boolean
36 | }
37 | export function ToggleButton({ ...props }: ToggleButtonProps) {
38 | const { on, toggle } = useToggle()
39 | return
40 | }
41 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.solution.validation/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use, useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 |
4 | type ToggleValue = { on: boolean; toggle: () => void }
5 | const ToggleContext = createContext(null)
6 |
7 | export function Toggle({ children }: { children: React.ReactNode }) {
8 | const [on, setOn] = useState(false)
9 | const toggle = () => setOn(!on)
10 |
11 | return {children}
12 | }
13 |
14 | function useToggle() {
15 | const context = use(ToggleContext)
16 | if (!context) {
17 | throw new Error(
18 | 'Cannot find ToggleContext. All Toggle components must be rendered within ',
19 | )
20 | }
21 | return context
22 | }
23 |
24 | export function ToggleOn({ children }: { children: React.ReactNode }) {
25 | const { on } = useToggle()
26 | return <>{on ? children : null}>
27 | }
28 |
29 | export function ToggleOff({ children }: { children: React.ReactNode }) {
30 | const { on } = useToggle()
31 | return <>{on ? null : children}>
32 | }
33 |
34 | type ToggleButtonProps = Omit, 'on'> & {
35 | on?: boolean
36 | }
37 | export function ToggleButton({ ...props }: ToggleButtonProps) {
38 | const { on, toggle } = useToggle()
39 | return
40 | }
41 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.solution.initial/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer } from 'react'
2 |
3 | function callAll>(
4 | ...fns: Array<((...args: Args) => unknown) | undefined>
5 | ) {
6 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
7 | }
8 |
9 | type ToggleState = { on: boolean }
10 | type ToggleAction =
11 | | { type: 'toggle' }
12 | | { type: 'reset'; initialState: ToggleState }
13 |
14 | function toggleReducer(state: ToggleState, action: ToggleAction) {
15 | switch (action.type) {
16 | case 'toggle': {
17 | return { on: !state.on }
18 | }
19 | case 'reset': {
20 | return action.initialState
21 | }
22 | }
23 | }
24 |
25 | export function useToggle({ initialOn = false } = {}) {
26 | const initialState = { on: initialOn }
27 | const [state, dispatch] = useReducer(toggleReducer, initialState)
28 | const { on } = state
29 |
30 | const toggle = () => dispatch({ type: 'toggle' })
31 | const reset = () => dispatch({ type: 'reset', initialState })
32 |
33 | function getTogglerProps({
34 | onClick,
35 | ...props
36 | }: {
37 | onClick?: React.ComponentProps<'button'>['onClick']
38 | } & Props) {
39 | return {
40 | 'aria-checked': on,
41 | onClick: callAll(onClick, toggle),
42 | ...props,
43 | }
44 | }
45 |
46 | return {
47 | on,
48 | reset,
49 | toggle,
50 | getTogglerProps,
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/exercises/01.composition/01.solution.compose/ui-still-works.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { within } from '@testing-library/dom'
3 | import { userEvent } from '@testing-library/user-event'
4 | import '.'
5 |
6 | const screen = within(document.body)
7 |
8 | const nav = await testStep('The nav should be rendered', () =>
9 | screen.findByRole('navigation'),
10 | )
11 | const userSettingsLink = await testStep(
12 | 'The user settings link should be rendered in the nav',
13 | () =>
14 | within(nav).findByRole('link', {
15 | description: /user settings/i,
16 | }),
17 | )
18 | await testStep(
19 | 'The user avatar should be rendered in the user settings link',
20 | () => within(userSettingsLink).findByRole('img', { name: /kody/i }),
21 | )
22 |
23 | // verify the user info is in the footer
24 | await testStep(
25 | 'The greeting and username should be rendered in the footer',
26 | async () =>
27 | expect(await screen.findByRole('contentinfo')).to.have.text(
28 | `Don't have a good day–have a great day, Kody`,
29 | ),
30 | )
31 |
32 | // verify selecting a sport updates the detail info
33 | await testStep('Clicking a sport should update the detail info', async () => {
34 | const floaterButton = await screen.findByRole('button', { name: /floater/i })
35 | await userEvent.click(floaterButton)
36 | await screen.findByText(/space tornado/i)
37 | })
38 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.solution.stability/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer, useRef } from 'react'
2 |
3 | function callAll>(
4 | ...fns: Array<((...args: Args) => unknown) | undefined>
5 | ) {
6 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
7 | }
8 |
9 | type ToggleState = { on: boolean }
10 | type ToggleAction =
11 | | { type: 'toggle' }
12 | | { type: 'reset'; initialState: ToggleState }
13 |
14 | function toggleReducer(state: ToggleState, action: ToggleAction) {
15 | switch (action.type) {
16 | case 'toggle': {
17 | return { on: !state.on }
18 | }
19 | case 'reset': {
20 | return action.initialState
21 | }
22 | }
23 | }
24 |
25 | export function useToggle({ initialOn = false } = {}) {
26 | const { current: initialState } = useRef({ on: initialOn })
27 | const [state, dispatch] = useReducer(toggleReducer, initialState)
28 | const { on } = state
29 |
30 | const toggle = () => dispatch({ type: 'toggle' })
31 | const reset = () => dispatch({ type: 'reset', initialState })
32 |
33 | function getTogglerProps({
34 | onClick,
35 | ...props
36 | }: {
37 | onClick?: React.ComponentProps<'button'>['onClick']
38 | } & Props) {
39 | return {
40 | 'aria-checked': on,
41 | onClick: callAll(onClick, toggle),
42 | ...props,
43 | }
44 | }
45 |
46 | return {
47 | on,
48 | reset,
49 | toggle,
50 | getTogglerProps,
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.solution.stability/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render, screen } from '@testing-library/react'
3 | import { userEvent } from '@testing-library/user-event'
4 | import { App } from './app.tsx'
5 |
6 | await testStep('can render the app', () => {
7 | render( )
8 | })
9 |
10 | await testStep('Toggle is rendered and initially on', async () => {
11 | const toggleElement = await screen.findByRole('switch')
12 | expect(toggleElement).toBeChecked()
13 | })
14 |
15 | await testStep('Toggle can be turned off', async () => {
16 | const toggleElement = await screen.findByRole('switch')
17 | await userEvent.click(toggleElement)
18 | expect(toggleElement).not.toBeChecked()
19 | })
20 |
21 | await testStep('Changing initialOn updates the initialOn option', async () => {
22 | const initialOnButton = await screen.findByRole('button', {
23 | name: /initialOn/i,
24 | })
25 | await userEvent.click(initialOnButton)
26 | })
27 |
28 | await testStep(
29 | 'Clicking reset turns the toggle back on even though the initialOn option was changed to false',
30 | async () => {
31 | const resetButton = await screen.findByRole('button', { name: /reset/i })
32 | await userEvent.click(resetButton)
33 | const toggleElement = await screen.findByRole('switch')
34 | expect(
35 | toggleElement,
36 | '🚨 Did you forget to stablize the initalOn value?',
37 | ).toBeChecked()
38 | },
39 | )
40 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/02.problem.getters/README.mdx:
--------------------------------------------------------------------------------
1 | # Prop Getters
2 |
3 |
4 |
5 | 👨💼 Uh oh! Someone wants to use our `togglerProps` object, but they need to apply
6 | their own `onClick` handler! Try doing that by updating the `App` component to
7 | this:
8 |
9 | ```tsx
10 | function App() {
11 | const { on, togglerProps } = useToggle()
12 | return (
13 |
14 |
15 |
16 | console.info('onButtonClick')}
20 | >
21 | {on ? 'on' : 'off'}
22 |
23 |
24 | )
25 | }
26 | ```
27 |
28 | I want both the toggle to work as well as the log. Does that work? Why not? Can
29 | you change it to make it work?
30 |
31 | What if we change the API slightly so that instead of having an object of props,
32 | we call a function to get the props. Then we can pass that function the props we
33 | want applied and that function will be responsible for composing the props
34 | together.
35 |
36 | Let's try that. Our file has been updated to use a
37 | new API we're responsible for creating. See if you can make that API work.
38 |
39 | 🦺 The types for the argument to the `getTogglerProps` component might be a bit
40 | tricky, so here's a little tip: you can get the `onClick` prop from:
41 | `React.ComponentProps<'button'>['onClick']`.
42 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/02.problem.stability/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer } from 'react'
2 |
3 | function callAll>(
4 | ...fns: Array<((...args: Args) => unknown) | undefined>
5 | ) {
6 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
7 | }
8 |
9 | type ToggleState = { on: boolean }
10 | type ToggleAction =
11 | | { type: 'toggle' }
12 | | { type: 'reset'; initialState: ToggleState }
13 |
14 | function toggleReducer(state: ToggleState, action: ToggleAction) {
15 | switch (action.type) {
16 | case 'toggle': {
17 | return { on: !state.on }
18 | }
19 | case 'reset': {
20 | return action.initialState
21 | }
22 | }
23 | }
24 |
25 | export function useToggle({ initialOn = false } = {}) {
26 | // 🐨 wrap this in a useRef
27 | const initialState = { on: initialOn }
28 | // 🐨 pass the ref-ed initial state into useReducer
29 | const [state, dispatch] = useReducer(toggleReducer, initialState)
30 | const { on } = state
31 |
32 | const toggle = () => dispatch({ type: 'toggle' })
33 | // 🐨 make sure the ref-ed initial state gets passed here
34 | const reset = () => dispatch({ type: 'reset', initialState })
35 |
36 | function getTogglerProps({
37 | onClick,
38 | ...props
39 | }: {
40 | onClick?: React.ComponentProps<'button'>['onClick']
41 | } & Props) {
42 | return {
43 | 'aria-checked': on,
44 | onClick: callAll(onClick, toggle),
45 | ...props,
46 | }
47 | }
48 |
49 | return {
50 | on,
51 | reset,
52 | toggle,
53 | getTogglerProps,
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/public/switch.styles.css:
--------------------------------------------------------------------------------
1 | /*
2 | toggle styles copied and modified from
3 | https://codepen.io/mallendeo/pen/eLIiG
4 | by Mauricio Allende (https://mallendeo.com/)
5 | */
6 | .toggle-btn {
7 | box-sizing: initial;
8 | display: block;
9 | outline: 0;
10 | width: 8em;
11 | height: 4em;
12 | position: relative;
13 | cursor: pointer;
14 | user-select: none;
15 | background: #fbfbfb;
16 | border-radius: 4em;
17 | padding: 4px;
18 | transition: all 0.4s ease;
19 | border: 2px solid #e8eae9;
20 | }
21 | .toggle-button:focus + .toggle-btn::after,
22 | .toggle-btn:active::after {
23 | box-sizing: initial;
24 | box-shadow:
25 | 0 0 0 2px rgba(0, 0, 0, 0.1),
26 | 0 4px 0 rgba(0, 0, 0, 0.08),
27 | inset 0px 0px 0px 3px #9c9c9c;
28 | }
29 | .toggle-btn::after {
30 | left: 0;
31 | position: relative;
32 | display: block;
33 | content: '';
34 | width: 50%;
35 | height: 100%;
36 | border-radius: 4em;
37 | background: #fbfbfb;
38 | transition:
39 | all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275),
40 | padding 0.3s ease,
41 | margin 0.3s ease;
42 | box-shadow:
43 | 0 0 0 2px rgba(0, 0, 0, 0.1),
44 | 0 4px 0 rgba(0, 0, 0, 0.08);
45 | }
46 | .toggle-btn.toggle-btn-on::after {
47 | left: 50%;
48 | }
49 | .toggle-btn.toggle-btn-on {
50 | background: #86d993;
51 | }
52 | .toggle-btn.toggle-btn-on:active {
53 | box-shadow: none;
54 | }
55 | .toggle-btn.toggle-btn-on:active::after {
56 | margin-left: -1.6em;
57 | }
58 | .toggle-btn:active::after {
59 | padding-right: 1.6em;
60 | }
61 | .toggle-btn[disabled] {
62 | opacity: 0.7;
63 | cursor: auto;
64 | }
65 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.problem.default/app.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 | import { useToggle } from './toggle.tsx'
4 | // 🐨 import the toggleReducer
5 |
6 | export function App() {
7 | const [timesClicked, setTimesClicked] = useState(0)
8 | const clickedTooMuch = timesClicked >= 4
9 |
10 | const { on, getTogglerProps, getResetterProps } = useToggle({
11 | reducer(state, action) {
12 | // 🐨 add an if statement for our special logic
13 | // 💰 if the action.type === 'toggle' and clickedTooMuch is true
14 | // then return state
15 |
16 | // 🐨 otherwise call the toggleReducer with the state and action
17 | // and return that.
18 |
19 | // 💣 delete this whole switch statement
20 | switch (action.type) {
21 | case 'toggle': {
22 | if (clickedTooMuch) {
23 | return state
24 | }
25 | return { on: !state.on }
26 | }
27 | case 'reset': {
28 | return { on: false }
29 | }
30 | }
31 | },
32 | })
33 |
34 | return (
35 |
36 |
setTimesClicked(count => count + 1),
40 | })}
41 | />
42 | {clickedTooMuch ? (
43 |
44 | Whoa, you clicked too much!
45 |
46 |
47 | ) : timesClicked > 0 ? (
48 | Click count: {timesClicked}
49 | ) : null}
50 | setTimesClicked(0) })}>
51 | Reset
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.solution.context/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render, screen } from '@testing-library/react'
3 | import { userEvent } from '@testing-library/user-event'
4 | import { App } from './app.tsx'
5 |
6 | await testStep('can render the app', () => {
7 | render( )
8 | })
9 |
10 | const toggle = await testStep('Toggle is rendered', () =>
11 | screen.findByRole('switch', { name: /party mode/i }),
12 | )
13 |
14 | await testStep('Toggle is off to start', () =>
15 | expect(toggle).to.have.attr('aria-checked', 'false'),
16 | )
17 |
18 | await testStep(`Renders "Sad town 😭" when off`, () =>
19 | screen.findByText('Sad town 😭'),
20 | )
21 |
22 | await testStep(
23 | `Does not render "Let's party 🥳" when off`,
24 | () => expect(screen.queryByText("Let's party 🥳")).to.be.null,
25 | )
26 |
27 | await userEvent.click(toggle)
28 |
29 | await testStep('Clicking the toggle turns it on', async () => {
30 | expect(toggle).to.have.attr('aria-checked', 'true')
31 | })
32 |
33 | await testStep(`Renders "Let's party 🥳" when on`, () =>
34 | screen.findByText("Let's party 🥳"),
35 | )
36 |
37 | await testStep(
38 | 'Does not render "Sad town 😭" when on',
39 | () => expect(screen.queryByText('Sad town 😭')).to.be.null,
40 | )
41 |
42 | const textField = await testStep('TextField is rendered', () =>
43 | screen.findByLabelText('Venue'),
44 | )
45 |
46 | await testStep('TextField label and input are associated', () => {
47 | const label = screen.getByText('Venue')
48 | expect(label).to.have.attr('for', textField.id)
49 | })
50 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.solution.generic/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render, screen } from '@testing-library/react'
3 | import { userEvent } from '@testing-library/user-event'
4 | import { App } from './app.tsx'
5 |
6 | await testStep('can render the app', () => {
7 | render( )
8 | })
9 |
10 | const toggle = await testStep('Toggle is rendered', () =>
11 | screen.findByRole('switch', { name: /party mode/i }),
12 | )
13 |
14 | await testStep('Toggle is off to start', () =>
15 | expect(toggle).to.have.attr('aria-checked', 'false'),
16 | )
17 |
18 | await testStep(`Renders "Sad town 😭" when off`, () =>
19 | screen.findByText('Sad town 😭'),
20 | )
21 |
22 | await testStep(
23 | `Does not render "Let's party 🥳" when off`,
24 | () => expect(screen.queryByText("Let's party 🥳")).to.be.null,
25 | )
26 |
27 | await userEvent.click(toggle)
28 |
29 | await testStep('Clicking the toggle turns it on', async () => {
30 | expect(toggle).to.have.attr('aria-checked', 'true')
31 | })
32 |
33 | await testStep(`Renders "Let's party 🥳" when on`, () =>
34 | screen.findByText("Let's party 🥳"),
35 | )
36 |
37 | await testStep(
38 | 'Does not render "Sad town 😭" when on',
39 | () => expect(screen.queryByText('Sad town 😭')).to.be.null,
40 | )
41 |
42 | const textField = await testStep('TextField is rendered', () =>
43 | screen.findByLabelText('Venue'),
44 | )
45 |
46 | await testStep('TextField label and input are associated', () => {
47 | const label = screen.getByText('Venue')
48 | expect(label).to.have.attr('for', textField.id)
49 | })
50 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.solution.prop/slots.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use } from 'react'
2 | import { Switch as BaseSwitch } from '#shared/switch'
3 |
4 | type Slots = Record>
5 | export const SlotContext = createContext({})
6 |
7 | function useSlotProps(
8 | props: Props & { slot?: string },
9 | defaultSlot?: string,
10 | ): Props {
11 | const slot = props.slot ?? defaultSlot
12 | if (!slot) return props
13 |
14 | const slots = use(SlotContext)
15 |
16 | // a more proper "mergeProps" function is in order here
17 | // to handle things like merging event handlers better.
18 | // we'll get to that a bit in a later exercise.
19 | return { ...slots[slot], slot, ...props } as Props
20 | }
21 |
22 | export function Label(
23 | props: React.ComponentProps<'label'> & { slot?: string },
24 | ) {
25 | props = useSlotProps(props, 'label')
26 | return
27 | }
28 |
29 | export function Input(
30 | props: React.ComponentProps<'input'> & { slot?: string },
31 | ) {
32 | props = useSlotProps(props, 'input')
33 | return
34 | }
35 |
36 | export function Text(props: React.ComponentProps<'span'> & { slot?: string }) {
37 | props = useSlotProps(props, 'text')
38 | return
39 | }
40 |
41 | type SwitchProps = Omit, 'on'> & {
42 | slot?: string
43 | }
44 | export function Switch(props: SwitchProps) {
45 | return (
46 | )}
50 | />
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.solution.validation/validation.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render } from '@testing-library/react'
3 | import { ToggleButton, ToggleOff, ToggleOn } from './toggle.tsx'
4 |
5 | const expectedErrorMessage =
6 | 'Cannot find ToggleContext. All Toggle components must be rendered within '
7 |
8 | window.addEventListener('error', event => {
9 | if (event.error.message.includes(expectedErrorMessage)) {
10 | event.preventDefault()
11 | }
12 | })
13 | // silence React error logging for tests that expect errors
14 | console.error = () => {}
15 |
16 | await testStep(
17 | 'ToggleButton should throw an error when rendered outside a Toggle component',
18 | () => {
19 | const renderToggleButton = () => render( )
20 | expect(renderToggleButton).to.throw()
21 | },
22 | )
23 |
24 | await testStep(
25 | 'ToggleOn should throw an error when rendered outside a Toggle component',
26 | () => {
27 | const renderToggleOn = () => render(toggle on )
28 | expect(renderToggleOn).to.throw()
29 | },
30 | )
31 |
32 | await testStep(
33 | 'ToggleOff should throw an error when rendered outside a Toggle component',
34 | () => {
35 | const renderToggleOff = () => render(toggle off )
36 | expect(renderToggleOff).to.throw()
37 | },
38 | )
39 |
40 | await testStep(`The error thrown should say "${expectedErrorMessage}"`, () => {
41 | const renderToggleButton = () => render( )
42 | expect(renderToggleButton).to.throw(expectedErrorMessage)
43 | })
44 |
--------------------------------------------------------------------------------
/exercises/04.slots/01.problem.context/README.mdx:
--------------------------------------------------------------------------------
1 | # Slot Context
2 |
3 |
4 |
5 | 👨💼 It's a tale as old as time. Our `label` and `input` are not properly
6 | associated in this form and so clicking the `label` will not focus the `input`
7 | as expected (in addition to other accessibility issues).
8 |
9 | But we don't want developers to be able to make this mistake. So we've made a
10 | `TextField` component which will generate the `id` for the relationship (if one
11 | is not provided). The tricky bit is we want people to be able to structure their
12 | label and input however they want, so we can't render the `input` and `label`
13 | for them. Instead, we want to be able to provide the `id` and `htmlFor` props to
14 | the `label` and `input`.
15 |
16 | So what we want you to do is first create a `SlotContext` and `useSlotProps`
17 | hook in , then use those in the `Label` and
18 | `Input` components to retrieve the necessary props.
19 |
20 | The `useSlotProps` hook should accept a props object and a slot name and return
21 | the props to be applied to the element for that slot. It should merge the props
22 | it's been given with the props from the `SlotContext` for that slot.
23 |
24 | Once you've finished that, then render the `SlotContext` provider in the
25 | `TextField` component in to provide slot
26 | props for the `label` and `input`.
27 |
28 | When you're finished, the label and input should be properly associated and
29 | clicking the label should focus the input.
30 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/02.problem.validation/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use, useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 |
4 | type ToggleValue = { on: boolean; toggle: () => void }
5 | const ToggleContext = createContext(null)
6 |
7 | export function Toggle({ children }: { children: React.ReactNode }) {
8 | const [on, setOn] = useState(false)
9 | const toggle = () => setOn(!on)
10 |
11 | return {children}
12 | }
13 |
14 | // 🐨 create a custom useToggle() hook here
15 | // create a new context variable and read ToggleContext with use
16 | // when no context is found, throw an error with a useful message
17 | // otherwise return the context
18 |
19 | export function ToggleOn({ children }: { children: React.ReactNode }) {
20 | // 🐨 instead reading context with use, we'll need to get that from useToggle()
21 | const { on } = use(ToggleContext)!
22 | return <>{on ? children : null}>
23 | }
24 |
25 | export function ToggleOff({ children }: { children: React.ReactNode }) {
26 | // 🐨 instead reading context with use, we'll need to get that from useToggle()
27 | const { on } = use(ToggleContext)!
28 | return <>{on ? null : children}>
29 | }
30 |
31 | type ToggleButtonProps = Omit, 'on'> & {
32 | on?: boolean
33 | }
34 | export function ToggleButton({ ...props }: ToggleButtonProps) {
35 | // 🐨 instead reading context with use, we'll need to get that from useToggle()
36 | const { on, toggle } = use(ToggleContext)!
37 | return
38 | }
39 |
--------------------------------------------------------------------------------
/exercises/02.latest-ref/01.solution.ref/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useRef, useState } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 |
4 | function debounce) => void>(
5 | fn: Callback,
6 | delay: number,
7 | ) {
8 | let timer: ReturnType | null = null
9 | return (...args: Parameters) => {
10 | if (timer) clearTimeout(timer)
11 | timer = setTimeout(() => {
12 | fn(...args)
13 | }, delay)
14 | }
15 | }
16 |
17 | function useDebounce) => unknown>(
18 | callback: Callback,
19 | delay: number,
20 | ) {
21 | const callbackRef = useRef(callback)
22 | useEffect(() => {
23 | callbackRef.current = callback
24 | })
25 | return useMemo(
26 | () => debounce((...args) => callbackRef.current(...args), delay),
27 | [delay],
28 | )
29 | }
30 |
31 | function App() {
32 | const [step, setStep] = useState(1)
33 | const [count, setCount] = useState(0)
34 | const increment = () => setCount(c => c + step)
35 | const debouncedIncrement = useDebounce(increment, 3000)
36 | return (
37 |
38 |
39 |
40 | Step:{' '}
41 | setStep(Number(e.currentTarget.value))}
47 | defaultValue={step}
48 | />
49 |
50 |
51 |
{count}
52 |
53 | )
54 | }
55 |
56 | const rootEl = document.createElement('div')
57 | document.body.append(rootEl)
58 | ReactDOM.createRoot(rootEl).render( )
59 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.solution.generic/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use, useId, useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 | import { SlotContext } from './slots'
4 |
5 | type ToggleValue = { on: boolean; toggle: () => void; id: string }
6 | const ToggleContext = createContext(null)
7 |
8 | export function Toggle({
9 | id,
10 | children,
11 | }: {
12 | id?: string
13 | children: React.ReactNode
14 | }) {
15 | const [on, setOn] = useState(false)
16 | const generatedId = useId()
17 | id ??= generatedId
18 |
19 | const toggle = () => setOn(!on)
20 |
21 | const slots = { label: { htmlFor: id } }
22 |
23 | return (
24 |
25 | {children}
26 |
27 | )
28 | }
29 |
30 | function useToggle() {
31 | const context = use(ToggleContext)
32 | if (!context) {
33 | throw new Error(
34 | 'Cannot find ToggleContext. All Toggle components must be rendered within ',
35 | )
36 | }
37 | return context
38 | }
39 |
40 | export function ToggleOn({ children }: { children: React.ReactNode }) {
41 | const { on } = useToggle()
42 | return <>{on ? children : null}>
43 | }
44 |
45 | export function ToggleOff({ children }: { children: React.ReactNode }) {
46 | const { on } = useToggle()
47 | return <>{on ? null : children}>
48 | }
49 |
50 | type ToggleButtonProps = Omit, 'on'> & {
51 | on?: boolean
52 | }
53 | export function ToggleButton({ ...props }: ToggleButtonProps) {
54 | const { on, toggle, id } = useToggle()
55 | return
56 | }
57 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.solution.reducer/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer, useRef } from 'react'
2 |
3 | function callAll>(
4 | ...fns: Array<((...args: Args) => unknown) | undefined>
5 | ) {
6 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
7 | }
8 |
9 | type ToggleState = { on: boolean }
10 | type ToggleAction =
11 | | { type: 'toggle' }
12 | | { type: 'reset'; initialState: ToggleState }
13 |
14 | function toggleReducer(state: ToggleState, action: ToggleAction) {
15 | switch (action.type) {
16 | case 'toggle': {
17 | return { on: !state.on }
18 | }
19 | case 'reset': {
20 | return action.initialState
21 | }
22 | }
23 | }
24 |
25 | export function useToggle({ initialOn = false, reducer = toggleReducer } = {}) {
26 | const { current: initialState } = useRef({ on: initialOn })
27 | const [state, dispatch] = useReducer(reducer, initialState)
28 | const { on } = state
29 |
30 | const toggle = () => dispatch({ type: 'toggle' })
31 | const reset = () => dispatch({ type: 'reset', initialState })
32 |
33 | function getTogglerProps({
34 | onClick,
35 | ...props
36 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
37 | return {
38 | 'aria-checked': on,
39 | onClick: callAll(onClick, toggle),
40 | ...props,
41 | }
42 | }
43 |
44 | function getResetterProps({
45 | onClick,
46 | ...props
47 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
48 | return {
49 | onClick: callAll(onClick, reset),
50 | ...props,
51 | }
52 | }
53 |
54 | return {
55 | on,
56 | reset,
57 | toggle,
58 | getTogglerProps,
59 | getResetterProps,
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.solution.default/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer, useRef } from 'react'
2 |
3 | function callAll>(
4 | ...fns: Array<((...args: Args) => unknown) | undefined>
5 | ) {
6 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
7 | }
8 |
9 | type ToggleState = { on: boolean }
10 | type ToggleAction =
11 | | { type: 'toggle' }
12 | | { type: 'reset'; initialState: ToggleState }
13 |
14 | export function toggleReducer(state: ToggleState, action: ToggleAction) {
15 | switch (action.type) {
16 | case 'toggle': {
17 | return { on: !state.on }
18 | }
19 | case 'reset': {
20 | return action.initialState
21 | }
22 | }
23 | }
24 |
25 | export function useToggle({ initialOn = false, reducer = toggleReducer } = {}) {
26 | const { current: initialState } = useRef({ on: initialOn })
27 | const [state, dispatch] = useReducer(reducer, initialState)
28 | const { on } = state
29 |
30 | const toggle = () => dispatch({ type: 'toggle' })
31 | const reset = () => dispatch({ type: 'reset', initialState })
32 | function getTogglerProps({
33 | onClick,
34 | ...props
35 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
36 | return {
37 | 'aria-checked': on,
38 | onClick: callAll(onClick, toggle),
39 | ...props,
40 | }
41 | }
42 |
43 | function getResetterProps({
44 | onClick,
45 | ...props
46 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
47 | return {
48 | onClick: callAll(onClick, reset),
49 | ...props,
50 | }
51 | }
52 |
53 | return {
54 | on,
55 | reset,
56 | toggle,
57 | getTogglerProps,
58 | getResetterProps,
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.solution.prop/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render, screen } from '@testing-library/react'
3 | import { userEvent } from '@testing-library/user-event'
4 | import { App } from './app.tsx'
5 |
6 | await testStep('can render the app', () => {
7 | render( )
8 | })
9 |
10 | const toggle = await testStep('Toggle is rendered', () =>
11 | screen.findByRole('switch', { name: /party mode/i }),
12 | )
13 |
14 | await testStep('Toggle is off to start', () =>
15 | expect(toggle).to.have.attr('aria-checked', 'false'),
16 | )
17 |
18 | await testStep(`Renders "Sad town 😭" when off`, () =>
19 | screen.findByText('Sad town 😭'),
20 | )
21 |
22 | await testStep(`Does not render "Let's party 🥳" when off`, () => {
23 | const textNode = screen.queryByText("Let's party 🥳")
24 | if (textNode) {
25 | expect(textNode).not.toBeVisible()
26 | }
27 | })
28 |
29 | await userEvent.click(toggle)
30 |
31 | await testStep('Clicking the toggle turns it on', async () => {
32 | expect(toggle).to.have.attr('aria-checked', 'true')
33 | })
34 |
35 | await testStep(`Renders "Let's party 🥳" when on`, () =>
36 | screen.findByText("Let's party 🥳"),
37 | )
38 |
39 | await testStep('Does not render "Sad town 😭" when on', () => {
40 | const textNode = screen.queryByText('Sad town 😭')
41 | if (textNode) {
42 | expect(textNode).not.toBeVisible()
43 | }
44 | })
45 |
46 | const textField = await testStep('TextField is rendered', () =>
47 | screen.findByLabelText('Venue'),
48 | )
49 |
50 | await testStep('TextField label and input are associated', () => {
51 | const label = screen.getByText('Venue')
52 | expect(label).to.have.attr('for', textField.id)
53 | })
54 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/01.problem.initial/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer } from 'react'
2 |
3 | function callAll>(
4 | ...fns: Array<((...args: Args) => unknown) | undefined>
5 | ) {
6 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
7 | }
8 |
9 | type ToggleState = { on: boolean }
10 | type ToggleAction = { type: 'toggle' }
11 | // 🦺 add an action type for reset:
12 | // 💰 | { type: 'reset'; initialState: ToggleState }
13 |
14 | function toggleReducer(state: ToggleState, action: ToggleAction) {
15 | switch (action.type) {
16 | case 'toggle': {
17 | return { on: !state.on }
18 | }
19 | // 🐨 add a case for 'reset' that simply returns the "initialState"
20 | // which you can get from the action.
21 | }
22 | }
23 |
24 | // 🐨 We'll need to add an option for `initialOn` here (default to false)
25 | export function useToggle() {
26 | // 🐨 update the initialState object to use the initialOn option
27 | const initialState = { on: false }
28 | const [state, dispatch] = useReducer(toggleReducer, initialState)
29 | const { on } = state
30 |
31 | const toggle = () => dispatch({ type: 'toggle' })
32 |
33 | // 🐨 add a reset function here which dispatches a 'reset' type with your
34 | // initialState object and calls `onReset` with the initialState.on value
35 |
36 | function getTogglerProps({
37 | onClick,
38 | ...props
39 | }: {
40 | onClick?: React.ComponentProps<'button'>['onClick']
41 | } & Props) {
42 | return {
43 | 'aria-checked': on,
44 | onClick: callAll(onClick, toggle),
45 | ...props,
46 | }
47 | }
48 |
49 | return {
50 | on,
51 | // 🐨 add your reset function here.
52 | toggle,
53 | getTogglerProps,
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.problem.default/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer, useRef } from 'react'
2 |
3 | function callAll>(
4 | ...fns: Array<((...args: Args) => unknown) | undefined>
5 | ) {
6 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
7 | }
8 |
9 | type ToggleState = { on: boolean }
10 | type ToggleAction =
11 | | { type: 'toggle' }
12 | | { type: 'reset'; initialState: ToggleState }
13 |
14 | // 🐨 export this
15 | function toggleReducer(state: ToggleState, action: ToggleAction) {
16 | switch (action.type) {
17 | case 'toggle': {
18 | return { on: !state.on }
19 | }
20 | case 'reset': {
21 | return action.initialState
22 | }
23 | }
24 | }
25 |
26 | export function useToggle({ initialOn = false, reducer = toggleReducer } = {}) {
27 | const { current: initialState } = useRef({ on: initialOn })
28 | const [state, dispatch] = useReducer(reducer, initialState)
29 | const { on } = state
30 |
31 | const toggle = () => dispatch({ type: 'toggle' })
32 | const reset = () => dispatch({ type: 'reset', initialState })
33 |
34 | function getTogglerProps({
35 | onClick,
36 | ...props
37 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
38 | return {
39 | 'aria-checked': on,
40 | onClick: callAll(onClick, toggle),
41 | ...props,
42 | }
43 | }
44 |
45 | function getResetterProps({
46 | onClick,
47 | ...props
48 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
49 | return {
50 | onClick: callAll(onClick, reset),
51 | ...props,
52 | }
53 | }
54 |
55 | return {
56 | on,
57 | reset,
58 | toggle,
59 | getTogglerProps,
60 | getResetterProps,
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.problem.context/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 |
4 | // 🐨 create your ToggleContext context here
5 | // 📜 https://react.dev/reference/react/createContext
6 | // 💰 the default value should be `null`
7 | // 🦺 the typing for the context value should be `{on: boolean; toggle: () => void}`
8 | // but because we must initialize it to `null`, you need to union that with `null`
9 |
10 | export function Toggle({ children }: { children: React.ReactNode }) {
11 | const [on, setOn] = useState(false)
12 | const toggle = () => setOn(!on)
13 |
14 | // 💣 remove this and instead return where
15 | // the value is an object that has `on` and `toggle` on it. Render children
16 | // within the provider.
17 | return <>TODO...>
18 | }
19 |
20 | export function ToggleOn({ children }: { children: React.ReactNode }) {
21 | // 🐨 instead of this constant value, we'll need to get that from
22 | // use(ToggleContext)
23 | // 📜 https://react.dev/reference/react/use#reading-context-with-use
24 | const on = false
25 | return <>{on ? children : null}>
26 | }
27 |
28 | export function ToggleOff({ children }: { children: React.ReactNode }) {
29 | // 🐨 do the same thing to this that you did to the ToggleOn component
30 | const on = false
31 | return <>{on ? null : children}>
32 | }
33 |
34 | type ToggleButtonProps = Omit, 'on'> & {
35 | on?: boolean
36 | }
37 | export function ToggleButton(props: ToggleButtonProps) {
38 | // 🐨 get `on` and `toggle` from the ToggleContext with `use`
39 | const on = false
40 | const toggle = () => {}
41 | return
42 | }
43 |
44 | /*
45 | eslint
46 | @typescript-eslint/no-unused-vars: "off",
47 | */
48 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.problem.prop/README.mdx:
--------------------------------------------------------------------------------
1 | # Slot Prop
2 |
3 |
4 |
5 | 👨💼 We have `ToggleOn` and `ToggleOff` components, but really we could make those
6 | components a simple `Text` component that accepts a `slot` prop. Then the
7 | `Toggle` component could define the props that the individual `Text` components
8 | should have based on which slot they're taking.
9 |
10 | In fact, we could do this with the `Switch` as well!
11 |
12 | 🧝♂️ I've added `Text` and `Switch` components to
13 | the file for you to use. These are both already
14 | wired up to consume a `slot` named `text` and `switch`. You
15 | can check the diff for details.
16 |
17 | What we want to do in this exercise is add a `slot` prop to each of our slot
18 | components so the slot they're taking can be defined by the parent component.
19 |
20 | Then you'll need to update `Toggle` to get rid of the `ToggleContext` provider
21 | and instead use the `SlotProvider` for all the components it wants to send props
22 | to:
23 |
24 | - `label` - `htmlFor`
25 | - `onText` - `hidden` (`undefined` if `isOn` is true, and `true` if `isOn` is
26 | `false`)
27 | - `offText` - `hidden` (`undefined` if `isOn` is false, and `true` if `isOn` is
28 | `true`)
29 | - `switch` - `id`, `on`, and `onClick`
30 |
31 | So by the end of all of this, here's what I want the API to be like:
32 |
33 | ```tsx
34 |
35 | Party mode
36 |
37 | Let's party 🥳
38 | Sad town 😭
39 |
40 | ```
41 |
42 | Once that's been updated, you can delete the `useToggle` hook and the
43 | `ToggleOn`, `ToggleOff`, and `ToggleButton` components.
44 |
45 | Reusability FTW!
46 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.problem.prop/slots.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use } from 'react'
2 | import { Switch as BaseSwitch } from '#shared/switch'
3 |
4 | type Slots = Record>
5 | export const SlotContext = createContext({})
6 |
7 | function useSlotProps(
8 | props: Props, // 🐨 this should now be Props & { slot?: string }
9 | slot: string, // 🐨 rename this to "defaultSlot" and make it optional
10 | ): Props {
11 | // 🐨 create a slot variable that is set to props.slot and falls back to the defaultSlot
12 | // 🐨 if there's no slot, return the props as they are
13 |
14 | const slots = use(SlotContext)
15 |
16 | // a more proper "mergeProps" function is in order here
17 | // to handle things like merging event handlers better.
18 | // we'll get to that a bit in a later exercise.
19 | return { ...slots[slot], slot, ...props } as Props
20 | }
21 |
22 | // 🐨 add an optional slot to the props type here
23 | export function Label(props: React.ComponentProps<'label'>) {
24 | props = useSlotProps(props, 'label')
25 | return
26 | }
27 |
28 | // 🐨 add an optional slot to the props type here
29 | export function Input(props: React.ComponentProps<'input'>) {
30 | props = useSlotProps(props, 'input')
31 | return
32 | }
33 |
34 | // 🐨 add an optional slot to the props type here
35 | export function Text(props: React.ComponentProps<'span'>) {
36 | props = useSlotProps(props, 'text')
37 | return
38 | }
39 |
40 | // 🐨 add an optional slot to the props type here
41 | type SwitchProps = Omit, 'on'>
42 | export function Switch(props: SwitchProps) {
43 | return (
44 | )}
48 | />
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/exercises/04.slots/02.problem.generic/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use, useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 |
4 | // 🐨 add an id string to the ToggleValue type
5 | type ToggleValue = { on: boolean; toggle: () => void }
6 | const ToggleContext = createContext(null)
7 |
8 | // 🐨 update this to accept an optional id
9 | export function Toggle({ children }: { children: React.ReactNode }) {
10 | const [on, setOn] = useState(false)
11 | // 🐨 generate an id using useId (💰 similar to in text-field.tsx)
12 |
13 | const toggle = () => setOn(!on)
14 |
15 | // 🐨 create a slots object that has props for a slot called
16 | // "label" with an htmlFor prop
17 |
18 | // 🐨 wrap this in SlotContext and pass the labelProps in the label slot
19 | // 🐨 add the id to the value in the ToggleContext
20 | return {children}
21 | }
22 |
23 | function useToggle() {
24 | const context = use(ToggleContext)
25 | if (!context) {
26 | throw new Error(
27 | 'Cannot find ToggleContext. All Toggle components must be rendered within ',
28 | )
29 | }
30 | return context
31 | }
32 |
33 | export function ToggleOn({ children }: { children: React.ReactNode }) {
34 | const { on } = useToggle()
35 | return <>{on ? children : null}>
36 | }
37 |
38 | export function ToggleOff({ children }: { children: React.ReactNode }) {
39 | const { on } = useToggle()
40 | return <>{on ? null : children}>
41 | }
42 |
43 | type ToggleButtonProps = Omit, 'on'> & {
44 | on?: boolean
45 | }
46 | export function ToggleButton({ ...props }: ToggleButtonProps) {
47 | // 🐨 get the id out of useToggle
48 | const { on, toggle } = useToggle()
49 | // 🐨 pass the id for the ToggleButton here
50 | return
51 | }
52 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.problem.reducer/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer, useRef } from 'react'
2 |
3 | function callAll>(
4 | ...fns: Array<((...args: Args) => unknown) | undefined>
5 | ) {
6 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
7 | }
8 |
9 | type ToggleState = { on: boolean }
10 | type ToggleAction =
11 | | { type: 'toggle' }
12 | | { type: 'reset'; initialState: ToggleState }
13 |
14 | function toggleReducer(state: ToggleState, action: ToggleAction) {
15 | switch (action.type) {
16 | case 'toggle': {
17 | return { on: !state.on }
18 | }
19 | case 'reset': {
20 | return action.initialState
21 | }
22 | }
23 | }
24 |
25 | // 🐨 add a new option called `reducer` that defaults to `toggleReducer`
26 | export function useToggle({ initialOn = false } = {}) {
27 | const { current: initialState } = useRef({ on: initialOn })
28 | // 🐨 instead of passing `toggleReducer` here, pass the `reducer` that's
29 | // provided as an option
30 | // ... and that's it! Don't forget to check the next step!
31 | const [state, dispatch] = useReducer(toggleReducer, initialState)
32 | const { on } = state
33 |
34 | const toggle = () => dispatch({ type: 'toggle' })
35 | const reset = () => dispatch({ type: 'reset', initialState })
36 |
37 | function getTogglerProps({
38 | onClick,
39 | ...props
40 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
41 | return {
42 | 'aria-checked': on,
43 | onClick: callAll(onClick, toggle),
44 | ...props,
45 | }
46 | }
47 |
48 | function getResetterProps({
49 | onClick,
50 | ...props
51 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
52 | return {
53 | onClick: callAll(onClick, reset),
54 | ...props,
55 | }
56 | }
57 |
58 | return {
59 | on,
60 | reset,
61 | toggle,
62 | getTogglerProps,
63 | getResetterProps,
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/exercises/04.slots/03.problem.prop/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, use, useId, useState } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 | import { SlotContext } from './slots'
4 |
5 | // 🐨 delete all this context stuff
6 | type ToggleValue = { on: boolean; toggle: () => void; id: string }
7 | const ToggleContext = createContext(null)
8 |
9 | export function Toggle({
10 | id,
11 | children,
12 | }: {
13 | id?: string
14 | children: React.ReactNode
15 | }) {
16 | const [on, setOn] = useState(false)
17 | const generatedId = useId()
18 | id ??= generatedId
19 |
20 | const toggle = () => setOn(!on)
21 |
22 | const slots = {
23 | label: { htmlFor: id },
24 | // 🐨 add slots for onText (hidden prop), offText (hidden prop),
25 | // and switch (id, on, onClick props)
26 | }
27 |
28 | return (
29 |
30 | {/* 🐨 get rid of the ToggleContext here */}
31 | {children}
32 |
33 | )
34 | }
35 |
36 | // 🐨 delete everything below here!
37 | function useToggle() {
38 | const context = use(ToggleContext)
39 | if (!context) {
40 | throw new Error(
41 | 'Cannot find ToggleContext. All Toggle components must be rendered within ',
42 | )
43 | }
44 | return context
45 | }
46 |
47 | export function ToggleOn({ children }: { children: React.ReactNode }) {
48 | const { on } = useToggle()
49 | return <>{on ? children : null}>
50 | }
51 |
52 | export function ToggleOff({ children }: { children: React.ReactNode }) {
53 | const { on } = useToggle()
54 | return <>{on ? null : children}>
55 | }
56 |
57 | type ToggleButtonProps = Omit, 'on'> & {
58 | on?: boolean
59 | }
60 | export function ToggleButton({ ...props }: ToggleButtonProps) {
61 | const { on, toggle, id } = useToggle()
62 | return
63 | }
64 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/README.mdx:
--------------------------------------------------------------------------------
1 | # State Reducer
2 |
3 |
4 |
5 |
6 | **One liner:** The State Reducer Pattern inverts control over the state
7 | management of your hook and/or component to the developer using it so they can
8 | control the state changes that happen when dispatching events.
9 |
10 |
11 | During the life of a reusable component which is used in many different
12 | contexts, feature requests are made over and over again to handle different
13 | cases and cater to different scenarios.
14 |
15 | We could definitely add props to our component and add logic in our reducer for
16 | how to handle these different cases, but there's a never ending list of logical
17 | customizations that people could want out of our custom hook and we don't want
18 | to have to code for every one of those cases.
19 |
20 | For example, imagine you've got a combobox component and by default when the
21 | user clicks out of the combobox, the menu closes. But then someone wants to
22 | prevent the menu from closing when the user clicks out of the combobox.
23 |
24 | You can use the state reducer pattern to allow the user of your hook to control
25 | the state changes that happen when events are dispatched. Here's an example of
26 | what that may look like on the `ComboBox` component:
27 |
28 | ```tsx
29 | {
31 | if (action.type === 'clickOutside' && state.isOpen) {
32 | return { ...state, isOpen: false }
33 | }
34 | return state
35 | }}
36 | // ...other props
37 | />
38 | ```
39 |
40 | 📜 Read more about this pattern in:
41 | [The State Reducer Pattern with React Hooks](https://kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks)
42 |
43 | **Real World Projects that use this pattern:**
44 |
45 | - [downshift](https://github.com/downshift-js/downshift)
46 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/01.problem.context/README.mdx:
--------------------------------------------------------------------------------
1 | # Compound Components
2 |
3 |
4 |
5 | 👨💼 In this exercise we're going to make ` ` the parent of a few
6 | compound components:
7 |
8 | - ` ` renders children when the `on` state is `true`
9 | - ` ` renders children when the `on` state is `false`
10 | - ` ` renders the ` ` with the `on` prop set to the `on`
11 | state and the `onClick` prop set to `toggle`.
12 |
13 | We have a Toggle component that manages the state, and we want to render
14 | different parts of the UI however we want. We want control over the presentation
15 | of the UI.
16 |
17 | 🦉 The fundamental challenge you face with an API like this is the state shared
18 | between the components is implicit, meaning that the developer using your
19 | component cannot actually see or interact with the state (`on`) or the
20 | mechanisms for updating that state (`toggle`) that are being shared between the
21 | components.
22 |
23 | So in this exercise, we'll solve that problem by using the 📜
24 | [React Context API](https://react.dev/reference/react/use#reading-context-with-use)!
25 |
26 | What we want to do in this exercise is allow users of our component to render
27 | something when the toggle button is on and to render something else when that
28 | toggle button is off without troubling them with managing the state that's
29 | controlling whether it's shown or not.
30 |
31 | Your job will be to make a `ToggleContext` which will be used to implicitly
32 | share the state between these components. The `Toggle` component will render the
33 | `ToggleContext` and the other compound components will access that
34 | implicit state via `use(ToggleContext)`.
35 |
36 | 🦺 TypeScript might not like your `use` call depending on how you set up your
37 | context. We'll deal with this in another step.
38 |
--------------------------------------------------------------------------------
/.vscode/settings.kcd.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "editor.detectIndentation": true,
5 | "editor.fontFamily": "'Dank Mono', Menlo, Monaco, 'Courier New', monospace",
6 | "editor.fontLigatures": false,
7 | "editor.rulers": [80],
8 | "editor.snippetSuggestions": "top",
9 | "editor.wordBasedSuggestions": false,
10 | "editor.suggest.localityBonus": true,
11 | "editor.acceptSuggestionOnCommitCharacter": false,
12 | "[javascript]": {
13 | "editor.defaultFormatter": "esbenp.prettier-vscode",
14 | "editor.suggestSelection": "recentlyUsed",
15 | "editor.suggest.showKeywords": false
16 | },
17 | "editor.renderWhitespace": "boundary",
18 | "files.defaultLanguage": "{activeEditorLanguage}",
19 | "javascript.validate.enable": false,
20 | "search.exclude": {
21 | "**/node_modules": true,
22 | "**/bower_components": true,
23 | "**/coverage": true,
24 | "**/dist": true,
25 | "**/build": true,
26 | "**/.build": true,
27 | "**/.gh-pages": true
28 | },
29 | "editor.codeActionsOnSave": {
30 | "source.fixAll.eslint": false
31 | },
32 | "eslint.validate": [
33 | "javascript",
34 | "javascriptreact",
35 | "typescript",
36 | "typescriptreact"
37 | ],
38 | "eslint.options": {
39 | "env": {
40 | "browser": true,
41 | "jest/globals": true,
42 | "es6": true
43 | },
44 | "parserOptions": {
45 | "ecmaVersion": 2019,
46 | "sourceType": "module",
47 | "ecmaFeatures": {
48 | "jsx": true
49 | }
50 | },
51 | "rules": {
52 | "no-debugger": "off"
53 | }
54 | },
55 | "workbench.colorTheme": "Night Owl",
56 | "workbench.iconTheme": "material-icon-theme",
57 | "breadcrumbs.enabled": true,
58 | "grunt.autoDetect": "off",
59 | "gulp.autoDetect": "off",
60 | "npm.runSilent": true,
61 | "explorer.confirmDragAndDrop": false,
62 | "editor.formatOnPaste": false,
63 | "editor.cursorSmoothCaretAnimation": true,
64 | "editor.smoothScrolling": true,
65 | "php.suggest.basic": false
66 | }
67 |
--------------------------------------------------------------------------------
/exercises/02.latest-ref/01.problem.ref/index.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo, useState } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 |
4 | function debounce) => void>(
5 | fn: Callback,
6 | delay: number,
7 | ) {
8 | let timer: ReturnType | null = null
9 | return (...args: Parameters) => {
10 | if (timer) clearTimeout(timer)
11 | timer = setTimeout(() => {
12 | fn(...args)
13 | }, delay)
14 | }
15 | }
16 |
17 | function useDebounce) => unknown>(
18 | callback: Callback,
19 | delay: number,
20 | ) {
21 | // 🐨 create a latest ref (via useRef and useEffect) here
22 |
23 | // use the latest version of the callback here:
24 | // 💰 you'll need to pass an anonymous function to debounce. Do *not*
25 | // simply change this to `debounce(latestCallbackRef.current, delay)`
26 | // as that won't work. Can you think of why?
27 | return useMemo(() => debounce(callback, delay), [callback, delay])
28 | }
29 |
30 | function App() {
31 | const [step, setStep] = useState(1)
32 | const [count, setCount] = useState(0)
33 |
34 | // 🦉 feel free to swap these two implementations and see they don't make
35 | // any difference to the user experience
36 | // const increment = useCallback(() => setCount(c => c + step), [step])
37 | const increment = () => setCount(c => c + step)
38 | const debouncedIncrement = useDebounce(increment, 3000)
39 | return (
40 |
41 |
42 |
43 | Step:{' '}
44 | setStep(Number(e.currentTarget.value))}
50 | defaultValue={step}
51 | />
52 |
53 |
54 |
{count}
55 |
56 | )
57 | }
58 |
59 | const rootEl = document.createElement('div')
60 | document.body.append(rootEl)
61 | ReactDOM.createRoot(rootEl).render( )
62 |
--------------------------------------------------------------------------------
/exercises/06.state-initializers/README.mdx:
--------------------------------------------------------------------------------
1 | # State Initializer
2 |
3 |
4 |
5 |
6 | **One liner:** The state initializer pattern is a way to initialize (and
7 | reset) the state of a component in a predictable way.
8 |
9 |
10 | This one is simple in concept:
11 |
12 | ```tsx
13 | function useCounter() {
14 | const [count, setCount] = useState(0)
15 | const increment = () => setCount(c => c + 1)
16 | return { count, increment }
17 | }
18 | ```
19 |
20 | If I wanted to initialize the state of the count to a different value, I could
21 | do so by passing an argument to the `useCounter` function:
22 |
23 | ```tsx
24 | function useCounter({ initialCount = 0 } = {}) {
25 | const [count, setCount] = useState(initialCount)
26 | const increment = () => setCount(c => c + 1)
27 | return { count, increment }
28 | }
29 | ```
30 |
31 | And often when you have a state initializer, you also have a state resetter:
32 |
33 | ```tsx
34 | function useCounter({ initialCount = 0 } = {}) {
35 | const [count, setCount] = useState(initialCount)
36 | const increment = () => setCount(c => c + 1)
37 | const reset = () => setCount(initialCount)
38 | return { count, increment, reset }
39 | }
40 | ```
41 |
42 | But there's a catch. If you truly want to reset the component to its _initial_
43 | state, then you need to make certain that any changes to the `initialCount` are
44 | ignored!
45 |
46 | You can do this, by using a `ref` which will keep the initial value constant
47 | across renders:
48 |
49 | ```tsx
50 | function useCounter({ initialCount = 0 } = {}) {
51 | const initialCountRef = useRef(initialCount)
52 | const [count, setCount] = useState(initialCountRef.current)
53 | const increment = () => setCount(c => c + 1)
54 | const reset = () => setCount(initialCountRef.current)
55 | return { count, increment, reset }
56 | }
57 | ```
58 |
59 | And that's the crux of the state initializer pattern.
60 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/01.solution.reducer/click-limit.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render, screen } from '@testing-library/react'
3 | import { userEvent } from '@testing-library/user-event'
4 | import { App } from './app.tsx'
5 |
6 | await testStep('can render the app', () => {
7 | render( )
8 | })
9 |
10 | const toggle = screen.getByRole('switch')
11 |
12 | await testStep('can toggle the switch up to four times', async () => {
13 | expect(toggle).to.have.attr('aria-checked', 'false')
14 | await userEvent.click(toggle) // 1
15 | expect(toggle).to.have.attr('aria-checked', 'true')
16 | await userEvent.click(toggle) // 2
17 | expect(toggle).to.have.attr('aria-checked', 'false')
18 | expect(screen.getByTestId('click-count')).to.have.text('Click count: 2')
19 | await userEvent.click(toggle) // 3
20 | expect(toggle).to.have.attr('aria-checked', 'true')
21 | expect(screen.queryByText(/whoa/i)).to.be.null
22 | await userEvent.click(toggle) // 4
23 | expect(toggle).to.have.attr('aria-checked', 'true')
24 | })
25 |
26 | await testStep('notice is visible after four clicks', () => {
27 | expect(screen.getByText(/whoa/i)).not.to.be.null
28 | })
29 |
30 | await testStep('cannot click beyond the limit of 4', async () => {
31 | await userEvent.click(toggle) // 5: Whoa, too many
32 | expect(toggle).to.have.attr('aria-checked', 'true')
33 | await userEvent.click(toggle) // 6
34 | expect(toggle).to.have.attr('aria-checked', 'true')
35 | expect(screen.getByTestId('notice')).not.to.be.null
36 | })
37 |
38 | await testStep('can reset the click count', async () => {
39 | await userEvent.click(screen.getByText('Reset'))
40 | expect(screen.queryByTestId('notice')).to.be.null
41 | })
42 |
43 | await testStep('can click again after reset', async () => {
44 | expect(toggle).to.have.attr('aria-checked', 'false')
45 | await userEvent.click(toggle)
46 | expect(toggle).to.have.attr('aria-checked', 'true')
47 |
48 | expect(screen.getByTestId('click-count')).to.have.text('Click count: 1')
49 | })
50 |
--------------------------------------------------------------------------------
/exercises/07.state-reducer/02.solution.default/click-limit.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { render, screen } from '@testing-library/react'
3 | import { userEvent } from '@testing-library/user-event'
4 | import { App } from './app.tsx'
5 |
6 | await testStep('can render the app', () => {
7 | render( )
8 | })
9 |
10 | const toggle = screen.getByRole('switch')
11 |
12 | await testStep('can toggle the switch up to four times', async () => {
13 | expect(toggle).to.have.attr('aria-checked', 'false')
14 | await userEvent.click(toggle) // 1
15 | expect(toggle).to.have.attr('aria-checked', 'true')
16 | await userEvent.click(toggle) // 2
17 | expect(toggle).to.have.attr('aria-checked', 'false')
18 | expect(screen.getByTestId('click-count')).to.have.text('Click count: 2')
19 | await userEvent.click(toggle) // 3
20 | expect(toggle).to.have.attr('aria-checked', 'true')
21 | expect(screen.queryByText(/whoa/i)).to.be.null
22 | await userEvent.click(toggle) // 4
23 | expect(toggle).to.have.attr('aria-checked', 'true')
24 | })
25 |
26 | await testStep('notice is visible after four clicks', () => {
27 | expect(screen.getByText(/whoa/i)).not.to.be.null
28 | })
29 |
30 | await testStep('cannot click beyond the limit of 4', async () => {
31 | await userEvent.click(toggle) // 5: Whoa, too many
32 | expect(toggle).to.have.attr('aria-checked', 'true')
33 | await userEvent.click(toggle) // 6
34 | expect(toggle).to.have.attr('aria-checked', 'true')
35 | expect(screen.getByTestId('notice')).not.to.be.null
36 | })
37 |
38 | await testStep('can reset the click count', async () => {
39 | await userEvent.click(screen.getByText('Reset'))
40 | expect(screen.queryByTestId('notice')).to.be.null
41 | })
42 |
43 | await testStep('can click again after reset', async () => {
44 | expect(toggle).to.have.attr('aria-checked', 'false')
45 | await userEvent.click(toggle)
46 | expect(toggle).to.have.attr('aria-checked', 'true')
47 |
48 | expect(screen.getByTestId('click-count')).to.have.text('Click count: 1')
49 | })
50 |
--------------------------------------------------------------------------------
/exercises/05.prop-getters/README.mdx:
--------------------------------------------------------------------------------
1 | # Prop Collections and Getters
2 |
3 |
4 |
5 |
6 | **One liner:** The Prop Collections and Getters Pattern allows your hook to
7 | support common use cases for UI elements people build with your hook.
8 |
9 |
10 | In typical UI components, you need to take accessibility into account. For a
11 | button functioning as a toggle, it should have the `aria-checked` attribute set
12 | to `true` or `false` if it's toggled on or off. In addition to remembering that,
13 | people need to remember to also add the `onClick` handler to call `toggle`.
14 |
15 | Lots of the reusable/flexible components and hooks that we'll create have some
16 | common use-cases and it'd be cool if we could make it easier to use our
17 | components and hooks the right way without requiring people to wire things up
18 | for common use cases.
19 |
20 | Here's a quick example:
21 |
22 | ```tsx
23 | function useCounter() {
24 | const [count, setCount] = useState(initialCount)
25 | const increment = () => setCount(c => c + 1)
26 | return {
27 | count,
28 | increment,
29 | counterButtonProps: { children: count, onClick: increment },
30 | } // <-- this is the prop collection
31 | }
32 |
33 | function App() {
34 | const counter = useCounter()
35 | return
36 | }
37 | ```
38 |
39 | 🦉 Note that we're moving from a collection of related components (compound
40 | components) to a hook for the upcoming patterns. We'll bring back a `Toggle`
41 | component that uses the hook later, but often it's useful to drop down to a
42 | lower level of abstraction to give consumers more power and then build on top of
43 | that. And you can definitely combine the patterns (read my old post on the
44 | subject demonstrating how to do this with class components:
45 | [Mixing Component Patterns](https://kentcdodds.com/blog/mixing-component-patterns)).
46 |
47 | **Real World Projects that use this pattern:**
48 |
49 | - [downshift](https://github.com/downshift-js/downshift) (uses prop getters)
50 | - [conform's `getFormProps`](https://conform.guide/api/react/getFormProps) (prop
51 | getters)
52 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/exercises/08.control-props/01.solution.control/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer, useRef } from 'react'
2 | import { Switch } from '#shared/switch.tsx'
3 |
4 | function callAll>(
5 | ...fns: Array<((...args: Args) => unknown) | undefined>
6 | ) {
7 | return (...args: Args) => fns.forEach(fn => fn?.(...args))
8 | }
9 |
10 | export type ToggleState = { on: boolean }
11 | export type ToggleAction =
12 | | { type: 'toggle' }
13 | | { type: 'reset'; initialState: ToggleState }
14 |
15 | export function toggleReducer(state: ToggleState, action: ToggleAction) {
16 | switch (action.type) {
17 | case 'toggle': {
18 | return { on: !state.on }
19 | }
20 | case 'reset': {
21 | return action.initialState
22 | }
23 | }
24 | }
25 |
26 | export function useToggle({
27 | initialOn = false,
28 | reducer = toggleReducer,
29 | onChange,
30 | on: controlledOn,
31 | }: {
32 | initialOn?: boolean
33 | reducer?: typeof toggleReducer
34 | onChange?: (state: ToggleState, action: ToggleAction) => void
35 | on?: boolean
36 | } = {}) {
37 | const { current: initialState } = useRef({ on: initialOn })
38 | const [state, dispatch] = useReducer(reducer, initialState)
39 | const onIsControlled = controlledOn != null
40 | const on = onIsControlled ? controlledOn : state.on
41 |
42 | function dispatchWithOnChange(action: ToggleAction) {
43 | if (!onIsControlled) {
44 | dispatch(action)
45 | }
46 | onChange?.(reducer({ ...state, on }, action), action)
47 | }
48 |
49 | const toggle = () => dispatchWithOnChange({ type: 'toggle' })
50 | const reset = () => dispatchWithOnChange({ type: 'reset', initialState })
51 |
52 | function getTogglerProps({
53 | onClick,
54 | ...props
55 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
56 | return {
57 | 'aria-checked': on,
58 | onClick: callAll(onClick, toggle),
59 | ...props,
60 | }
61 | }
62 |
63 | function getResetterProps({
64 | onClick,
65 | ...props
66 | }: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
67 | return {
68 | onClick: callAll(onClick, reset),
69 | ...props,
70 | }
71 | }
72 |
73 | return {
74 | on,
75 | reset,
76 | toggle,
77 | getTogglerProps,
78 | getResetterProps,
79 | }
80 | }
81 |
82 | export function Toggle({
83 | on: controlledOn,
84 | onChange,
85 | }: {
86 | on?: boolean
87 | onChange?: (state: ToggleState, action: ToggleAction) => void
88 | }) {
89 | const { on, getTogglerProps } = useToggle({ on: controlledOn, onChange })
90 | const props = getTogglerProps({ on })
91 | return
92 | }
93 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "advanced-react-patterns",
3 | "private": true,
4 | "epicshop": {
5 | "title": "Advanced React Patterns 🤯",
6 | "subtitle": "Learn how to build simple and flexible React Components and Hooks using modern patterns",
7 | "githubRepo": "https://github.com/epicweb-dev/advanced-react-patterns",
8 | "stackBlitzConfig": {
9 | "view": "editor"
10 | },
11 | "product": {
12 | "host": "www.epicreact.dev",
13 | "slug": "advanced-react-patterns",
14 | "displayName": "EpicReact.dev",
15 | "displayNameShort": "Epic React",
16 | "logo": "/logo.svg",
17 | "discordChannelId": "1285244676286189569",
18 | "discordTags": [
19 | "1285246046498328627",
20 | "1285245811944325193"
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 | "#shared/*": "./shared/*"
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 | "test": "cd ./epicshop && npm test",
44 | "validate:all": "npm-run-all --parallel --print-label --print-name --continue-on-error lint typecheck"
45 | },
46 | "keywords": [],
47 | "author": "Kent C. Dodds (https://kentcdodds.com/)",
48 | "license": "GPL-3.0-only",
49 | "dependencies": {
50 | "@epic-web/config": "^1.16.3",
51 | "react": "^19.0.0",
52 | "react-dom": "^19.0.0"
53 | },
54 | "devDependencies": {
55 | "@epic-web/workshop-utils": "^6.47.1",
56 | "@testing-library/react": "^16.1.0",
57 | "@testing-library/user-event": "^14.5.2",
58 | "@types/node": "^22.10.1",
59 | "@types/react": "^19.0.0",
60 | "@types/react-dom": "^19.0.0",
61 | "eslint": "^9.16.0",
62 | "npm-run-all": "^4.1.5",
63 | "prettier": "^3.4.2",
64 | "typescript": "^5.7.2"
65 | },
66 | "engines": {
67 | "node": ">=20",
68 | "npm": ">=9.3.0",
69 | "git": ">=2.18.0"
70 | },
71 | "prettier": "@epic-web/config/prettier",
72 | "prettierIgnore": [
73 | "node_modules",
74 | "**/build/**",
75 | "**/public/build/**",
76 | ".env",
77 | "**/package.json",
78 | "**/tsconfig.json",
79 | "**/package-lock.json",
80 | "**/playwright-report/**"
81 | ]
82 | }
83 |
--------------------------------------------------------------------------------
/shared/utils.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | // this needs to be put into an open source project on npm at some point...
4 |
5 | const isProd = process.env.NODE_ENV === 'production'
6 |
7 | const useControlPropWarnings = isProd
8 | ? () => {}
9 | : function useControlPropWarnings({
10 | controlPropValue,
11 | controlPropName,
12 | componentName,
13 | hasOnChange,
14 | readOnly,
15 | readOnlyProp,
16 | initialValueProp,
17 | onChangeProp,
18 | }: {
19 | controlPropValue: unknown
20 | controlPropName: string
21 | componentName: string
22 | hasOnChange: boolean
23 | readOnly?: boolean
24 | readOnlyProp: string
25 | initialValueProp: string
26 | onChangeProp: string
27 | }) {
28 | const hasWarnedReadOnlyRef = useRef(false)
29 | const hasWarnedSwitchRef = useRef(false)
30 | const isControlled = controlPropValue != null
31 | const { current: wasControlled } = useRef(isControlled)
32 |
33 | useEffect(() => {
34 | if (
35 | !hasWarnedReadOnlyRef.current &&
36 | !hasOnChange &&
37 | isControlled &&
38 | !readOnly
39 | ) {
40 | hasWarnedReadOnlyRef.current = true
41 | console.error(
42 | `The control prop \`${controlPropName}\` was provided to \`${componentName}\` without the prop \`${onChangeProp}\`. This will result in a read-only \`${controlPropName}\` value. If you want it to be mutable, use \`${initialValueProp}\`. Otherwise, set either \`${onChangeProp}\` or \`${readOnlyProp}\`.`,
43 | )
44 | }
45 | }, [
46 | componentName,
47 | controlPropName,
48 | isControlled,
49 | hasOnChange,
50 | readOnly,
51 | onChangeProp,
52 | initialValueProp,
53 | readOnlyProp,
54 | ])
55 |
56 | useEffect(() => {
57 | if (hasWarnedSwitchRef.current) return
58 | if (isControlled && !wasControlled) {
59 | hasWarnedSwitchRef.current = true
60 | console.error(
61 | `\`${componentName}\` is changing from uncontrolled to be controlled. Components should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled \`${componentName}\` for the lifetime of the component. Check the \`${controlPropName}\` prop.`,
62 | )
63 | }
64 | if (!isControlled && wasControlled) {
65 | hasWarnedSwitchRef.current = true
66 | console.error(
67 | `\`${componentName}\` is changing from controlled to be uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled \`${componentName}\` for the lifetime of the component. Check the \`${controlPropName}\` prop.`,
68 | )
69 | }
70 | }, [componentName, controlPropName, isControlled, wasControlled])
71 | }
72 |
73 | export { useControlPropWarnings }
74 |
--------------------------------------------------------------------------------
/exercises/01.composition/01.problem.compose/index.css:
--------------------------------------------------------------------------------
1 | #app-root {
2 | max-width: 800px;
3 | min-width: 480px;
4 | margin: auto;
5 | }
6 |
7 | .spacer[data-size='base'] {
8 | height: 2rem;
9 | }
10 |
11 | .spacer[data-size='sm'] {
12 | height: 1rem;
13 | }
14 |
15 | .spacer[data-size='xs'] {
16 | height: 0.5rem;
17 | }
18 |
19 | .spacer[data-size='lg'] {
20 | height: 3rem;
21 | }
22 |
23 | .spacer[data-size='xl'] {
24 | height: 5rem;
25 | }
26 |
27 | nav {
28 | display: flex;
29 | justify-content: space-between;
30 | gap: 3rem;
31 | align-items: center;
32 | }
33 | nav ul {
34 | flex: auto;
35 | list-style: none;
36 | display: flex;
37 | justify-content: space-between;
38 | padding-left: 0px;
39 | }
40 | nav img {
41 | width: 64px;
42 | height: 64px;
43 | border-radius: 4px;
44 | }
45 | nav a,
46 | nav a:visited {
47 | color: hsl(302deg 76% 34%);
48 | text-decoration: none;
49 | }
50 | nav a:hover,
51 | nav a:active,
52 | nav a:focus {
53 | text-decoration: underline;
54 | }
55 | nav {
56 | position: relative;
57 | padding-bottom: 1rem;
58 | }
59 | nav::after {
60 | content: '';
61 | border-bottom: 0.3rem solid var(--accent-color);
62 | filter: drop-shadow(0rem 0rem 0.06rem var(--accent-color));
63 | position: absolute;
64 | bottom: 0;
65 | left: 0;
66 | right: 0;
67 | }
68 |
69 | main {
70 | display: flex;
71 | gap: 4rem;
72 | }
73 |
74 | .sport-list {
75 | overflow-y: scroll;
76 | overflow-x: hidden;
77 | max-width: 30vw;
78 | min-width: 200px;
79 | }
80 |
81 | .sport-list ul {
82 | list-style: none;
83 | padding: 0px;
84 | display: flex;
85 | flex-direction: column;
86 | gap: 1.5rem;
87 | }
88 |
89 | .sport-list ul li button {
90 | display: flex;
91 | gap: 1rem;
92 | align-items: center;
93 | background-color: transparent;
94 | border: none;
95 | cursor: pointer;
96 | background-color: var(--accent-color);
97 | padding: 0.5rem 1rem;
98 | border-radius: 2px;
99 | width: 100%;
100 | }
101 | .sport-list ul li button small {
102 | position: relative;
103 | }
104 |
105 | .sport-list .sport-list-info {
106 | display: flex;
107 | flex-direction: column;
108 | text-align: left;
109 | }
110 |
111 | .sport-list ul li img {
112 | width: 64px;
113 | height: 64px;
114 | border-radius: 4px;
115 | aspect-ratio: 1;
116 | object-fit: contain;
117 | }
118 |
119 | .sport-details img {
120 | width: 256px;
121 | height: 256px;
122 | aspect-ratio: 1;
123 | object-fit: contain;
124 | }
125 |
126 | footer {
127 | position: relative;
128 | padding-top: 1rem;
129 | }
130 | footer::before {
131 | content: '';
132 | border-top: 0.3rem solid var(--accent-color);
133 | filter: drop-shadow(0rem 0rem 0.06rem var(--accent-color));
134 | position: absolute;
135 | top: 0;
136 | left: 0;
137 | right: 0;
138 | }
139 |
--------------------------------------------------------------------------------
/exercises/01.composition/01.solution.compose/index.css:
--------------------------------------------------------------------------------
1 | #app-root {
2 | max-width: 800px;
3 | min-width: 480px;
4 | margin: auto;
5 | }
6 |
7 | .spacer[data-size='base'] {
8 | height: 2rem;
9 | }
10 |
11 | .spacer[data-size='sm'] {
12 | height: 1rem;
13 | }
14 |
15 | .spacer[data-size='xs'] {
16 | height: 0.5rem;
17 | }
18 |
19 | .spacer[data-size='lg'] {
20 | height: 3rem;
21 | }
22 |
23 | .spacer[data-size='xl'] {
24 | height: 5rem;
25 | }
26 |
27 | nav {
28 | display: flex;
29 | justify-content: space-between;
30 | gap: 3rem;
31 | align-items: center;
32 | }
33 | nav ul {
34 | flex: auto;
35 | list-style: none;
36 | display: flex;
37 | justify-content: space-between;
38 | padding-left: 0px;
39 | }
40 | nav img {
41 | width: 64px;
42 | height: 64px;
43 | border-radius: 4px;
44 | }
45 | nav a,
46 | nav a:visited {
47 | color: hsl(302deg 76% 34%);
48 | text-decoration: none;
49 | }
50 | nav a:hover,
51 | nav a:active,
52 | nav a:focus {
53 | text-decoration: underline;
54 | }
55 | nav {
56 | position: relative;
57 | padding-bottom: 1rem;
58 | }
59 | nav::after {
60 | content: '';
61 | border-bottom: 0.3rem solid var(--accent-color);
62 | filter: drop-shadow(0rem 0rem 0.06rem var(--accent-color));
63 | position: absolute;
64 | bottom: 0;
65 | left: 0;
66 | right: 0;
67 | }
68 |
69 | main {
70 | display: flex;
71 | gap: 4rem;
72 | }
73 |
74 | .sport-list {
75 | overflow-y: scroll;
76 | overflow-x: hidden;
77 | max-width: 30vw;
78 | min-width: 200px;
79 | }
80 |
81 | .sport-list ul {
82 | list-style: none;
83 | padding: 0px;
84 | display: flex;
85 | flex-direction: column;
86 | gap: 1.5rem;
87 | }
88 |
89 | .sport-list ul li button {
90 | display: flex;
91 | gap: 1rem;
92 | align-items: center;
93 | background-color: transparent;
94 | border: none;
95 | cursor: pointer;
96 | background-color: var(--accent-color);
97 | padding: 0.5rem 1rem;
98 | border-radius: 2px;
99 | width: 100%;
100 | }
101 | .sport-list ul li button small {
102 | position: relative;
103 | }
104 |
105 | .sport-list .sport-list-info {
106 | display: flex;
107 | flex-direction: column;
108 | text-align: left;
109 | }
110 |
111 | .sport-list ul li img {
112 | width: 64px;
113 | height: 64px;
114 | border-radius: 4px;
115 | aspect-ratio: 1;
116 | object-fit: contain;
117 | }
118 |
119 | .sport-details img {
120 | width: 256px;
121 | height: 256px;
122 | aspect-ratio: 1;
123 | object-fit: contain;
124 | }
125 |
126 | footer {
127 | position: relative;
128 | padding-top: 1rem;
129 | }
130 | footer::before {
131 | content: '';
132 | border-top: 0.3rem solid var(--accent-color);
133 | filter: drop-shadow(0rem 0rem 0.06rem var(--accent-color));
134 | position: absolute;
135 | top: 0;
136 | left: 0;
137 | right: 0;
138 | }
139 |
--------------------------------------------------------------------------------
/exercises/08.control-props/README.mdx:
--------------------------------------------------------------------------------
1 | # Control Props
2 |
3 |
4 |
5 |
6 | **One liner:** The Control Props pattern allows users to completely control
7 | state values within your component. This differs from the state reducer
8 | pattern in the fact that you can not only change the state changes based on
9 | actions dispatched but you _also_ can trigger state changes from outside the
10 | component or hook as well.
11 |
12 |
13 | Sometimes, people want to be able to manage the internal state of our component
14 | from the outside. The state reducer allows them to manage what state changes are
15 | made when a state change happens, but sometimes people may want to make state
16 | changes themselves. We can allow them to do this with a feature called "Control
17 | Props."
18 |
19 | This concept is basically the same as
20 | [controlled form elements 📜](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable)
21 | in React that you've probably used many times.
22 |
23 | ```tsx
24 | function MyCapitalizedInput() {
25 | const [capitalizedValue, setCapitalizedValue] = useState('')
26 |
27 | return (
28 | setCapitalizedValue(e.target.value.toUpperCase())}
31 | />
32 | )
33 | }
34 | ```
35 |
36 | In this case, the "component" that's implemented the "control props" pattern is
37 | the ` `. Normally it controls state itself (like if you render
38 | ` ` by itself with no `value` prop). But once you add the `value` prop,
39 | suddenly the ` ` takes the back seat and instead makes "suggestions" to
40 | you via the `onChange` prop on the state updates that it would normally make
41 | itself.
42 |
43 | This flexibility allows us to change how the state is managed (by capitalizing
44 | the value), and it also allows us to programmatically change the state whenever
45 | we want to, which enables this kind of synchronized input situation:
46 |
47 | ```tsx
48 | function MyTwoInputs() {
49 | const [capitalizedValue, setCapitalizedValue] = useState('')
50 | const [lowerCasedValue, setLowerCasedValue] = useState('')
51 |
52 | function handleInputChange(e) {
53 | setCapitalizedValue(e.target.value.toUpperCase())
54 | setLowerCasedValue(e.target.value.toLowerCase())
55 | }
56 |
57 | return (
58 | <>
59 |
60 |
61 | >
62 | )
63 | }
64 | ```
65 |
66 | **Real World Projects that use this pattern:**
67 |
68 | - [downshift](https://github.com/downshift-js/downshift)
69 | - [`@radix-ui/react-select`](https://www.radix-ui.com/primitives/docs/components/select#controlling-the-value-displayed-in-the-trigger)
70 |
--------------------------------------------------------------------------------
/exercises/03.compound-components/README.mdx:
--------------------------------------------------------------------------------
1 | # Compound Components
2 |
3 |
4 |
5 |
6 | **One liner:** The Compound Components Pattern enables you to provide a set of
7 | components that implicitly share state for a simple yet powerful declarative
8 | API for reusable components.
9 |
10 |
11 | Compound components are components that work together to form a complete UI. The
12 | classic example of this is `` and `` in HTML:
13 |
14 | ```html
15 |
16 | Option 1
17 | Option 2
18 |
19 | ```
20 |
21 | The `` is the element responsible for managing the state of the UI, and
22 | the `` elements are essentially more configuration for how the select
23 | should operate (specifically, which options are available and their values).
24 |
25 | Let's imagine that we were going to implement this native control manually. A
26 | naive implementation would look something like this:
27 |
28 | ```tsx
29 |
35 | ```
36 |
37 | This works fine, but it's less extensible/flexible than a compound components
38 | API. For example. What if I want to supply additional attributes on the
39 | ` ` that's rendered, or I want the `display` to change based on whether
40 | it's selected? We can easily add API surface area to support these use cases,
41 | but that's just more for us to code and more for users to learn. That's where
42 | compound components come in really handy!
43 |
44 | For the rest of the exercises in this workshop, we'll be working with a simple
45 | ` ` component.
46 |
47 | Every reusable component starts out as a simple implementation for a specific
48 | use case. It's advisable to not overcomplicate your components and try to solve
49 | every conceivable problem that you don't yet have (and likely will never have).
50 | But as changes come (and they almost always do), then you'll want the
51 | implementation of your component to be flexible and changeable. One of the most
52 | important abilities of a software developer is optimizing for change. Learning
53 | how to do that is the point of much of this workshop.
54 |
55 | This is why we're starting with a super simple ` ` component. You'll be
56 | surprised how feature-rich we can make a simple toggle component. Keeping it
57 | simple allows us to focus in on making it reusable without getting distracted by
58 | the complexities of the feature implementation (like we would if we were
59 | building a date picker or something 😅).
60 |
61 | Shout-out to [Ryan Florence](https://twitter.com/ryanflorence) for creating this
62 | pattern.
63 |
64 | **Real World Projects that use this pattern:**
65 |
66 | - [`@radix-ui/react-tabs`](https://www.radix-ui.com/primitives/docs/components/tabs)
67 | - [`@radix-ui/react-accordion`](https://www.radix-ui.com/primitives/docs/components/accordion)
68 | - Actually most of [Radix UI](https://www.radix-ui.com/primitives) implements
69 | this pattern
70 |
--------------------------------------------------------------------------------
/shared/sports.tsx:
--------------------------------------------------------------------------------
1 | import { type SportData } from './types.ts'
2 |
3 | export function SportDataView({ sport }: { sport: SportData }) {
4 | return (
5 |
6 |
7 |
8 |
9 |
12 |
13 |
14 | {sport.tricks.map(trick => (
15 |
16 | {trick.name} :{' '}
17 |
18 | {trick.points} ({trick.type})
19 |
20 |
21 | ))}
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | export const allSports: Record = {
29 | onewheel: {
30 | id: 'jfoJDiw8df.sdf',
31 | name: 'Floater',
32 | image: '/img/onewheel.png',
33 | color: '#e98080',
34 | tricks: [
35 | {
36 | name: 'Tail Grind',
37 | type: 'Grind',
38 | points: 45,
39 | },
40 | {
41 | name: 'Drop',
42 | type: 'Air',
43 | points: 60,
44 | },
45 | {
46 | name: 'Curb Hop',
47 | type: 'Street',
48 | points: 30,
49 | },
50 | {
51 | name: 'Space Tornado',
52 | type: 'Spin',
53 | points: 75,
54 | },
55 | ],
56 | },
57 | ski: {
58 | id: 'osdiCjew.s8efsz',
59 | image: '/img/ski.png',
60 | name: 'Skier',
61 | color: '#f4f4ad',
62 | tricks: [
63 | {
64 | name: 'Air to Fakie',
65 | type: 'Air',
66 | points: 45,
67 | },
68 | {
69 | name: 'Kangaroo Flip',
70 | type: 'Flip',
71 | points: 60,
72 | },
73 | {
74 | name: 'Misty Flip',
75 | type: 'Flip',
76 | points: 70,
77 | },
78 | {
79 | name: 'Alley-Oop',
80 | type: 'Spin',
81 | points: 80,
82 | },
83 | {
84 | name: 'Cab 1440 Mute',
85 | type: 'Spin',
86 | points: 90,
87 | },
88 | {
89 | name: 'Box Slide',
90 | type: 'Grind',
91 | points: 30,
92 | },
93 | ],
94 | },
95 | snowboard: {
96 | id: 'sdfj8sdfj.sdfj8sdfj',
97 | image: '/img/snowboard.png',
98 | name: 'Snowboard',
99 | color: '#a0afdd',
100 | tricks: [
101 | {
102 | name: 'Butter',
103 | type: 'Slide',
104 | points: 25,
105 | },
106 | {
107 | name: 'Tripod',
108 | type: 'Slide',
109 | points: 40,
110 | },
111 | {
112 | name: 'Melon Grab',
113 | type: 'Grab',
114 | points: 55,
115 | },
116 | {
117 | name: 'Backflip',
118 | type: 'Flip',
119 | points: 70,
120 | },
121 | {
122 | name: 'Tail Press',
123 | type: 'Grind',
124 | points: 65,
125 | },
126 | ],
127 | },
128 | soccer: {
129 | id: 'rix38.sfjgihxl',
130 | image: '/img/soccer.png',
131 | name: 'Soccer',
132 | color: '#c0c5c1',
133 | tricks: [
134 | {
135 | name: 'Rabona',
136 | type: 'Pass',
137 | points: 25,
138 | },
139 | {
140 | name: 'Scissor',
141 | type: 'Dribble',
142 | points: 40,
143 | },
144 | {
145 | name: 'Rainbow',
146 | type: 'Dribble',
147 | points: 55,
148 | },
149 | {
150 | name: 'El Tornado',
151 | type: 'Shot',
152 | points: 70,
153 | },
154 | ],
155 | },
156 | }
157 |
--------------------------------------------------------------------------------
/shared/toggle.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, testStep } from '@epic-web/workshop-utils/test'
2 | import { screen } from '@testing-library/dom'
3 | import { userEvent } from '@testing-library/user-event'
4 |
5 | export async function verifySimpleToggleWithText() {
6 | const toggle = await testStep('Switch is rendered', () =>
7 | screen.findByRole('switch', { name: 'Toggle' }),
8 | )
9 | await testStep('Switch is off to start', () =>
10 | expect(toggle).to.have.attr('aria-checked', 'false'),
11 | )
12 | await testStep('Renders "The button is off"', () =>
13 | screen.findByText('The button is off'),
14 | )
15 | await testStep(
16 | 'Does not render "The button is on"',
17 | () => expect(screen.queryByText('The button is on')).to.be.null,
18 | )
19 | await userEvent.click(toggle)
20 | await testStep(
21 | 'Clicking the switch turns it on and the text updates',
22 | async () => {
23 | await screen.findByText('The button is on')
24 | expect(screen.queryByText('The button is off')).to.be.null
25 | expect(toggle).to.have.attr('aria-checked', 'true')
26 | },
27 | )
28 |
29 | await userEvent.click(toggle)
30 | await testStep(
31 | 'Clicking the switch again turns it off and the text updates',
32 | async () => {
33 | await screen.findByText('The button is off')
34 | expect(screen.queryByText('The button is on')).to.be.null
35 | expect(toggle).to.have.attr('aria-checked', 'false')
36 | },
37 | )
38 | }
39 |
40 | export async function verifySimpleToggle() {
41 | const toggle = await testStep('Switch is rendered', () =>
42 | screen.findByRole('switch', { name: 'Toggle' }),
43 | )
44 | await testStep('Switch is off to start', () =>
45 | expect(toggle).to.have.attr('aria-checked', 'false'),
46 | )
47 | await userEvent.click(toggle)
48 | await testStep('Clicking the switch turns it on', async () => {
49 | expect(toggle).to.have.attr('aria-checked', 'true')
50 | })
51 |
52 | await userEvent.click(toggle)
53 | await testStep('Clicking the switch again turns it off', async () => {
54 | expect(toggle).to.have.attr('aria-checked', 'false')
55 | })
56 | }
57 |
58 | export async function verifySimpleToggleOnToStart() {
59 | const toggle = await testStep('Switch is rendered', () =>
60 | screen.findByRole('switch', { name: 'Toggle' }),
61 | )
62 | await testStep('Switch is on to start', () =>
63 | expect(toggle).to.have.attr('aria-checked', 'true'),
64 | )
65 | await userEvent.click(toggle)
66 | await testStep('Clicking the switch turns it off', async () => {
67 | expect(toggle).to.have.attr('aria-checked', 'false')
68 | })
69 |
70 | await userEvent.click(toggle)
71 | await testStep('Clicking the switch again turns it on', async () => {
72 | expect(toggle).to.have.attr('aria-checked', 'true')
73 | })
74 | }
75 |
76 | export async function verifyIsToggle(toggle: HTMLElement) {
77 | await testStep('Switch is off to start', () =>
78 | expect(toggle).to.have.attr('aria-checked', 'false'),
79 | )
80 | await userEvent.click(toggle)
81 | await testStep('Clicking the switch turns it on', async () => {
82 | expect(toggle).to.have.attr('aria-checked', 'true')
83 | })
84 |
85 | await userEvent.click(toggle)
86 | await testStep('Clicking the switch again turns it off', async () => {
87 | expect(toggle).to.have.attr('aria-checked', 'false')
88 | })
89 | }
90 |
--------------------------------------------------------------------------------
/exercises/01.composition/01.solution.compose/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 | import { SportDataView, allSports } from '#shared/sports.tsx'
4 | import { type SportData, type User } from '#shared/types.tsx'
5 |
6 | function App() {
7 | const [user] = useState({ name: 'Kody', image: '/img/kody.png' })
8 | const [sportList] = useState>(() => Object.values(allSports))
9 | const [selectedSport, setSelectedSport] = useState(null)
10 |
11 | return (
12 |
16 |
} />
17 |
18 |
(
22 |
23 | setSelectedSport(p)}
26 | />
27 |
28 | ))}
29 | />
30 | }
31 | content={ }
32 | />
33 |
34 |
37 |
38 | )
39 | }
40 |
41 | function Nav({ avatar }: { avatar: React.ReactNode }) {
42 | return (
43 |
44 |
55 |
56 | {avatar}
57 |
58 |
59 | )
60 | }
61 |
62 | function Main({
63 | sidebar,
64 | content,
65 | }: {
66 | sidebar: React.ReactNode
67 | content: React.ReactNode
68 | }) {
69 | return (
70 |
71 | {sidebar}
72 | {content}
73 |
74 | )
75 | }
76 |
77 | function List({ listItems }: { listItems: Array }) {
78 | return (
79 |
82 | )
83 | }
84 |
85 | function SportListItemButton({
86 | sport,
87 | onClick,
88 | }: {
89 | sport: SportData
90 | onClick: () => void
91 | }) {
92 | return (
93 |
99 |
100 |
101 | {sport.name}
102 |
103 |
104 | )
105 | }
106 |
107 | function Details({ selectedSport }: { selectedSport: SportData | null }) {
108 | return (
109 |
110 | {selectedSport ? (
111 |
112 | ) : (
113 |
Select a Sport
114 | )}
115 |
116 | )
117 | }
118 |
119 | function Footer({ footerMessage }: { footerMessage: string }) {
120 | return (
121 |
122 | {footerMessage}
123 |
124 | )
125 | }
126 |
127 | const rootEl = document.createElement('div')
128 | document.body.append(rootEl)
129 | ReactDOM.createRoot(rootEl).render( )
130 |
--------------------------------------------------------------------------------
/epicshop/fix.js:
--------------------------------------------------------------------------------
1 | // This should run by node without any dependencies
2 | // because you may need to run it without deps.
3 |
4 | import cp from 'node:child_process'
5 | import fs from 'node:fs'
6 | import path from 'node:path'
7 | import { fileURLToPath } from 'node:url'
8 |
9 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
10 | const here = (...p) => path.join(__dirname, ...p)
11 | const VERBOSE = false
12 | const logVerbose = (...args) => (VERBOSE ? console.log(...args) : undefined)
13 |
14 | const workshopRoot = here('..')
15 | const examples = (await readDir(here('../examples'))).map(dir =>
16 | here(`../examples/${dir}`),
17 | )
18 | const exercises = (await readDir(here('../exercises')))
19 | .map(name => here(`../exercises/${name}`))
20 | .filter(filepath => fs.statSync(filepath).isDirectory())
21 | const exerciseApps = (
22 | await Promise.all(
23 | exercises.flatMap(async exercise => {
24 | return (await readDir(exercise))
25 | .filter(dir => {
26 | return /(problem|solution)/.test(dir)
27 | })
28 | .map(dir => path.join(exercise, dir))
29 | }),
30 | )
31 | ).flat()
32 | const exampleApps = (await readDir(here('../examples'))).map(dir =>
33 | here(`../examples/${dir}`),
34 | )
35 | const apps = [...exampleApps, ...exerciseApps]
36 |
37 | const appsWithPkgJson = [...examples, ...apps].filter(app => {
38 | const pkgjsonPath = path.join(app, 'package.json')
39 | return exists(pkgjsonPath)
40 | })
41 |
42 | // update the package.json file name property
43 | // to match the parent directory name + directory name
44 | // e.g. exercises/01-goo/problem.01-great
45 | // name: "exercises__sep__01-goo.problem__sep__01-great"
46 |
47 | function relativeToWorkshopRoot(dir) {
48 | return dir.replace(`${workshopRoot}${path.sep}`, '')
49 | }
50 |
51 | await updatePkgNames()
52 | await updateTsconfig()
53 |
54 | async function updatePkgNames() {
55 | for (const file of appsWithPkgJson) {
56 | const pkgjsonPath = path.join(file, 'package.json')
57 | const pkg = JSON.parse(await fs.promises.readFile(pkgjsonPath, 'utf8'))
58 | pkg.name = relativeToWorkshopRoot(file).replace(/\\|\//g, '__sep__')
59 | const written = await writeIfNeeded(
60 | pkgjsonPath,
61 | `${JSON.stringify(pkg, null, 2)}\n`,
62 | )
63 | if (written) {
64 | console.log(`updated ${path.relative(process.cwd(), pkgjsonPath)}`)
65 | }
66 | }
67 | }
68 |
69 | async function updateTsconfig() {
70 | const tsconfig = {
71 | files: [],
72 | exclude: ['node_modules'],
73 | references: appsWithPkgJson.map(a => ({
74 | path: relativeToWorkshopRoot(a).replace(/\\/g, '/'),
75 | })),
76 | }
77 | const written = await writeIfNeeded(
78 | path.join(workshopRoot, 'tsconfig.json'),
79 | `${JSON.stringify(tsconfig, null, 2)}\n`,
80 | { parser: 'json' },
81 | )
82 |
83 | if (written) {
84 | // delete node_modules/.cache
85 | const cacheDir = path.join(workshopRoot, 'node_modules', '.cache')
86 | if (exists(cacheDir)) {
87 | await fs.promises.rm(cacheDir, { recursive: true })
88 | }
89 | console.log('all fixed up')
90 | }
91 | }
92 |
93 | async function writeIfNeeded(filepath, content) {
94 | const oldContent = await fs.promises.readFile(filepath, 'utf8')
95 | if (oldContent !== content) {
96 | await fs.promises.writeFile(filepath, content)
97 | }
98 | return oldContent !== content
99 | }
100 |
101 | function exists(p) {
102 | if (!p) return false
103 | try {
104 | fs.statSync(p)
105 | return true
106 | } catch (error) {
107 | return false
108 | }
109 | }
110 |
111 | async function readDir(dir) {
112 | if (exists(dir)) {
113 | return fs.promises.readdir(dir)
114 | }
115 | return []
116 | }
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Learn how to build simple and flexible React Components and Hooks using
5 | modern patterns
6 |
7 |
8 | Not only learn great patterns you can use but also the strengths and
9 | weaknesses of each, so you know which to reach for to provide your custom
10 | hooks and components the flexibility and power you need.
11 |
12 |
13 |
14 |
15 |
16 |
27 |
28 |
29 |
30 |
31 | [![Build Status][build-badge]][build]
32 | [![GPL 3.0 License][license-badge]][license]
33 | [![Code of Conduct][coc-badge]][coc]
34 |
35 |
36 | ## Prerequisites
37 |
38 | - The more experience you have with building React abstractions, the more
39 | helpful this workshop will be for you.
40 |
41 | ## Pre-workshop Resources
42 |
43 | Here are some resources you can read before taking the workshop to get you up to
44 | speed on some of the tools and concepts we'll be covering:
45 |
46 | - [Inversion of Control](https://kentcdodds.com/blog/inversion-of-control)
47 | - [Implement Inversion of Control](https://egghead.io/lessons/egghead-implement-inversion-of-control?pl=kent-s-blog-posts-as-screencasts-eefa540c&af=5236ad)
48 |
49 | ## System Requirements
50 |
51 | - [git][git] v2.18 or greater
52 | - [NodeJS][node] v20 or greater
53 | - [npm][npm] v8 or greater
54 |
55 | All of these must be available in your `PATH`. To verify things are set up
56 | properly, you can run this:
57 |
58 | ```shell
59 | git --version
60 | node --version
61 | npm --version
62 | ```
63 |
64 | If you have trouble with any of these, learn more about the PATH environment
65 | variable and how to fix it here for [windows][win-path] or
66 | [mac/linux][mac-path].
67 |
68 | ## Setup
69 |
70 | Use the Epic Workshop CLI to get this setup:
71 |
72 | ```sh nonumber
73 | npx --yes epicshop@latest add advanced-react-patterns
74 | ```
75 |
76 | If you experience errors here, please open [an issue][issue] with as many
77 | details as you can offer.
78 |
79 | ## Starting the app
80 |
81 | Once you have the setup finished, you can start the app with:
82 |
83 | ```
84 | npm start
85 | ```
86 |
87 | ## The Workshop App
88 |
89 | Learn all about the workshop app on the
90 | [Epic Web Getting Started Guide](https://www.epicweb.dev/get-started).
91 |
92 | [](https://www.epicweb.dev/get-started)
93 |
94 |
95 | [npm]: https://www.npmjs.com/
96 | [node]: https://nodejs.org
97 | [git]: https://git-scm.com/
98 | [build-badge]: https://img.shields.io/github/actions/workflow/status/epicweb-dev/advanced-react-patterns/validate.yml?branch=main&logo=github&style=flat-square
99 | [build]: https://github.com/epicweb-dev/advanced-react-patterns/actions?query=workflow%3Avalidate
100 | [license-badge]: https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square
101 | [license]: https://github.com/epicweb-dev/advanced-react-patterns/blob/main/LICENSE.md
102 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square
103 | [coc]: https://kentcdodds.com/conduct
104 | [win-path]: https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/
105 | [mac-path]: http://stackoverflow.com/a/24322978/971592
106 | [issue]: https://github.com/epicweb-dev/advanced-react-patterns/issues/new
107 |
108 |
--------------------------------------------------------------------------------
/exercises/02.latest-ref/01.problem.ref/README.mdx:
--------------------------------------------------------------------------------
1 | # Latest Ref
2 |
3 |
4 |
5 | 👨💼 In our exercise, we have a `useDebounce` function that isn't working the way
6 | we want with hooks. We're going to need to "change the default" using the latest
7 | ref pattern.
8 |
9 | `debounce` is a pattern that's often used in user-input fields. For example, if
10 | you've got a signup form where the user can select their username, you probably
11 | want to validate for the user that the username is not taken. You want to do it
12 | when the user's done typing but without requiring them to do anything to trigger
13 | the validation. With a debounced function, you could say when the user stops
14 | typing for 400ms you can trigger the validation. If they start typing again
15 | after only 350ms then you want to start over and wait again until the user
16 | pauses for 400ms.
17 |
18 | In this exercise, the `debounce` function is already written. Even the
19 | `useDebounce` hook is implemented for you. Your job is to implement the latest
20 | ref pattern to fix its behavior.
21 |
22 | Our example here is a counter button that has a debounced increment function. We
23 | want to make it so this works:
24 |
25 | - The step is `1`
26 | - The user clicks the button
27 | - The user updates the step value to `2`
28 | - The user clicks the button again (before the debounce timer completes)
29 | - The debounce timer completes for both clicks
30 | - The count value should be `2` (instead of `1`)
31 |
32 | (Keep in mind, the tests are there to help you know you got it right).
33 |
34 | Before continuing here, please familiarize yourself with the code to know how
35 | it's implemented... Got it? Great, let's continue.
36 |
37 | Right now, you can play around with two different problems with the way our
38 | exercise is implemented:
39 |
40 | ```ts
41 | // option 1:
42 | // ...
43 | const increment = () => setCount(c => c + step)
44 | const debouncedIncrement = useDebounce(increment, 3000)
45 | // ...
46 | ```
47 |
48 | The problem here is `useDebounce` list `increment` in the dependency list for
49 | `useMemo`. For this reason, any time there's a state update, we create a _new_
50 | debounced version of that function so the `timer` in that debounce function's
51 | closure is different from the previous which means we don't cancel that timeout.
52 | Ultimately this is the bug our users experience:
53 |
54 | - The user clicks the button
55 | - The user updates the step value
56 | - The user clicks the button again
57 | - The first debounce timer completes
58 | - The count value is incremented by the step value at the time the first click
59 | happened
60 | - The second debounce timer completes
61 | - The count value is incremented by the step value at the time the second click
62 | happened
63 |
64 | This is not what we want at all! And the reason it's a problem is because we're
65 | not memoizing the callback that's going into our `useMemo` dependency list.
66 |
67 | So the alternative solution is we could change our `useDebounce` API to require
68 | you pass a memoized callback:
69 |
70 | ```ts
71 | // option 2:
72 | // ...
73 | const increment = useCallback(() => setCount(c => c + step), [step])
74 | const debouncedIncrement = useDebounce(increment, 3000)
75 | // ...
76 | ```
77 |
78 | But again, this callback function will be updated when the `step` value changes
79 | which means we'll get another instance of the `debouncedIncrement`. Dah! So the
80 | user experience doesn't actually change with this adjustment _and_ we have a
81 | less fun API. The latest ref pattern will give us a nice API and we'll avoid
82 | this problem.
83 |
84 | I've made the debounce value last `3000ms` to make it easier for you to observe
85 | and test the behavior, but you can feel free to adjust that as you like. The
86 | tests can also help you make sure you've got things working well.
87 |
88 |
89 | The debounce behavior means that this will make the tests a bit slow. Don't
90 | worry though, the rest of the tests will be quite fast.
91 |
92 |
--------------------------------------------------------------------------------
/exercises/04.slots/README.mdx:
--------------------------------------------------------------------------------
1 | # Slots
2 |
3 |
4 |
5 |
6 | **One liner:** Slots allow you to specify an element which takes on a
7 | particular role in the overall collection of components.
8 |
9 |
10 | This pattern is particularly useful for situations where you're building a UI
11 | library with a lot of components that need to work together. It's a way to
12 | provide a flexible API for your components that allows them to be used in a
13 | variety of contexts.
14 |
15 | If you're building a component library, you have to deal with two competing
16 | interests:
17 |
18 | 1. Correctness
19 | 2. Flexibility
20 |
21 | You want to make sure people don't mess up things like accessibility, but you
22 | also want to give them the flexibility to build things the way their diverse
23 | needs require. Slots can help with this.
24 |
25 | Here's a quick example of a component that uses slots (from the
26 | [`react-aria`](https://react-spectrum.adobe.com/react-aria/index.html) docs):
27 |
28 | ```tsx
29 |
30 | Pets
31 | Dogs
32 | Cats
33 | Dragons
34 | Select your pets.
35 |
36 | ```
37 |
38 | The `slot="description"` prop is letting the `Text` component know that it needs
39 | to look for special props that are meant to be used as a description. Those
40 | special props will be provided by the `CheckboxGroup` component.
41 |
42 | Essentially, the `CheckboxGroup` component will say: "here's a bucket of props
43 | for any component that takes on the role of a description." The `Text` component
44 | will then say: "Oh look, I've got a `slot` prop that matches the `description`
45 | slot, so I'll use these props to render myself."
46 |
47 | All of this is built using context.
48 |
49 | What this enables is a powerfully flexible capability to have components which
50 | are highly reusable. The `Text` component can be used in many different
51 | contexts, and it can adapt to the needs of the parent component. For example,
52 | it's also used in react-aria's `ComboBox` components. Here's the anatomy of a
53 | react-aria `ComboBox` component:
54 |
55 | ```tsx lines=5,10,11
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
72 |
73 |
74 |
75 | ```
76 |
77 | This can be used to apply appropriate `aria-` attributes as well as `id`s and
78 | event handlers. You might think about it as a way to implement compound
79 | components in a way that doesn't require an individual component for every
80 | single use case.
81 |
82 | ## Implementation
83 |
84 | Folks tend to struggle with this one a bit more than the rest, but it's simpler
85 | than it seems.
86 |
87 | The basic concept is your root component creates collections of props like so:
88 |
89 | ```tsx
90 | function NumberField({ children }: { children: React.ReactNode }) {
91 | // setup state/events/etc
92 |
93 | const slots = {
94 | label: { htmlFor: inputId },
95 | decrement: { onClick: decrement },
96 | increment: { onClick: increment },
97 | description: { id: descriptionId },
98 | input: { id: inputId, 'aria-describedby': descriptionId },
99 | }
100 | return {children}
101 | }
102 | ```
103 |
104 | Then the consuming components use the `use(SlotContext)` to get access to the
105 | `slots` object and pluck off the props they need to do their job:
106 |
107 | ```tsx
108 | function Input(props) {
109 | props = useSlotProps(props, 'input')
110 |
111 | return
112 | }
113 | ```
114 |
115 | The `useSlotProps` hook is responsible for taking the props that have been
116 | specified and combining it with those from the `SlotContext` for the named slot.
117 |
--------------------------------------------------------------------------------
/exercises/01.composition/01.problem.compose/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import * as ReactDOM from 'react-dom/client'
3 | import { SportDataView, allSports } from '#shared/sports.tsx'
4 | import { type SportData, type User } from '#shared/types.tsx'
5 |
6 | function App() {
7 | const [user] = useState({ name: 'Kody', image: '/img/kody.png' })
8 | const [sportList] = useState>(() => Object.values(allSports))
9 | const [selectedSport, setSelectedSport] = useState(null)
10 |
11 | return (
12 |
16 | {/*
17 | 🐨 make Nav accept a ReactNode prop called "avatar"
18 | instead of a User prop called "user"
19 | */}
20 |
21 |
22 | {/*
23 | 🐨 make Main accept ReactNode props called "sidebar" and "content"
24 | instead of the props it accepts right now.
25 | */}
26 |
31 |
32 | {/*
33 | 🐨 make Footer accept a String prop called "footerMessage"
34 | instead of the User prop called "user"
35 | */}
36 |
37 |
38 | )
39 | }
40 |
41 | // 🐨 this should accept an avatar prop that's a ReactNode
42 | function Nav({ user }: { user: User }) {
43 | return (
44 |
45 |
56 |
57 | {/* 🐨 render the avatar prop here instead of the img */}
58 |
59 |
60 |
61 | )
62 | }
63 |
64 | function Main({
65 | // 🐨 all these props should be removed in favor of the sidebar and content props
66 | sportList,
67 | selectedSport,
68 | setSelectedSport,
69 | }: {
70 | sportList: Array
71 | selectedSport: SportData | null
72 | setSelectedSport: (sport: SportData) => void
73 | }) {
74 | return (
75 |
76 | {/* 🐨 put the sidebar and content props here */}
77 |
78 |
79 |
80 | )
81 | }
82 |
83 | function List({
84 | // 🐨 make this accept an array of ReactNodes called "listItems"
85 | // and remove the existing props
86 | sportList,
87 | setSelectedSport,
88 | }: {
89 | sportList: Array
90 | setSelectedSport: (sport: SportData) => void
91 | }) {
92 | return (
93 |
94 |
95 | {/* 🐨 render the listItems here */}
96 | {sportList.map(p => (
97 |
98 | setSelectedSport(p)}
101 | />
102 |
103 | ))}
104 |
105 |
106 | )
107 | }
108 |
109 | function SportListItemButton({
110 | sport,
111 | onClick,
112 | }: {
113 | sport: SportData
114 | onClick: () => void
115 | }) {
116 | return (
117 |
123 |
124 |
125 | {sport.name}
126 |
127 |
128 | )
129 | }
130 |
131 | function Details({ selectedSport }: { selectedSport: SportData | null }) {
132 | return (
133 |
134 | {selectedSport ? (
135 |
136 | ) : (
137 |
Select a Sport
138 | )}
139 |
140 | )
141 | }
142 |
143 | // 🐨 make this accept a footerMessage string instead of the user
144 | function Footer({ user }: { user: User }) {
145 | return (
146 |
149 | )
150 | }
151 |
152 | const rootEl = document.createElement('div')
153 | document.body.append(rootEl)
154 | ReactDOM.createRoot(rootEl).render( )
155 |
--------------------------------------------------------------------------------
/exercises/01.composition/README.mdx:
--------------------------------------------------------------------------------
1 | # Composition
2 |
3 |
4 |
5 |
6 | **One liner:** The Composition and Layout Components Pattern helps to avoid
7 | the prop drilling problem and enhances the reusability of your components.
8 |
9 |
10 | 🦉 If you're unfamiliar with the concept of "Prop Drilling" then please read
11 | [this blog post](https://kentcdodds.com/blog/prop-drilling) before going
12 | forward.
13 |
14 | Let's skip to the end here. It's surprising what you can accomplish by passing
15 | react elements rather than treating components as uncrossable boundaries. We'll
16 | have a practical example in our exercise, so let me show you a quick and easy
17 | contrived example to explain what we'll be doing here:
18 |
19 | ```tsx
20 | function App() {
21 | const [count, setCount] = useState(0)
22 | const increment = () => setCount(c => c + 1)
23 | return
24 | }
25 |
26 | function Child({ count, increment }: { count: number; increment: () => void }) {
27 | return (
28 |
29 |
30 | I am a child and I don't actually use count or increment. My child does
31 | though so I have to accept those as props and forward them along.
32 |
33 |
34 |
35 | )
36 | }
37 |
38 | function GrandChild({
39 | count,
40 | onIncrementClick,
41 | }: {
42 | count: number
43 | onIncrementClick: () => void
44 | }) {
45 | return (
46 |
47 | I am a grand child and I just pass things off to a button
48 | {count}
49 |
50 | )
51 | }
52 | ```
53 |
54 | This prop drilling stuff is one of the reasons so many people have jumped onto
55 | state management solutions, whether it be libraries or React context. However,
56 | if we restructure things a bit, we'll notice that things get quite a bit easier
57 | without losing the flexibility we're hoping for.
58 |
59 | ```tsx
60 | function App() {
61 | const [count, setCount] = useState(0)
62 | const increment = () => setCount(c => c + 1)
63 | return (
64 | {count}}
68 | />
69 | }
70 | />
71 | )
72 | }
73 |
74 | function Child({ grandChild }: { grandChild: React.ReactNode }) {
75 | return (
76 |
77 |
78 | I am a child and I don't actually use count or increment. My child does
79 | though so I have to accept those as props and forward them along.
80 |
81 | {grandChild}
82 |
83 | )
84 | }
85 |
86 | function GrandChild({ button }: { button: React.ReactNode }) {
87 | return (
88 |
89 | I am a grand child and I just pass things off to a button
90 | {button}
91 |
92 | )
93 | }
94 | ```
95 |
96 | Now, clearly you can take this too far (our contrived example above probably
97 | does), but the point is that by structuring things a little differently, you can
98 | keep the components that don't care about state free of the plumbing needed to
99 | make it work. If we decided we needed to manage some more state in the App and
100 | that was needed in the button then we could update only the app for that.
101 |
102 | This style of composition has helped me reduce the amount of components and
103 | files I touch (break?) when I need to make a change and it's also made my
104 | abstractions much easier (imagine if we wanted to reuse the `Child` from above
105 | but needed to customize the `grandChild`. Much easier when we're just accepting
106 | a prop for it).
107 |
108 | When we structure our components to only really deal with props it actually
109 | cares about, then it becomes more of a "layout" component. A component
110 | responsible for laying out the react elements it accepts as props. If you're
111 | familiar with Vue, this concept is similar to the concept of scoped slots.
112 |
113 | 📜 Read more about this in my blog post:
114 | [One React mistake that's slowing you down](https://epicreact.dev/one-react-mistake-thats-slowing-you-down)
115 |
116 | **Real World Projects that use this pattern:**
117 |
118 | - [kentcdodds.com](https://kentcdodds.com) (for the hero component you see at
119 | the top of most pages)
120 |
--------------------------------------------------------------------------------