├── .gitattributes
├── public
├── _redirects
├── favicon.ico
├── manifest.json
├── index.html
└── mockServiceWorker.js
├── setup.js
├── .eslintignore
├── .prettierignore
├── src
├── setupTests.js
├── index.js
├── auth-context.js
├── user-client.js
├── final
│ ├── 04.js
│ ├── 02.js
│ ├── 02.extra-1.js
│ ├── 04.extra-1.js
│ ├── 03.js
│ ├── 03.extra-1.js
│ ├── 05.extra-1.js
│ ├── 05.js
│ ├── 05.extra-2.js
│ ├── 06.js
│ ├── 06.extra-1.js
│ ├── 06.extra-2.js
│ ├── 01.js
│ ├── 06.extra-3.js
│ └── 06.extra-4.js
├── __tests__
│ ├── 02.js
│ ├── 03.js
│ ├── 06.js
│ ├── 04.js
│ ├── 05.js
│ ├── 06.extra-4.js
│ └── 01.js
├── examples
│ ├── warnings.js
│ ├── counter-before.js
│ └── counter-after.js
├── exercise
│ ├── 04.js
│ ├── 02.js
│ ├── 03.js
│ ├── 03.md
│ ├── 05.js
│ ├── 06.js
│ ├── 04.md
│ ├── 05.md
│ ├── 02.md
│ ├── 01.js
│ ├── 06.md
│ └── 01.md
├── switch.js
└── switch.styles.css
├── .npmrc
├── CODE_OF_CONDUCT.md
├── .gitignore
├── scripts
├── fix-links
├── update-deps
├── pre-commit.js
├── pre-push.js
├── diff.js
└── setup.js
├── Dockerfile
├── sandbox.config.json
├── docker-compose.yml
├── LICENSE.md
├── .vscode
├── extensions.json
└── settings.kcd.json
├── .prettierrc
├── .github
└── workflows
│ └── validate.yml
├── CONTRIBUTING.md
├── test
└── utils.js
├── OUTLINE.md
├── package.json
├── .all-contributorsrc
└── README.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
--------------------------------------------------------------------------------
/setup.js:
--------------------------------------------------------------------------------
1 | require('./scripts/setup')
2 |
3 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | build
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | build
4 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import '@kentcdodds/react-workshop-app/setup-tests'
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://registry.npmjs.org/
2 | package-lock=true
3 | yes=true
4 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | Please refer to [kentcdodds.com/conduct/](https://kentcdodds.com/conduct/)
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | coverage
4 | build
5 | .idea/
6 | .vscode/
7 | .eslintcache
8 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nikkifurls/advanced-react-patterns/main/public/favicon.ico
--------------------------------------------------------------------------------
/scripts/fix-links:
--------------------------------------------------------------------------------
1 | npx https://gist.github.com/kentcdodds/436a77ff8977269e5fee39d9d89956de
2 | npm run format
3 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16
2 |
3 | WORKDIR /app
4 | COPY . .
5 | RUN NO_EMAIL_AUTOFILL=true node setup
6 |
7 | CMD ["npm", "start"]
8 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import codegen from 'codegen.macro'
2 |
3 | codegen`module.exports = require('@kentcdodds/react-workshop-app/codegen')`
4 |
--------------------------------------------------------------------------------
/scripts/update-deps:
--------------------------------------------------------------------------------
1 | npx npm-check-updates --upgrade --reject husky
2 | rm -rf node_modules package-lock.json
3 | npx npm@6 install
4 | npm run validate
5 |
--------------------------------------------------------------------------------
/sandbox.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "template": "node",
3 | "container": {
4 | "startScript": "start",
5 | "port": 3000,
6 | "node": "14"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | node:
5 | build: .
6 | volumes:
7 | - ./src:/app/src
8 | ports:
9 | - '3000:3000'
10 |
--------------------------------------------------------------------------------
/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 me
4 | at me@kentcdodds.com
5 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode",
5 | "formulahendry.auto-rename-tag",
6 | "mgmcdermott.vscode-language-babel",
7 | "VisualStudioExptTeam.vscodeintellicode"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/scripts/pre-commit.js:
--------------------------------------------------------------------------------
1 | var spawnSync = require('child_process').spawnSync
2 | const {username} = require('os').userInfo()
3 |
4 | if (username === 'kentcdodds') {
5 | const result = spawnSync('npm run validate', {stdio: 'inherit', shell: true})
6 |
7 | if (result.status !== 0) {
8 | process.exit(result.status)
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "bracketSpacing": false,
4 | "endOfLine": "lf",
5 | "htmlWhitespaceSensitivity": "css",
6 | "insertPragma": false,
7 | "jsxBracketSameLine": false,
8 | "jsxSingleQuote": false,
9 | "printWidth": 80,
10 | "proseWrap": "always",
11 | "quoteProps": "as-needed",
12 | "requirePragma": false,
13 | "semi": false,
14 | "singleQuote": true,
15 | "tabWidth": 2,
16 | "trailingComma": "all",
17 | "useTabs": false
18 | }
19 |
--------------------------------------------------------------------------------
/src/auth-context.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | // normally this is going to implement a similar pattern
4 | // learn more here: https://kcd.im/auth
5 |
6 | const AuthContext = React.createContext({
7 | user: {username: 'jakiechan', tagline: '', bio: ''},
8 | })
9 | AuthContext.displayName = 'AuthContext'
10 | const AuthProvider = ({user, ...props}) => (
11 |
12 | )
13 |
14 | function useAuth() {
15 | return React.useContext(AuthContext)
16 | }
17 |
18 | export {AuthProvider, useAuth}
19 |
--------------------------------------------------------------------------------
/src/user-client.js:
--------------------------------------------------------------------------------
1 | // this is just a fake user client, in reality it'd probably be using
2 | // window.fetch to actually interact with the user.
3 |
4 | const sleep = t => new Promise(resolve => setTimeout(resolve, t))
5 |
6 | // TODO: make this a real request with fetch so the signal does something
7 | async function updateUser(user, updates, signal) {
8 | await sleep(1500) // simulate a real-world wait period
9 | if (`${updates.tagline} ${updates.bio}`.includes('fail')) {
10 | return Promise.reject({message: 'Something went wrong'})
11 | }
12 | return {...user, ...updates}
13 | }
14 |
15 | export {updateUser}
16 |
--------------------------------------------------------------------------------
/.github/workflows/validate.yml:
--------------------------------------------------------------------------------
1 | name: validate
2 | on:
3 | push:
4 | branches:
5 | - 'main'
6 | pull_request:
7 | branches:
8 | - 'main'
9 | jobs:
10 | setup:
11 | # ignore all-contributors PRs
12 | if: ${{ !contains(github.head_ref, 'all-contributors') }}
13 | strategy:
14 | matrix:
15 | os: [ubuntu-latest, windows-latest, macos-latest]
16 | node: [12, 14, 16]
17 | runs-on: ${{ matrix.os }}
18 | steps:
19 | - name: ⬇️ Checkout repo
20 | uses: actions/checkout@v2
21 |
22 | - name: ⎔ Setup node
23 | uses: actions/setup-node@v1
24 | with:
25 | node-version: ${{ matrix.node }}
26 |
27 | - name: ▶️ Run setup script
28 | run: npm run setup
29 |
--------------------------------------------------------------------------------
/src/final/04.js:
--------------------------------------------------------------------------------
1 | // Prop Collections and Getters
2 | // http://localhost:3000/isolated/final/04.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | function useToggle() {
8 | const [on, setOn] = React.useState(false)
9 | const toggle = () => setOn(!on)
10 |
11 | return {
12 | on,
13 | toggle,
14 | togglerProps: {
15 | 'aria-pressed': on,
16 | onClick: toggle,
17 | },
18 | }
19 | }
20 |
21 | function App() {
22 | const {on, togglerProps} = useToggle()
23 | return (
24 |
25 |
26 |
27 |
28 | {on ? 'on' : 'off'}
29 |
30 |
31 | )
32 | }
33 |
34 | export default App
35 |
--------------------------------------------------------------------------------
/scripts/pre-push.js:
--------------------------------------------------------------------------------
1 | try {
2 | const {username} = require('os').userInfo()
3 | const {
4 | repository: {url: repoUrl},
5 | } = require('../package.json')
6 |
7 | const remote = process.env.HUSKY_GIT_PARAMS.split(' ')[1]
8 | const repoName = repoUrl.match(/(?:.(?!\/))+\.git$/)[0]
9 | if (username !== 'kentcdodds' && remote.includes(`kentcdodds${repoName}`)) {
10 | console.log(
11 | `You're trying to push to Kent's repo directly. If you want to save and push your work or even make a contribution to the workshop material, you'll need to fork the repo first and push changes to your fork. Learn how here: https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo`,
12 | )
13 | process.exit(1)
14 | }
15 | } catch (error) {
16 | // ignore
17 | }
18 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 | Advanced React Patterns 🤯
13 |
19 |
20 |
21 |
22 | You need to enable JavaScript to run this app.
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/__tests__/02.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {renderToggle} from '../../test/utils'
3 | import App from '../final/02'
4 | // import App from '../exercise/02'
5 |
6 | test('renders a toggle component', () => {
7 | const {toggleButton, toggle, container} = renderToggle( )
8 | expect(toggleButton).not.toBeChecked()
9 | expect(container.textContent).toMatch('The button is off')
10 | expect(container.textContent).not.toMatch('The button is on')
11 | toggle()
12 | expect(toggleButton).toBeChecked()
13 | expect(container.textContent).toMatch('The button is on')
14 | expect(container.textContent).not.toMatch('The button is off')
15 | toggle()
16 | expect(toggleButton).not.toBeChecked()
17 | expect(container.textContent).toMatch('The button is off')
18 | expect(container.textContent).not.toMatch('The button is on')
19 | })
20 |
--------------------------------------------------------------------------------
/src/__tests__/03.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {renderToggle} from '../../test/utils'
3 | import App from '../final/03'
4 | // import App from '../exercise/03'
5 |
6 | test('renders a toggle component', () => {
7 | const {toggleButton, toggle, container} = renderToggle( )
8 | expect(toggleButton).not.toBeChecked()
9 | expect(container.textContent).toMatch('The button is off')
10 | expect(container.textContent).not.toMatch('The button is on')
11 | toggle()
12 | expect(toggleButton).toBeChecked()
13 | expect(container.textContent).toMatch('The button is on')
14 | expect(container.textContent).not.toMatch('The button is off')
15 | toggle()
16 | expect(toggleButton).not.toBeChecked()
17 | expect(container.textContent).toMatch('The button is off')
18 | expect(container.textContent).not.toMatch('The button is on')
19 | })
20 |
--------------------------------------------------------------------------------
/src/__tests__/06.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {renderToggle, screen, userEvent} from '../../test/utils'
3 | import App, {Toggle} from '../final/06'
4 | // import App, {Toggle} from '../exercise/06'
5 |
6 | test('toggling either toggle toggles both', () => {
7 | renderToggle( )
8 | const buttons = screen.getAllByTestId('toggle-input')
9 | const [toggleButton1, toggleButton2] = buttons
10 | userEvent.click(toggleButton1)
11 | expect(toggleButton1).toBeChecked()
12 | expect(toggleButton2).toBeChecked()
13 |
14 | userEvent.click(toggleButton2)
15 | expect(toggleButton1).not.toBeChecked()
16 | expect(toggleButton2).not.toBeChecked()
17 | })
18 |
19 | test('toggle can still be uncontrolled', () => {
20 | const {toggleButton, toggle} = renderToggle( )
21 | expect(toggleButton).not.toBeChecked()
22 | toggle()
23 | expect(toggleButton).toBeChecked()
24 | })
25 |
--------------------------------------------------------------------------------
/scripts/diff.js:
--------------------------------------------------------------------------------
1 | const {spawnSync} = require('child_process')
2 | const inquirer = require('inquirer')
3 | const glob = require('glob')
4 |
5 | async function go() {
6 | const files = glob
7 | .sync('src/+(exercise|final)/*.+(js|ts|tsx)', {
8 | ignore: ['*.d.ts'],
9 | })
10 | .map(f => f.replace(/^src\//, ''))
11 | const {first} = await inquirer.prompt([
12 | {
13 | name: 'first',
14 | message: `What's the first file`,
15 | type: 'list',
16 | choices: files,
17 | },
18 | ])
19 | const {second} = await inquirer.prompt([
20 | {
21 | name: 'second',
22 | message: `What's the second file`,
23 | type: 'list',
24 | choices: files.filter(f => f !== first),
25 | },
26 | ])
27 |
28 | spawnSync(`git diff --no-index ./src/${first} ./src/${second}`, {
29 | shell: true,
30 | stdio: 'inherit',
31 | })
32 | }
33 |
34 | go()
35 |
--------------------------------------------------------------------------------
/src/final/02.js:
--------------------------------------------------------------------------------
1 | // Compound Components
2 | // http://localhost:3000/isolated/final/02.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | function Toggle({children}) {
8 | const [on, setOn] = React.useState(false)
9 | const toggle = () => setOn(!on)
10 | return React.Children.map(children, child =>
11 | React.cloneElement(child, {on, toggle}),
12 | )
13 | }
14 |
15 | function ToggleOn({on, children}) {
16 | return on ? children : null
17 | }
18 |
19 | function ToggleOff({on, children}) {
20 | return on ? null : children
21 | }
22 |
23 | function ToggleButton({on, toggle, ...props}) {
24 | return
25 | }
26 |
27 | function App() {
28 | return (
29 |
30 |
31 | The button is on
32 | The button is off
33 |
34 |
35 |
36 | )
37 | }
38 |
39 | export default App
40 |
--------------------------------------------------------------------------------
/src/examples/warnings.js:
--------------------------------------------------------------------------------
1 | // http://localhost:3000/isolated/examples/warnings.js
2 |
3 | import * as React from 'react'
4 |
5 | function App() {
6 | const [name, setName] = React.useState()
7 | const [animal, setAnimal] = React.useState('tiger')
8 | return (
9 |
30 | )
31 | }
32 |
33 | export default App
34 |
--------------------------------------------------------------------------------
/src/exercise/04.js:
--------------------------------------------------------------------------------
1 | // Prop Collections and Getters
2 | // http://localhost:3000/isolated/exercise/04.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | function useToggle() {
8 | const [on, setOn] = React.useState(false)
9 | const toggle = () => setOn(!on)
10 | const togglerProps = {'aria-pressed': on, onClick: toggle};
11 | const getTogglerProps = (props) => ({
12 | ...togglerProps,
13 | ...props,
14 | });
15 |
16 | return {on, getTogglerProps}
17 | }
18 |
19 | function App() {
20 | const {on, getTogglerProps} = useToggle()
21 | return (
22 |
23 |
24 |
25 | console.info('onButtonClick'),
29 | id: 'custom-button-id',
30 | })}
31 | >
32 | {on ? 'on' : 'off'}
33 |
34 |
35 | )
36 | }
37 |
38 | export default App
39 |
40 | /*
41 | eslint
42 | no-unused-vars: "off",
43 | */
44 |
--------------------------------------------------------------------------------
/src/exercise/02.js:
--------------------------------------------------------------------------------
1 | // Compound Components
2 | // http://localhost:3000/isolated/exercise/02.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | function Toggle({children}) {
8 | const [on, setOn] = React.useState(false)
9 | const toggle = () => setOn(!on)
10 |
11 | return React.Children.map(children, child => {
12 | return (typeof child.type === 'string')
13 | ? child
14 | : React.cloneElement(child, {
15 | on,
16 | toggle,
17 | })
18 | });
19 | }
20 |
21 | const ToggleOn = ({on, children}) => on ? children : null
22 | const ToggleOff = ({on, children}) => on ? null : children
23 | const ToggleButton = ({on, toggle}) =>
24 |
25 | function App() {
26 | return (
27 |
28 |
29 | The button is on
30 | The button is off
31 | Hello
32 |
33 |
34 |
35 | )
36 | }
37 |
38 | export default App
39 |
40 | /*
41 | eslint
42 | no-unused-vars: "off",
43 | */
44 |
--------------------------------------------------------------------------------
/src/final/02.extra-1.js:
--------------------------------------------------------------------------------
1 | // Compound Components
2 | // 💯 Support non-toggle children
3 | // http://localhost:3000/isolated/final/02.extra-1.js
4 |
5 | import * as React from 'react'
6 | import {Switch} from '../switch'
7 |
8 | function Toggle({children}) {
9 | const [on, setOn] = React.useState(false)
10 | const toggle = () => setOn(!on)
11 | return React.Children.map(children, child => {
12 | return typeof child.type === 'string'
13 | ? child
14 | : React.cloneElement(child, {on, toggle})
15 | })
16 | }
17 |
18 | function ToggleOn({on, children}) {
19 | return on ? children : null
20 | }
21 |
22 | function ToggleOff({on, children}) {
23 | return on ? null : children
24 | }
25 |
26 | function ToggleButton({on, toggle, ...props}) {
27 | return
28 | }
29 |
30 | function App() {
31 | return (
32 |
33 |
34 | The button is on
35 | The button is off
36 | Hello
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export default App
44 |
--------------------------------------------------------------------------------
/src/__tests__/04.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {renderToggle, screen, userEvent} from '../../test/utils'
3 | import App from '../final/04'
4 | // import App from '../exercise/04'
5 |
6 | test('renders a toggle component', () => {
7 | const {toggleButton, toggle} = renderToggle( )
8 | expect(toggleButton).not.toBeChecked()
9 | toggle()
10 | expect(toggleButton).toBeChecked()
11 | toggle()
12 | expect(toggleButton).not.toBeChecked()
13 | })
14 |
15 | test('can also toggle with the custom button', () => {
16 | const {toggleButton} = renderToggle( )
17 | expect(toggleButton).not.toBeChecked()
18 | userEvent.click(screen.getByLabelText('custom-button'))
19 | expect(toggleButton).toBeChecked()
20 | })
21 |
22 | // 💯 remove the `.skip` if you're working on the extra credit
23 | test.skip('passes custom props to the custom-button', () => {
24 | const {toggleButton} = renderToggle( )
25 | const customButton = screen.getByLabelText('custom-button')
26 | expect(customButton.getAttribute('id')).toBe('custom-button-id')
27 |
28 | userEvent.click(customButton)
29 |
30 | expect(toggleButton).toBeChecked()
31 | })
32 |
--------------------------------------------------------------------------------
/src/final/04.extra-1.js:
--------------------------------------------------------------------------------
1 | // Prop Collections and Getters
2 | // 💯 prop getters
3 | // http://localhost:3000/isolated/final/04.extra-1.js
4 |
5 | import * as React from 'react'
6 | import {Switch} from '../switch'
7 |
8 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
9 |
10 | function useToggle() {
11 | const [on, setOn] = React.useState(false)
12 | const toggle = () => setOn(!on)
13 |
14 | function getTogglerProps({onClick, ...props} = {}) {
15 | return {
16 | 'aria-pressed': on,
17 | onClick: callAll(onClick, toggle),
18 | ...props,
19 | }
20 | }
21 |
22 | return {
23 | on,
24 | toggle,
25 | getTogglerProps,
26 | }
27 | }
28 |
29 | function App() {
30 | const {on, getTogglerProps} = useToggle()
31 | return (
32 |
33 |
34 |
35 | console.info('onButtonClick'),
39 | id: 'custom-button-id',
40 | })}
41 | >
42 | {on ? 'on' : 'off'}
43 |
44 |
45 | )
46 | }
47 |
48 | export default App
49 |
--------------------------------------------------------------------------------
/src/final/03.js:
--------------------------------------------------------------------------------
1 | // Flexible Compound Components with context
2 | // http://localhost:3000/isolated/final/03.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | const ToggleContext = React.createContext()
8 | ToggleContext.displayName = 'ToggleContext'
9 |
10 | function Toggle({children}) {
11 | const [on, setOn] = React.useState(false)
12 | const toggle = () => setOn(!on)
13 |
14 | return (
15 |
16 | {children}
17 |
18 | )
19 | }
20 |
21 | function useToggle() {
22 | return React.useContext(ToggleContext)
23 | }
24 |
25 | function ToggleOn({children}) {
26 | const {on} = useToggle()
27 | return on ? children : null
28 | }
29 |
30 | function ToggleOff({children}) {
31 | const {on} = useToggle()
32 | return on ? null : children
33 | }
34 |
35 | function ToggleButton({...props}) {
36 | const {on, toggle} = useToggle()
37 | return
38 | }
39 |
40 | function App() {
41 | return (
42 |
43 |
44 | The button is on
45 | The button is off
46 |
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | export default App
55 |
--------------------------------------------------------------------------------
/src/exercise/03.js:
--------------------------------------------------------------------------------
1 | // Flexible Compound Components
2 | // http://localhost:3000/isolated/exercise/03.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | const ToggleContext = React.createContext();
8 |
9 | function Toggle({children}) {
10 | const [on, setOn] = React.useState(false)
11 | const toggle = () => setOn(!on)
12 |
13 | return {children}
14 | }
15 |
16 | function useToggle() {
17 | const context = React.useContext(ToggleContext);
18 | if (context === undefined) {
19 | throw new Error('useToggle must be used within a ');
20 | }
21 | return context;
22 | }
23 |
24 | function ToggleOn({children}) {
25 | const [on, toggle] = useToggle();
26 | return on ? children : null
27 | }
28 |
29 | function ToggleOff({children}) {
30 | const [on, toggle] = useToggle();
31 | return on ? null : children
32 | }
33 |
34 | function ToggleButton({...props}) {
35 | const [on, toggle] = useToggle();
36 | return
37 | }
38 |
39 | function App() {
40 | return (
41 |
42 |
43 | The button is on
44 | The button is off
45 |
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | export default App
54 |
55 | /*
56 | eslint
57 | no-unused-vars: "off",
58 | */
59 |
--------------------------------------------------------------------------------
/src/switch.js:
--------------------------------------------------------------------------------
1 | import './switch.styles.css'
2 | import * as React from 'react'
3 |
4 | // STOP! You should not have to change anything in this file to
5 | // make it through the workshop. If tests are failing because of
6 | // this switch not having properties set correctly, then the
7 | // problem is probably in your implementation. Tip: Check
8 | // your `render` method or the `getTogglerProps` method
9 | // (if we've gotten to that part)
10 |
11 | // this is here to fill in for the onChange handler
12 | // we're not using onChange because it seems to behave
13 | // differently in codesandbox and locally :shrug:
14 | const noop = () => {}
15 |
16 | class Switch extends React.Component {
17 | render() {
18 | const {
19 | on,
20 | className = '',
21 | 'aria-label': ariaLabel,
22 | onClick,
23 | ...props
24 | } = this.props
25 | const btnClassName = [
26 | className,
27 | 'toggle-btn',
28 | on ? 'toggle-btn-on' : 'toggle-btn-off',
29 | ]
30 | .filter(Boolean)
31 | .join(' ')
32 | return (
33 |
34 |
42 |
43 |
44 | )
45 | }
46 | }
47 |
48 | export {Switch}
49 |
--------------------------------------------------------------------------------
/src/final/03.extra-1.js:
--------------------------------------------------------------------------------
1 | // Flexible Compound Components with context
2 | // 💯 custom hook validation
3 | // http://localhost:3000/isolated/final/03.extra-1.js
4 |
5 | import * as React from 'react'
6 | import {Switch} from '../switch'
7 |
8 | const ToggleContext = React.createContext()
9 | ToggleContext.displayName = 'ToggleContext'
10 |
11 | function Toggle({children}) {
12 | const [on, setOn] = React.useState(false)
13 | const toggle = () => setOn(!on)
14 |
15 | return (
16 |
17 | {children}
18 |
19 | )
20 | }
21 |
22 | function useToggle() {
23 | const context = React.useContext(ToggleContext)
24 | if (context === undefined) {
25 | throw new Error('useToggle must be used within a ')
26 | }
27 | return context
28 | }
29 |
30 | function ToggleOn({children}) {
31 | const {on} = useToggle()
32 | return on ? children : null
33 | }
34 |
35 | function ToggleOff({children}) {
36 | const {on} = useToggle()
37 | return on ? null : children
38 | }
39 |
40 | function ToggleButton({...props}) {
41 | const {on, toggle} = useToggle()
42 | return
43 | }
44 |
45 | function App() {
46 | return (
47 |
48 |
49 | The button is on
50 | The button is off
51 |
52 |
53 |
54 |
55 |
56 | )
57 | }
58 |
59 | export default App
60 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Thanks for being willing to contribute!
4 |
5 | **Working on your first Pull Request?** You can learn how from this _free_
6 | series [How to Contribute to an Open Source Project on GitHub][egghead]
7 |
8 | ## Project setup
9 |
10 | 1. Fork and clone the repo
11 | 2. Run `npm run setup -s` to install dependencies and run validation
12 | 3. Create a branch for your PR with `git checkout -b pr/your-branch-name`
13 |
14 | > Tip: Keep your `main` branch pointing at the original repository and make
15 | > pull requests from branches on your fork. To do this, run:
16 | >
17 | > ```
18 | > git remote add upstream https://github.com/kentcdodds/advanced-react-patterns.git
19 | > git fetch upstream
20 | > git branch --set-upstream-to=upstream/main main
21 | > ```
22 | >
23 | > This will add the original repository as a "remote" called "upstream," Then
24 | > fetch the git information from that remote, then set your local `main`
25 | > branch to use the upstream main branch whenever you run `git pull`. Then you
26 | > can make all of your pull request branches based on this `main` branch.
27 | > Whenever you want to update your version of `main`, do a regular `git pull`.
28 |
29 | ## Help needed
30 |
31 | Please checkout the [the open issues][issues]
32 |
33 | Also, please watch the repo and respond to questions/bug reports/feature
34 | requests! Thanks!
35 |
36 | [egghead]:
37 | https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github
38 | [issues]: https://github.com/kentcdodds/advanced-react-patterns/issues
39 |
--------------------------------------------------------------------------------
/src/__tests__/05.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {renderToggle, screen, userEvent} from '../../test/utils'
3 | import App from '../final/05'
4 | // import App from '../exercise/05'
5 |
6 | test('renders a toggle component', () => {
7 | const {toggleButton, toggle} = renderToggle( )
8 | expect(toggleButton).not.toBeChecked()
9 | toggle()
10 | expect(toggleButton).toBeChecked()
11 | toggle()
12 | expect(toggleButton).not.toBeChecked()
13 | })
14 |
15 | test('can click too much', () => {
16 | const {toggleButton, toggle} = renderToggle( )
17 | expect(toggleButton).not.toBeChecked()
18 | toggle() // 1
19 | expect(toggleButton).toBeChecked()
20 | toggle() // 2
21 | expect(toggleButton).not.toBeChecked()
22 | expect(screen.getByTestId('click-count')).toHaveTextContent('2')
23 | toggle() // 3
24 | expect(toggleButton).toBeChecked()
25 | expect(screen.queryByText(/whoa/i)).not.toBeInTheDocument()
26 | toggle() // 4
27 | expect(toggleButton).toBeChecked()
28 | expect(screen.getByText(/whoa/i)).toBeInTheDocument()
29 | toggle() // 5: Whoa, too many
30 | expect(toggleButton).toBeChecked()
31 | toggle() // 6
32 | expect(toggleButton).toBeChecked()
33 |
34 | expect(screen.getByTestId('notice')).not.toBeNull()
35 |
36 | userEvent.click(screen.getByText('Reset'))
37 | expect(screen.queryByTestId('notice')).toBeNull()
38 |
39 | expect(toggleButton).not.toBeChecked()
40 | toggle()
41 | expect(toggleButton).toBeChecked()
42 |
43 | expect(screen.getByTestId('click-count')).toHaveTextContent('1')
44 | })
45 |
--------------------------------------------------------------------------------
/scripts/setup.js:
--------------------------------------------------------------------------------
1 | var spawnSync = require('child_process').spawnSync
2 |
3 | var 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 | var error = spawnSync('npx --version', {shell: true}).stderr.toString().trim()
20 | if (error) {
21 | console.error(
22 | color(
23 | 'danger',
24 | '🚨 npx is not available on this computer. Please install npm@5.2.0 or greater',
25 | ),
26 | )
27 | throw error
28 | }
29 |
30 | var command =
31 | 'npx "https://gist.github.com/kentcdodds/bb452ffe53a5caa3600197e1d8005733" -q'
32 | console.log(
33 | color('subtitle', ' Running the following command: ' + command),
34 | )
35 |
36 | var result = spawnSync(command, {stdio: 'inherit', shell: true})
37 |
38 | if (result.status === 0) {
39 | console.log(color('success', '✅ Workshop setup complete...'))
40 | } else {
41 | process.exit(result.status)
42 | }
43 |
44 | /*
45 | eslint
46 | no-var: "off",
47 | "vars-on-top": "off",
48 | */
49 |
--------------------------------------------------------------------------------
/src/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: inline-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-input:focus + .toggle-btn::after,
22 | .toggle-btn:active::after {
23 | box-sizing: initial;
24 | box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1), 0 4px 0 rgba(0, 0, 0, 0.08),
25 | inset 0px 0px 0px 3px #9c9c9c;
26 | }
27 | .toggle-btn::after {
28 | left: 0;
29 | position: relative;
30 | display: block;
31 | content: '';
32 | width: 50%;
33 | height: 100%;
34 | border-radius: 4em;
35 | background: #fbfbfb;
36 | transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275),
37 | padding 0.3s ease, margin 0.3s ease;
38 | box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1), 0 4px 0 rgba(0, 0, 0, 0.08);
39 | }
40 | .toggle-btn.toggle-btn-on::after {
41 | left: 50%;
42 | }
43 | .toggle-btn.toggle-btn-on {
44 | background: #86d993;
45 | }
46 | .toggle-btn.toggle-btn-on:active {
47 | box-shadow: none;
48 | }
49 | .toggle-btn.toggle-btn-on:active::after {
50 | margin-left: -1.6em;
51 | }
52 | .toggle-btn:active::after {
53 | padding-right: 1.6em;
54 | }
55 | .toggle-btn[disabled] {
56 | opacity: 0.7;
57 | cursor: auto;
58 | }
59 | .toggle-input {
60 | /* visually hidden but still accessible */
61 | border: 0;
62 | clip: rect(0 0 0 0);
63 | height: 1px;
64 | margin: -1px;
65 | overflow: hidden;
66 | padding: 0;
67 | position: absolute;
68 | width: 1px;
69 | white-space: nowrap;
70 | }
71 |
--------------------------------------------------------------------------------
/src/examples/counter-before.js:
--------------------------------------------------------------------------------
1 | // http://localhost:3000/isolated/examples/counter-before.js
2 |
3 | import * as React from 'react'
4 |
5 | // src/context/counter.js
6 | const CounterContext = React.createContext()
7 |
8 | function CounterProvider({step = 1, initialCount = 0, ...props}) {
9 | const [state, dispatch] = React.useReducer(
10 | (state, action) => {
11 | const change = action.step ?? step
12 | switch (action.type) {
13 | case 'increment': {
14 | return {...state, count: state.count + change}
15 | }
16 | case 'decrement': {
17 | return {...state, count: state.count - change}
18 | }
19 | default: {
20 | throw new Error(`Unhandled action type: ${action.type}`)
21 | }
22 | }
23 | },
24 | {count: initialCount},
25 | )
26 |
27 | return
28 | }
29 |
30 | function useCounter() {
31 | const context = React.useContext(CounterContext)
32 | if (context === undefined) {
33 | throw new Error(`useCounter must be used within a CounterProvider`)
34 | }
35 | return context
36 | }
37 |
38 | // export {CounterProvider, useCounter}
39 |
40 | // src/screens/counter.js
41 | // import {useCounter} from 'context/counter'
42 |
43 | function Counter() {
44 | const [state, dispatch] = useCounter()
45 | const increment = () => dispatch({type: 'increment'})
46 | const decrement = () => dispatch({type: 'decrement'})
47 | return (
48 |
49 |
Current Count: {state.count}
50 |
-
51 |
+
52 |
53 | )
54 | }
55 |
56 | // src/index.js
57 | // import {CounterProvider} from 'context/counter'
58 |
59 | function App() {
60 | return (
61 |
62 |
63 |
64 | )
65 | }
66 |
67 | export default App
68 |
--------------------------------------------------------------------------------
/test/utils.js:
--------------------------------------------------------------------------------
1 | import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils'
2 | import {render} from '@testing-library/react'
3 | import userEvent from '@testing-library/user-event'
4 | import * as React from 'react'
5 | import {
6 | findAllInRenderedTree,
7 | isCompositeComponentWithType,
8 | } from 'react-dom/test-utils'
9 | import {Switch} from '../src/switch'
10 |
11 | const findSwitchInstances = rootInstance =>
12 | findAllInRenderedTree(rootInstance, c =>
13 | isCompositeComponentWithType(c, Switch),
14 | )
15 |
16 | function validateSwitchInstance(switchInstance) {
17 | alfredTip(
18 | () => expect(switchInstance).toBeDefined(),
19 | `Unable to find the Switch component. Make sure you're rendering that!`,
20 | )
21 | alfredTip(
22 | () =>
23 | expect(switchInstance.props).toMatchObject({
24 | on: expect.any(Boolean),
25 | onClick: expect.any(Function),
26 | // it can also have aria-pressed...
27 | }),
28 | 'The Switch component is not being passed the right props.',
29 | )
30 | }
31 |
32 | // this only exists so we can search for an instance of the Switch
33 | // and make some assertions to give more helpful error messages.
34 | class Root extends React.Component {
35 | render() {
36 | return this.props.children
37 | }
38 | }
39 |
40 | function renderToggle(ui) {
41 | let rootInstance
42 | let rootRef = instance => (rootInstance = instance)
43 | const utils = render({ui} )
44 | const switchInstance = findSwitchInstances(rootInstance)[0]
45 | validateSwitchInstance(switchInstance)
46 | const toggleButton = utils.getAllByTestId('toggle-input')[0]
47 |
48 | return {
49 | toggle: () => userEvent.click(utils.getAllByTestId('toggle-input')[0]),
50 | toggleButton,
51 | rootInstance,
52 | ...utils,
53 | }
54 | }
55 |
56 | export * from '@testing-library/react'
57 | export {render, renderToggle, userEvent}
58 |
--------------------------------------------------------------------------------
/src/examples/counter-after.js:
--------------------------------------------------------------------------------
1 | // http://localhost:3000/isolated/examples/counter-after.js
2 |
3 | import * as React from 'react'
4 |
5 | // src/context/counter.js
6 | const CounterContext = React.createContext()
7 |
8 | function CounterProvider({step = 1, initialCount = 0, ...props}) {
9 | const [state, dispatch] = React.useReducer(
10 | (state, action) => {
11 | const change = action.step ?? step
12 | switch (action.type) {
13 | case 'increment': {
14 | return {...state, count: state.count + change}
15 | }
16 | case 'decrement': {
17 | return {...state, count: state.count - change}
18 | }
19 | default: {
20 | throw new Error(`Unhandled action type: ${action.type}`)
21 | }
22 | }
23 | },
24 | {count: initialCount},
25 | )
26 |
27 | return
28 | }
29 |
30 | function useCounter() {
31 | const context = React.useContext(CounterContext)
32 | if (context === undefined) {
33 | throw new Error(`useCounter must be used within a CounterProvider`)
34 | }
35 | return context
36 | }
37 |
38 | const increment = dispatch => dispatch({type: 'increment'})
39 | const decrement = dispatch => dispatch({type: 'decrement'})
40 |
41 | // export {CounterProvider, useCounter, increment, decrement}
42 |
43 | // src/screens/counter.js
44 | // import {useCounter, increment, decrement} from 'context/counter'
45 |
46 | function Counter() {
47 | const [state, dispatch] = useCounter()
48 | return (
49 |
50 |
Current Count: {state.count}
51 |
decrement(dispatch)}>-
52 |
increment(dispatch)}>+
53 |
54 | )
55 | }
56 |
57 | // src/index.js
58 | // import {CounterProvider} from 'context/counter'
59 |
60 | function App() {
61 | return (
62 |
63 |
64 |
65 | )
66 | }
67 |
68 | export default App
69 |
--------------------------------------------------------------------------------
/.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.exclude": {
19 | "USE_GITIGNORE": true
20 | },
21 | "files.defaultLanguage": "{activeEditorLanguage}",
22 | "javascript.validate.enable": false,
23 | "search.exclude": {
24 | "**/node_modules": true,
25 | "**/bower_components": true,
26 | "**/coverage": true,
27 | "**/dist": true,
28 | "**/build": true,
29 | "**/.build": true,
30 | "**/.gh-pages": true
31 | },
32 | "editor.codeActionsOnSave": {
33 | "source.fixAll.eslint": false
34 | },
35 | "eslint.validate": [
36 | "javascript",
37 | "javascriptreact",
38 | "typescript",
39 | "typescriptreact"
40 | ],
41 | "eslint.options": {
42 | "env": {
43 | "browser": true,
44 | "jest/globals": true,
45 | "es6": true
46 | },
47 | "parserOptions": {
48 | "ecmaVersion": 2019,
49 | "sourceType": "module",
50 | "ecmaFeatures": {
51 | "jsx": true
52 | }
53 | },
54 | "rules": {
55 | "no-debugger": "off"
56 | }
57 | },
58 | "workbench.colorTheme": "Night Owl",
59 | "workbench.iconTheme": "material-icon-theme",
60 | "breadcrumbs.enabled": true,
61 | "grunt.autoDetect": "off",
62 | "gulp.autoDetect": "off",
63 | "npm.runSilent": true,
64 | "explorer.confirmDragAndDrop": false,
65 | "editor.formatOnPaste": false,
66 | "editor.cursorSmoothCaretAnimation": true,
67 | "editor.smoothScrolling": true,
68 | "php.suggest.basic": false
69 | }
70 |
--------------------------------------------------------------------------------
/src/exercise/03.md:
--------------------------------------------------------------------------------
1 | # Flexible Compound Components
2 |
3 | ## 📝 Your Notes
4 |
5 | Elaborate on your learnings here in `src/exercise/03.md`
6 |
7 | ## Background
8 |
9 | **One liner:** The Flexible Compound Components Pattern is only differs from the
10 | previous exercise in that it uses React context. You should use this version of
11 | the pattern more often.
12 |
13 | Right now our component can only clone and pass props to immediate children. So
14 | we need some way for our compound components to implicitly accept the on state
15 | and toggle method regardless of where they're rendered within the Toggle
16 | component's "posterity" :)
17 |
18 | The way we do this is through context. `React.createContext` is the API we want.
19 |
20 | **Real World Projects that use this pattern:**
21 |
22 | - [`@reach/accordion`](https://reacttraining.com/reach-ui/accordion)
23 |
24 | ## Exercise
25 |
26 | Production deploys:
27 |
28 | - [Exercise](http://advanced-react-patterns.netlify.app/isolated/exercise/03.js)
29 | - [Final](http://advanced-react-patterns.netlify.app/isolated/final/03.js)
30 |
31 | The fundamental difference between this exercise and the last one is that now
32 | we're going to allow people to render the compound components wherever they like
33 | in the render tree. Searching through `props.children` for the components to
34 | clone would be futile. So we'll use context instead.
35 |
36 | Your job will be to make the `ToggleContext` which will be used to implicitly
37 | share the state between these components, and then a custom hook to consume that
38 | context for the compound components to do their job.
39 |
40 | ## Extra Credit
41 |
42 | ### 1. 💯 custom hook validation
43 |
44 | [Production deploy](http://advanced-react-patterns.netlify.app/isolated/final/03.extra-1.js)
45 |
46 | Change the `App` function to this:
47 |
48 | ```javascript
49 | const App = () =>
50 | ```
51 |
52 | Why doesn't that work? Can you figure out a way to give the developer a better
53 | error message?
54 |
55 | ## 🦉 Feedback
56 |
57 | Fill out
58 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Patterns%20%F0%9F%A4%AF&e=03%3A%20Flexible%20Compound%20Components&em=).
59 |
--------------------------------------------------------------------------------
/OUTLINE.md:
--------------------------------------------------------------------------------
1 | # Advanced React Patterns
2 |
3 | > Some sweeeeeeeet patterns 🍭
4 |
5 | 👋 I'm Kent C. Dodds
6 |
7 | - 🏡 Utah
8 | - 👩 👧 👦 👦 👦 🐕
9 | - 🏢 kentcdodds.com
10 | - 🐦/🐙 @kentcdodds
11 | - 🏆 testingjavascript.com
12 | - 🥚 kcd.im/egghead
13 | - 🥋 kcd.im/fem
14 | - 💌 kcd.im/news
15 | - 📝 kcd.im/blog
16 | - 📺 kcd.im/devtips
17 | - 💻 kcd.im/coding
18 | - 📽 kcd.im/youtube
19 | - 🎙 kcd.im/3-mins
20 | - ❓ kcd.im/ama
21 |
22 | # What this workshop is
23 |
24 | - Lots of exercises
25 |
26 | # What this workshop is not
27 |
28 | - Solo
29 | - Lecture
30 |
31 | # Logistics
32 |
33 | ## Schedule
34 |
35 | - 😴 Logistics
36 | - 💪 Compound Components
37 | - 💪 Flexible Compound Components
38 | - 😴 10 Minutes
39 | - 💪 Prop Collections and Getters
40 | - 🌮 30 Minutes
41 | - 💪 State Reducers
42 | - 😴 10 Minutes
43 | - 💪 Control Props
44 | - 😴 10 Minutes
45 | - 💪❓ Higher Order Components (If time permits)
46 | - 💪❓ Render Props (If time permits)
47 |
48 | ## Scripts
49 |
50 | - `npm run start`
51 | - `npm run test`
52 |
53 | ## Asking Questions
54 |
55 | Please do ask! Interrupt me. If you have an unrelated question, please ask on
56 | [my AMA](https://kcd.im/ama).
57 |
58 | ## Zoom
59 |
60 | - Help us make this more human by keeping your video on if possible
61 | - Keep microphone muted unless speaking
62 | - Breakout rooms
63 |
64 | ## Exercises
65 |
66 | - `src/exercise/0x.md`: Background, Exercise Instructions, Extra Credit
67 | - `src/exercise/0x.js`: Exercise with Emoji helpers
68 | - `src/__tests__/0x.js`: Tests
69 | - `src/final/0x.js`: Final version
70 |
71 | ## Emoji
72 |
73 | - **Kody the Koala** 🐨 "Do this"
74 | - **Matthew the Muscle** 💪 "Exercise"
75 | - **Chuck the Checkered Flag** 🏁 "Final"
76 | - **Marty the Money Bag** 💰 "Here's a hint"
77 | - **Hannah the Hundred** 💯 "Extra Credit"
78 | - **Olivia the Owl** 🦉 "Pro-tip"
79 | - **Dominic the Document** 📜 "Docs links"
80 | - **Berry the Bomb** 💣 "Remove this code"
81 | - **Peter the Product Manager** 👨💼 "Story time"
82 | - **Alfred the Alert** 🚨 "Extra helpful in test errors"
83 |
84 | ## Workshop Feedback
85 |
86 | Each exercise has an Elaboration and Feedback link. Please fill that out after
87 | the exercise and instruction.
88 |
89 | At the end of the workshop, please go to this URL to give overall feedback.
90 | Thank you! https://kcd.im/arp-ws-feedback
91 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "advanced-react-patterns",
3 | "title": "Advanced React Patterns 🤯",
4 | "description": "Advanced React Component Patterns Course by Kent C. Dodds",
5 | "author": "Kent C. Dodds (https://kentcdodds.com/)",
6 | "version": "1.0.0",
7 | "private": true,
8 | "keywords": [],
9 | "homepage": "http://advanced-react-patterns.netlify.app/",
10 | "license": "GPL-3.0-only",
11 | "main": "src/index.js",
12 | "engines": {
13 | "node": "12 || 14 || 15 || 16",
14 | "npm": ">=6"
15 | },
16 | "dependencies": {
17 | "@kentcdodds/react-workshop-app": "^4.3.0",
18 | "@testing-library/jest-dom": "^5.14.1",
19 | "@testing-library/react": "^12.1.2",
20 | "@testing-library/user-event": "^13.3.0",
21 | "chalk": "^4.1.2",
22 | "codegen.macro": "^4.1.0",
23 | "dequal": "^2.0.2",
24 | "lodash": "^4.17.21",
25 | "react": "^17.0.2",
26 | "react-dom": "^17.0.2"
27 | },
28 | "devDependencies": {
29 | "@types/react": "^17.0.27",
30 | "@types/react-dom": "^17.0.9",
31 | "husky": "^4.3.8",
32 | "npm-run-all": "^4.1.5",
33 | "prettier": "^2.4.1",
34 | "react-scripts": "^4.0.3"
35 | },
36 | "scripts": {
37 | "start": "react-scripts start",
38 | "build": "react-scripts build",
39 | "test": "react-scripts test",
40 | "test:coverage": "npm run test -- --watchAll=false",
41 | "test:exercises": "npm run test -- testing.*exercises\\/ --onlyChanged",
42 | "setup": "node setup",
43 | "lint": "eslint .",
44 | "format": "prettier --write \"./src\"",
45 | "validate": "npm-run-all --parallel build test:coverage lint"
46 | },
47 | "husky": {
48 | "hooks": {
49 | "pre-commit": "node ./scripts/pre-commit",
50 | "pre-push": "node ./scripts/pre-push"
51 | }
52 | },
53 | "jest": {
54 | "collectCoverageFrom": [
55 | "src/final/**/*.js"
56 | ]
57 | },
58 | "eslintConfig": {
59 | "extends": "react-app"
60 | },
61 | "babel": {
62 | "presets": [
63 | "babel-preset-react-app"
64 | ]
65 | },
66 | "browserslist": {
67 | "development": [
68 | "last 2 chrome versions",
69 | "last 2 firefox versions",
70 | "last 2 edge versions"
71 | ],
72 | "production": [
73 | ">1%",
74 | "last 4 versions",
75 | "Firefox ESR",
76 | "not ie < 11"
77 | ]
78 | },
79 | "directories": {
80 | "test": "test"
81 | },
82 | "repository": {
83 | "type": "git",
84 | "url": "git+https://github.com/kentcdodds/advanced-react-patterns.git"
85 | },
86 | "bugs": {
87 | "url": "https://github.com/kentcdodds/advanced-react-patterns/issues"
88 | },
89 | "msw": {
90 | "workerDirectory": "public"
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/final/05.extra-1.js:
--------------------------------------------------------------------------------
1 | // state reducer
2 | // 💯 default state reducer
3 | // http://localhost:3000/isolated/final/05.extra-1.js
4 |
5 | import * as React from 'react'
6 | import {Switch} from '../switch'
7 |
8 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
9 |
10 | function toggleReducer(state, {type, initialState}) {
11 | switch (type) {
12 | case 'toggle': {
13 | return {on: !state.on}
14 | }
15 | case 'reset': {
16 | return initialState
17 | }
18 | default: {
19 | throw new Error(`Unsupported type: ${type}`)
20 | }
21 | }
22 | }
23 |
24 | function useToggle({initialOn = false, reducer = toggleReducer} = {}) {
25 | const {current: initialState} = React.useRef({on: initialOn})
26 | const [state, dispatch] = React.useReducer(reducer, initialState)
27 | const {on} = state
28 |
29 | const toggle = () => dispatch({type: 'toggle'})
30 | const reset = () => dispatch({type: 'reset', initialState})
31 | function getTogglerProps({onClick, ...props} = {}) {
32 | return {
33 | 'aria-pressed': on,
34 | onClick: callAll(onClick, toggle),
35 | ...props,
36 | }
37 | }
38 |
39 | function getResetterProps({onClick, ...props} = {}) {
40 | return {
41 | onClick: callAll(onClick, reset),
42 | ...props,
43 | }
44 | }
45 |
46 | return {
47 | on,
48 | reset,
49 | toggle,
50 | getTogglerProps,
51 | getResetterProps,
52 | }
53 | }
54 | // export {useToggle, toggleReducer}
55 |
56 | // import {useToggle, toggleReducer} from './use-toggle'
57 |
58 | function App() {
59 | const [timesClicked, setTimesClicked] = React.useState(0)
60 | const clickedTooMuch = timesClicked >= 4
61 |
62 | function toggleStateReducer(state, action) {
63 | if (action.type === 'toggle' && clickedTooMuch) {
64 | return {on: state.on}
65 | }
66 | return toggleReducer(state, action)
67 | }
68 |
69 | const {on, getTogglerProps, getResetterProps} = useToggle({
70 | reducer: toggleStateReducer,
71 | })
72 |
73 | return (
74 |
75 |
setTimesClicked(count => count + 1),
80 | })}
81 | />
82 | {clickedTooMuch ? (
83 |
84 | Whoa, you clicked too much!
85 |
86 |
87 | ) : timesClicked > 0 ? (
88 | Click count: {timesClicked}
89 | ) : null}
90 | setTimesClicked(0)})}>
91 | Reset
92 |
93 |
94 | )
95 | }
96 |
97 | export default App
98 |
--------------------------------------------------------------------------------
/src/final/05.js:
--------------------------------------------------------------------------------
1 | // state reducer
2 | // http://localhost:3000/isolated/final/05.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
8 |
9 | function toggleReducer(state, {type, initialState}) {
10 | switch (type) {
11 | case 'toggle': {
12 | return {on: !state.on}
13 | }
14 | case 'reset': {
15 | return initialState
16 | }
17 | default: {
18 | throw new Error(`Unsupported type: ${type}`)
19 | }
20 | }
21 | }
22 |
23 | function useToggle({initialOn = false, reducer = toggleReducer} = {}) {
24 | const {current: initialState} = React.useRef({on: initialOn})
25 | const [state, dispatch] = React.useReducer(reducer, initialState)
26 | const {on} = state
27 |
28 | const toggle = () => dispatch({type: 'toggle'})
29 | const reset = () => dispatch({type: 'reset', initialState})
30 |
31 | function getTogglerProps({onClick, ...props} = {}) {
32 | return {
33 | 'aria-pressed': on,
34 | onClick: callAll(onClick, toggle),
35 | ...props,
36 | }
37 | }
38 |
39 | function getResetterProps({onClick, ...props} = {}) {
40 | return {
41 | onClick: callAll(onClick, reset),
42 | ...props,
43 | }
44 | }
45 |
46 | return {
47 | on,
48 | reset,
49 | toggle,
50 | getTogglerProps,
51 | getResetterProps,
52 | }
53 | }
54 |
55 | function App() {
56 | const [timesClicked, setTimesClicked] = React.useState(0)
57 | const clickedTooMuch = timesClicked >= 4
58 |
59 | function toggleStateReducer(state, action) {
60 | switch (action.type) {
61 | case 'toggle': {
62 | if (clickedTooMuch) {
63 | return {on: state.on}
64 | }
65 | return {on: !state.on}
66 | }
67 | case 'reset': {
68 | return {on: false}
69 | }
70 | default: {
71 | throw new Error(`Unsupported type: ${action.type}`)
72 | }
73 | }
74 | }
75 |
76 | const {on, getTogglerProps, getResetterProps} = useToggle({
77 | reducer: toggleStateReducer,
78 | })
79 |
80 | return (
81 |
82 |
setTimesClicked(count => count + 1),
87 | })}
88 | />
89 | {clickedTooMuch ? (
90 |
91 | Whoa, you clicked too much!
92 |
93 |
94 | ) : timesClicked > 0 ? (
95 | Click count: {timesClicked}
96 | ) : null}
97 | setTimesClicked(0)})}>
98 | Reset
99 |
100 |
101 | )
102 | }
103 |
104 | export default App
105 |
--------------------------------------------------------------------------------
/src/exercise/05.js:
--------------------------------------------------------------------------------
1 | // State Reducer
2 | // http://localhost:3000/isolated/exercise/05.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
8 |
9 | const TOGGLE_ACTION_TOGGLE = 'toggle';
10 | const TOGGLE_ACTION_RESET = 'reset';
11 |
12 | function toggleReducer(state, {type, initialState}) {
13 | switch (type) {
14 | case TOGGLE_ACTION_TOGGLE: {
15 | return {on: !state.on}
16 | }
17 | case TOGGLE_ACTION_RESET: {
18 | return initialState
19 | }
20 | default: {
21 | throw new Error(`Unsupported type: ${type}`)
22 | }
23 | }
24 | }
25 |
26 | // 🐨 add a new option called `reducer` that defaults to `toggleReducer`
27 | function useToggle({initialOn = false, reducer = toggleReducer} = {}) {
28 | const {current: initialState} = React.useRef({on: initialOn})
29 | const [state, dispatch] = React.useReducer(reducer, initialState)
30 | const {on} = state
31 |
32 | const toggle = () => dispatch({type: TOGGLE_ACTION_TOGGLE})
33 | const reset = () => dispatch({type: TOGGLE_ACTION_RESET, initialState})
34 |
35 | function getTogglerProps({onClick, ...props} = {}) {
36 | return {
37 | 'aria-pressed': on,
38 | onClick: callAll(onClick, toggle),
39 | ...props,
40 | }
41 | }
42 |
43 | function getResetterProps({onClick, ...props} = {}) {
44 | return {
45 | onClick: callAll(onClick, reset),
46 | ...props,
47 | }
48 | }
49 |
50 | return {
51 | on,
52 | reset,
53 | toggle,
54 | getTogglerProps,
55 | getResetterProps,
56 | }
57 | }
58 |
59 | function App() {
60 | const [timesClicked, setTimesClicked] = React.useState(0)
61 | const clickedTooMuch = timesClicked >= 4
62 |
63 | function toggleStateReducer(state, action) {
64 | if (action.type === TOGGLE_ACTION_TOGGLE && clickedTooMuch) {
65 | return {on: state.on}
66 | }
67 |
68 | return toggleReducer(state, action);
69 | }
70 |
71 | const {on, getTogglerProps, getResetterProps} = useToggle({
72 | reducer: toggleStateReducer,
73 | })
74 |
75 | return (
76 |
77 |
setTimesClicked(count => count + 1),
82 | })}
83 | />
84 | {clickedTooMuch ? (
85 |
86 | Whoa, you clicked too much!
87 |
88 |
89 | ) : timesClicked > 0 ? (
90 | Click count: {timesClicked}
91 | ) : null}
92 | setTimesClicked(0)})}>
93 | Reset
94 |
95 |
96 | )
97 | }
98 |
99 | export default App
100 |
101 | /*
102 | eslint
103 | no-unused-vars: "off",
104 | */
105 |
--------------------------------------------------------------------------------
/src/final/05.extra-2.js:
--------------------------------------------------------------------------------
1 | // state reducer
2 | // 💯 state reducer action types
3 | // http://localhost:3000/isolated/final/05.extra-2.js
4 |
5 | import * as React from 'react'
6 | import {Switch} from '../switch'
7 |
8 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
9 |
10 | const actionTypes = {
11 | toggle: 'toggle',
12 | reset: 'reset',
13 | }
14 |
15 | function toggleReducer(state, {type, initialState}) {
16 | switch (type) {
17 | case actionTypes.toggle: {
18 | return {on: !state.on}
19 | }
20 | case actionTypes.reset: {
21 | return initialState
22 | }
23 | default: {
24 | throw new Error(`Unsupported type: ${type}`)
25 | }
26 | }
27 | }
28 |
29 | function useToggle({initialOn = false, reducer = toggleReducer} = {}) {
30 | const {current: initialState} = React.useRef({on: initialOn})
31 | const [state, dispatch] = React.useReducer(reducer, initialState)
32 | const {on} = state
33 |
34 | const toggle = () => dispatch({type: actionTypes.toggle})
35 | const reset = () => dispatch({type: actionTypes.reset, initialState})
36 |
37 | function getTogglerProps({onClick, ...props} = {}) {
38 | return {
39 | 'aria-pressed': on,
40 | onClick: callAll(onClick, toggle),
41 | ...props,
42 | }
43 | }
44 |
45 | function getResetterProps({onClick, ...props} = {}) {
46 | return {
47 | onClick: callAll(onClick, reset),
48 | ...props,
49 | }
50 | }
51 |
52 | return {
53 | on,
54 | reset,
55 | toggle,
56 | getTogglerProps,
57 | getResetterProps,
58 | }
59 | }
60 | // export {useToggle, toggleReducer, actionTypes}
61 |
62 | // import {useToggle, toggleReducer, actionTypes} from './use-toggle'
63 |
64 | function App() {
65 | const [timesClicked, setTimesClicked] = React.useState(0)
66 | const clickedTooMuch = timesClicked >= 4
67 |
68 | function toggleStateReducer(state, action) {
69 | if (action.type === actionTypes.toggle && clickedTooMuch) {
70 | return {on: state.on}
71 | }
72 | return toggleReducer(state, action)
73 | }
74 |
75 | const {on, getTogglerProps, getResetterProps} = useToggle({
76 | reducer: toggleStateReducer,
77 | })
78 |
79 | return (
80 |
81 |
setTimesClicked(count => count + 1),
86 | })}
87 | />
88 | {clickedTooMuch ? (
89 |
90 | Whoa, you clicked too much!
91 |
92 |
93 | ) : timesClicked > 0 ? (
94 | Click count: {timesClicked}
95 | ) : null}
96 | setTimesClicked(0)})}>
97 | Reset
98 |
99 |
100 | )
101 | }
102 |
103 | export default App
104 |
--------------------------------------------------------------------------------
/src/__tests__/06.extra-4.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils'
3 | import {render, screen} from '@testing-library/react'
4 | import userEvent from '@testing-library/user-event'
5 | import {Toggle} from '../final/06.extra-4'
6 | // import {Toggle} from '../exercise/06'
7 |
8 | beforeEach(() => {
9 | jest.spyOn(console, 'error').mockImplementation(() => {})
10 | })
11 |
12 | afterEach(() => {
13 | console.error.mockRestore()
14 | })
15 |
16 | test('warning for controlled component without onChange', () => {
17 | render( )
18 | alfredTip(
19 | () =>
20 | expect(console.error).toHaveBeenCalledWith(
21 | expect.stringMatching(/readOnly/i),
22 | ),
23 | 'Make sure the error message explains you can use a "readOnly" prop',
24 | )
25 | alfredTip(
26 | () =>
27 | expect(console.error).toHaveBeenCalledWith(
28 | expect.stringMatching(/onChange/i),
29 | ),
30 | 'Make sure the error message explains you can use a "onChange" prop',
31 | )
32 | alfredTip(
33 | () =>
34 | expect(console.error).toHaveBeenCalledWith(
35 | expect.stringMatching(/initialOn/i),
36 | ),
37 | 'Make sure the error message explains you can use an "initialOn" prop',
38 | )
39 | expect(console.error).toHaveBeenCalledTimes(1)
40 | })
41 |
42 | test('no warning for controlled component with onChange prop', () => {
43 | render( {}} />)
44 | expect(console.error).toHaveBeenCalledTimes(0)
45 | })
46 |
47 | test('no warning for controlled component with readOnly prop', () => {
48 | render( )
49 | alfredTip(
50 | () => expect(console.error).toHaveBeenCalledTimes(0),
51 | 'Make sure you forward the readOnly prop to the hook',
52 | )
53 | })
54 |
55 | test('warning for changing from controlled to uncontrolled', () => {
56 | function Example() {
57 | const [state, setState] = React.useState(true)
58 | return setState(undefined)} />
59 | }
60 | render( )
61 | userEvent.click(screen.getByLabelText(/toggle/i))
62 | alfredTip(
63 | () =>
64 | expect(console.error).toHaveBeenCalledWith(
65 | expect.stringMatching(/from controlled to uncontrolled/i),
66 | ),
67 | `Make sure to explain that it's changing "from controlled to uncontrolled"`,
68 | )
69 | })
70 |
71 | test('warning for changing from uncontrolled to controlled', () => {
72 | function Example() {
73 | const [state, setState] = React.useState(undefined)
74 | return setState(true)} />
75 | }
76 | render( )
77 | userEvent.click(screen.getByLabelText(/toggle/i))
78 | alfredTip(
79 | () =>
80 | expect(console.error).toHaveBeenCalledWith(
81 | expect.stringMatching(/from uncontrolled to controlled/i),
82 | ),
83 | `Make sure to explain that it's changing "from uncontrolled to controlled"`,
84 | )
85 | })
86 |
--------------------------------------------------------------------------------
/src/final/06.js:
--------------------------------------------------------------------------------
1 | // Control Props
2 | // http://localhost:3000/isolated/final/06.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
8 |
9 | const actionTypes = {
10 | toggle: 'toggle',
11 | reset: 'reset',
12 | }
13 |
14 | function toggleReducer(state, {type, initialState}) {
15 | switch (type) {
16 | case actionTypes.toggle: {
17 | return {on: !state.on}
18 | }
19 | case actionTypes.reset: {
20 | return initialState
21 | }
22 | default: {
23 | throw new Error(`Unsupported type: ${type}`)
24 | }
25 | }
26 | }
27 |
28 | function useToggle({
29 | initialOn = false,
30 | reducer = toggleReducer,
31 | onChange,
32 | on: controlledOn,
33 | } = {}) {
34 | const {current: initialState} = React.useRef({on: initialOn})
35 | const [state, dispatch] = React.useReducer(reducer, initialState)
36 | const onIsControlled = controlledOn != null
37 | const on = onIsControlled ? controlledOn : state.on
38 |
39 | function dispatchWithOnChange(action) {
40 | if (!onIsControlled) {
41 | dispatch(action)
42 | }
43 | onChange?.(reducer({...state, on}, action), action)
44 | }
45 |
46 | const toggle = () => dispatchWithOnChange({type: actionTypes.toggle})
47 | const reset = () =>
48 | dispatchWithOnChange({type: actionTypes.reset, initialState})
49 |
50 | function getTogglerProps({onClick, ...props} = {}) {
51 | return {
52 | 'aria-pressed': on,
53 | onClick: callAll(onClick, toggle),
54 | ...props,
55 | }
56 | }
57 |
58 | function getResetterProps({onClick, ...props} = {}) {
59 | return {
60 | onClick: callAll(onClick, reset),
61 | ...props,
62 | }
63 | }
64 |
65 | return {
66 | on,
67 | reset,
68 | toggle,
69 | getTogglerProps,
70 | getResetterProps,
71 | }
72 | }
73 |
74 | function Toggle({on: controlledOn, onChange}) {
75 | const {on, getTogglerProps} = useToggle({on: controlledOn, onChange})
76 | const props = getTogglerProps({on})
77 | return
78 | }
79 |
80 | function App() {
81 | const [bothOn, setBothOn] = React.useState(false)
82 | const [timesClicked, setTimesClicked] = React.useState(0)
83 |
84 | function handleToggleChange(state, action) {
85 | if (action.type === actionTypes.toggle && timesClicked > 4) {
86 | return
87 | }
88 | setBothOn(state.on)
89 | setTimesClicked(c => c + 1)
90 | }
91 |
92 | function handleResetClick() {
93 | setBothOn(false)
94 | setTimesClicked(0)
95 | }
96 |
97 | return (
98 |
99 |
100 |
101 |
102 |
103 | {timesClicked > 4 ? (
104 |
105 | Whoa, you clicked too much!
106 |
107 |
108 | ) : (
109 |
Click count: {timesClicked}
110 | )}
111 |
Reset
112 |
113 |
114 |
Uncontrolled Toggle:
115 |
117 | console.info('Uncontrolled Toggle onChange', ...args)
118 | }
119 | />
120 |
121 |
122 | )
123 | }
124 |
125 | export default App
126 | // we're adding the Toggle export for tests
127 | export {Toggle}
128 |
129 | /*
130 | eslint
131 | no-unused-expressions: "off",
132 | */
133 |
--------------------------------------------------------------------------------
/src/exercise/06.js:
--------------------------------------------------------------------------------
1 | // Control Props
2 | // http://localhost:3000/isolated/exercise/06.js
3 |
4 | import * as React from 'react'
5 | import {Switch} from '../switch'
6 |
7 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
8 |
9 | const actionTypes = {
10 | toggle: 'toggle',
11 | reset: 'reset',
12 | }
13 |
14 | function toggleReducer(state, {type, initialState}) {
15 | switch (type) {
16 | case actionTypes.toggle: {
17 | return {on: !state.on}
18 | }
19 | case actionTypes.reset: {
20 | return initialState
21 | }
22 | default: {
23 | throw new Error(`Unsupported type: ${type}`)
24 | }
25 | }
26 | }
27 |
28 | function useToggle({
29 | initialOn = false,
30 | reducer = toggleReducer,
31 | onChange = null,
32 | on: controlledOn = false
33 | } = {}) {
34 | const {current: initialState} = React.useRef({on: initialOn})
35 | const [state, dispatch] = React.useReducer(reducer, initialState)
36 | const onIsControlled = controlledOn != null;
37 | const on = onIsControlled ? controlledOn : state.on;
38 |
39 | function dispatchWithOnChange(action) {
40 | if (!onIsControlled) {
41 | dispatch(action);
42 | }
43 | if (onChange) {
44 | onChange(reducer({...state, on}, action), action);
45 | }
46 | }
47 |
48 | const toggle = () => dispatchWithOnChange({type: actionTypes.toggle})
49 | const reset = () => dispatchWithOnChange({type: actionTypes.reset, initialState})
50 |
51 | function getTogglerProps({onClick, ...props} = {}) {
52 | return {
53 | 'aria-pressed': on,
54 | onClick: callAll(onClick, toggle),
55 | ...props,
56 | }
57 | }
58 |
59 | function getResetterProps({onClick, ...props} = {}) {
60 | return {
61 | onClick: callAll(onClick, reset),
62 | ...props,
63 | }
64 | }
65 |
66 | return {
67 | on,
68 | reset,
69 | toggle,
70 | getTogglerProps,
71 | getResetterProps,
72 | }
73 | }
74 |
75 | function Toggle({on: controlledOn, onChange}) {
76 | const {on, getTogglerProps} = useToggle({on: controlledOn, onChange})
77 | const props = getTogglerProps({on})
78 | return
79 | }
80 |
81 | function App() {
82 | const [bothOn, setBothOn] = React.useState(false)
83 | const [timesClicked, setTimesClicked] = React.useState(0)
84 |
85 | function handleToggleChange(state, action) {
86 | if (action.type === actionTypes.toggle && timesClicked > 4) {
87 | return
88 | }
89 | setBothOn(state.on)
90 | setTimesClicked(c => c + 1)
91 | }
92 |
93 | function handleResetClick() {
94 | setBothOn(false)
95 | setTimesClicked(0)
96 | }
97 |
98 | return (
99 |
100 |
101 |
102 |
103 |
104 | {timesClicked > 4 ? (
105 |
106 | Whoa, you clicked too much!
107 |
108 |
109 | ) : (
110 |
Click count: {timesClicked}
111 | )}
112 |
Reset
113 |
114 |
115 |
Uncontrolled Toggle:
116 |
118 | console.info('Uncontrolled Toggle onChange', ...args)
119 | }
120 | />
121 |
122 |
123 | )
124 | }
125 |
126 | export default App
127 | // we're adding the Toggle export for tests
128 | export {Toggle}
129 |
130 | /*
131 | eslint
132 | no-unused-vars: "off",
133 | */
134 |
--------------------------------------------------------------------------------
/src/exercise/04.md:
--------------------------------------------------------------------------------
1 | # Prop Collections and Getters
2 |
3 | ## 📝 Your Notes
4 |
5 | Elaborate on your learnings here in `src/exercise/04.md`
6 |
7 | ## Background
8 |
9 | **One liner:** The Prop Collections and Getters Pattern allows your hook to
10 | support common use cases for UI elements people build with your hook.
11 |
12 | In typical UI components, you need to take accessibility into account. For a
13 | button functioning as a toggle, it should have the `aria-pressed` attribute set
14 | to `true` or `false` if it's toggled on or off. In addition to remembering that,
15 | people need to remember to also add the `onClick` handler to call `toggle`.
16 |
17 | Lots of the reusable/flexible components and hooks that we'll create have some
18 | common use-cases and it'd be cool if we could make it easier to use our
19 | components and hooks the right way without requiring people to wire things up
20 | for common use cases.
21 |
22 | **Real World Projects that use this pattern:**
23 |
24 | - [downshift](https://github.com/downshift-js/downshift) (uses prop getters)
25 | - [react-table](https://github.com/tannerlinsley/react-table) (uses prop
26 | getters)
27 | - [`@reach/tooltip`](https://reacttraining.com/reach-ui/tooltip) (uses prop
28 | collections)
29 |
30 | ## Exercise
31 |
32 | Production deploys:
33 |
34 | - [Exercise](http://advanced-react-patterns.netlify.app/isolated/exercise/04.js)
35 | - [Final](http://advanced-react-patterns.netlify.app/isolated/final/04.js)
36 |
37 | In our simple example, this isn't too much for folks to remember, but in more
38 | complex components, the list of props that need to be applied to elements can be
39 | extensive, so it can be a good idea to take the common use cases for our hook
40 | and/or components and make objects of props that people can simply spread across
41 | the UI they render.
42 |
43 | ## Extra Credit
44 |
45 | ### 1. 💯 prop getters
46 |
47 | [Production deploy](http://advanced-react-patterns.netlify.app/isolated/final/04.extra-1.js)
48 |
49 | Uh oh! Someone wants to use our `togglerProps` object, but they need to apply
50 | their own `onClick` handler! Try doing that by updating the `App` component to
51 | this:
52 |
53 | ```javascript
54 | function App() {
55 | const {on, togglerProps} = useToggle()
56 | return (
57 |
58 |
59 |
60 | console.info('onButtonClick')}
64 | >
65 | {on ? 'on' : 'off'}
66 |
67 |
68 | )
69 | }
70 | ```
71 |
72 | Does that work? Why not? Can you change it to make it work?
73 |
74 | What if we change the API slightly so that instead of having an object of props,
75 | we call a function to get the props. Then we can pass that function the props we
76 | want applied and that function will be responsible for composing the props
77 | together.
78 |
79 | Let's try that. Update the `App` component to this:
80 |
81 | ```javascript
82 | function App() {
83 | const {on, getTogglerProps} = useToggle()
84 | return (
85 |
86 |
87 |
88 | console.info('onButtonClick'),
92 | id: 'custom-button-id',
93 | })}
94 | >
95 | {on ? 'on' : 'off'}
96 |
97 |
98 | )
99 | }
100 | ```
101 |
102 | See if you can make that API work.
103 |
104 | ## 🦉 Feedback
105 |
106 | Fill out
107 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Patterns%20%F0%9F%A4%AF&e=04%3A%20Prop%20Collections%20and%20Getters&em=).
108 |
--------------------------------------------------------------------------------
/src/exercise/05.md:
--------------------------------------------------------------------------------
1 | # State Reducer
2 |
3 | ## 📝 Your Notes
4 |
5 | Elaborate on your learnings here in `src/exercise/05.md`
6 |
7 | ## Background
8 |
9 | **One liner:** The State Reducer Pattern inverts control over the state
10 | management of your hook and/or component to the developer using it so they can
11 | control the state changes that happen when dispatching events.
12 |
13 | During the life of a reusable component which is used in many different
14 | contexts, feature requests are made over and over again to handle different
15 | cases and cater to different scenarios.
16 |
17 | We could definitely add props to our component and add logic in our reducer for
18 | how to handle these different cases, but there's a never ending list of logical
19 | customizations that people could want out of our custom hook and we don't want
20 | to have to code for every one of those cases.
21 |
22 | 📜 Read more about this pattern in:
23 | [The State Reducer Pattern with React Hooks](https://kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks)
24 |
25 | **Real World Projects that use this pattern:**
26 |
27 | - [downshift](https://github.com/downshift-js/downshift)
28 |
29 | ## Exercise
30 |
31 | Production deploys:
32 |
33 | - [Exercise](http://advanced-react-patterns.netlify.app/isolated/exercise/05.js)
34 | - [Final](http://advanced-react-patterns.netlify.app/isolated/final/05.js)
35 |
36 | In this exercise, we want to prevent the toggle from updating the toggle state
37 | after it's been clicked 4 times in a row before resetting. We could easily add
38 | that logic to our reducer, but instead we're going to apply a computer science
39 | pattern called "Inversion of Control" where we effectively say: "Here you go!
40 | You have complete control over how this thing works. It's now your
41 | responsibility."
42 |
43 | > As an aside, before React Hooks were a thing, this was pretty tricky to
44 | > implement and resulted in pretty weird code, but with useReducer, this is WAY
45 | > better. I ❤️ hooks. 😍
46 |
47 | Your job is to enable people to provide a custom `reducer` so they can have
48 | complete control over how state updates happen in our ` ` component.
49 |
50 | ## Extra Credit
51 |
52 | ### 1. 💯 default state reducer
53 |
54 | [Production deploy](http://advanced-react-patterns.netlify.app/isolated/final/05.extra-1.js)
55 |
56 | Our `toggleReducer` is pretty simple, so it's not a huge pain for people to
57 | implement their own. However, in a more realistic scenario, people may struggle
58 | with having to basically re-implement our entire reducer which could be pretty
59 | complex. So see if you can provide a nice way for people to be able to use the
60 | `toggleReducer` themselves if they so choose. Feel free to test this out by
61 | changing the `toggleStateReducer` function inside the ` ` example to use
62 | the default reducer instead of having to re-implement what to do when the action
63 | type is `'reset'`:
64 |
65 | ```javascript
66 | function toggleStateReducer(state, action) {
67 | if (action.type === 'toggle' && timesClicked >= 4) {
68 | return {on: state.on}
69 | }
70 | return toggleReducer(state, action)
71 | }
72 | ```
73 |
74 | ### 2. 💯 state reducer action types
75 |
76 | [Production deploy](http://advanced-react-patterns.netlify.app/isolated/final/05.extra-2.js)
77 |
78 | Requiring people to know what action types are available and code them is just
79 | asking for annoying typos (unless you're using TypeScript or Flow, which you
80 | really should consider). See if you can figure out a good way to help people
81 | avoid typos in those strings by perhaps putting all possible action types on an
82 | object somewhere and referencing them instead of hard coding them.
83 |
84 | ## 🦉 Feedback
85 |
86 | Fill out
87 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Patterns%20%F0%9F%A4%AF&e=05%3A%20State%20Reducer&em=).
88 |
--------------------------------------------------------------------------------
/src/final/06.extra-1.js:
--------------------------------------------------------------------------------
1 | // Control Props
2 | // 💯 add read only warning
3 | // http://localhost:3000/isolated/final/06.extra-1.js
4 |
5 | import * as React from 'react'
6 | import warning from 'warning'
7 | import {Switch} from '../switch'
8 |
9 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
10 |
11 | const actionTypes = {
12 | toggle: 'toggle',
13 | reset: 'reset',
14 | }
15 |
16 | function toggleReducer(state, {type, initialState}) {
17 | switch (type) {
18 | case actionTypes.toggle: {
19 | return {on: !state.on}
20 | }
21 | case actionTypes.reset: {
22 | return initialState
23 | }
24 | default: {
25 | throw new Error(`Unsupported type: ${type}`)
26 | }
27 | }
28 | }
29 |
30 | function useToggle({
31 | initialOn = false,
32 | reducer = toggleReducer,
33 | onChange,
34 | on: controlledOn,
35 | readOnly = false,
36 | } = {}) {
37 | const {current: initialState} = React.useRef({on: initialOn})
38 | const [state, dispatch] = React.useReducer(reducer, initialState)
39 |
40 | const onIsControlled = controlledOn != null
41 | const on = onIsControlled ? controlledOn : state.on
42 |
43 | const hasOnChange = Boolean(onChange)
44 | React.useEffect(() => {
45 | warning(
46 | !(!hasOnChange && onIsControlled && !readOnly),
47 | `An \`on\` prop was provided to useToggle without an \`onChange\` handler. This will render a read-only toggle. If you want it to be mutable, use \`initialOn\`. Otherwise, set either \`onChange\` or \`readOnly\`.`,
48 | )
49 | }, [hasOnChange, onIsControlled, readOnly])
50 |
51 | function dispatchWithOnChange(action) {
52 | if (!onIsControlled) {
53 | dispatch(action)
54 | }
55 | onChange?.(reducer({...state, on}, action), action)
56 | }
57 |
58 | const toggle = () => dispatchWithOnChange({type: actionTypes.toggle})
59 | const reset = () =>
60 | dispatchWithOnChange({type: actionTypes.reset, initialState})
61 |
62 | function getTogglerProps({onClick, ...props} = {}) {
63 | return {
64 | 'aria-pressed': on,
65 | onClick: callAll(onClick, toggle),
66 | ...props,
67 | }
68 | }
69 |
70 | function getResetterProps({onClick, ...props} = {}) {
71 | return {
72 | onClick: callAll(onClick, reset),
73 | ...props,
74 | }
75 | }
76 |
77 | return {
78 | on,
79 | reset,
80 | toggle,
81 | getTogglerProps,
82 | getResetterProps,
83 | }
84 | }
85 |
86 | function Toggle({on: controlledOn, onChange, readOnly}) {
87 | const {on, getTogglerProps} = useToggle({
88 | on: controlledOn,
89 | onChange,
90 | readOnly,
91 | })
92 | const props = getTogglerProps({on})
93 | return
94 | }
95 |
96 | function App() {
97 | const [bothOn, setBothOn] = React.useState(false)
98 | const [timesClicked, setTimesClicked] = React.useState(0)
99 |
100 | function handleToggleChange(state, action) {
101 | if (action.type === actionTypes.toggle && timesClicked > 4) {
102 | return
103 | }
104 | setBothOn(state.on)
105 | setTimesClicked(c => c + 1)
106 | }
107 |
108 | function handleResetClick() {
109 | setBothOn(false)
110 | setTimesClicked(0)
111 | }
112 |
113 | return (
114 |
115 |
116 |
117 |
118 |
119 | {timesClicked > 4 ? (
120 |
121 | Whoa, you clicked too much!
122 |
123 |
124 | ) : (
125 |
Click count: {timesClicked}
126 | )}
127 |
Reset
128 |
129 |
130 |
Uncontrolled Toggle:
131 |
133 | console.info('Uncontrolled Toggle onChange', ...args)
134 | }
135 | />
136 |
137 |
138 | )
139 | }
140 |
141 | export default App
142 | // we're adding the Toggle export for tests
143 | export {Toggle}
144 |
145 | /*
146 | eslint
147 | no-unused-expressions: "off",
148 | */
149 |
--------------------------------------------------------------------------------
/src/__tests__/01.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {render, screen, waitForElementToBeRemoved} from '@testing-library/react'
3 | import userEvent from '@testing-library/user-event'
4 | import * as userClient from '../user-client'
5 | import {AuthProvider} from '../auth-context'
6 | import App from '../final/01'
7 | // import App from '../exercise/01'
8 |
9 | jest.mock('../user-client', () => {
10 | return {updateUser: jest.fn(() => Promise.resolve())}
11 | })
12 |
13 | const mockUser = {username: 'jakiechan', tagline: '', bio: ''}
14 |
15 | function renderApp() {
16 | const utils = render(
17 |
18 |
19 | ,
20 | )
21 |
22 | const userDisplayPre = utils.container.querySelector('pre')
23 | return {
24 | ...utils,
25 | submitButton: screen.getByText(/✔/),
26 | resetButton: screen.getByText(/reset/i),
27 | taglineInput: screen.getByLabelText(/tagline/i),
28 | bioInput: screen.getByLabelText(/bio/i),
29 | waitForLoading: () =>
30 | waitForElementToBeRemoved(() => screen.getByText(/\.\.\./i)),
31 | userDisplayPre,
32 | getDisplayData: () => JSON.parse(userDisplayPre.textContent),
33 | }
34 | }
35 |
36 | test('happy path works', async () => {
37 | const {
38 | submitButton,
39 | resetButton,
40 | taglineInput,
41 | bioInput,
42 | waitForLoading,
43 | getDisplayData,
44 | } = renderApp()
45 |
46 | // unchanged form disables reset and submit buttons
47 | expect(submitButton).toHaveAttribute('disabled')
48 | expect(resetButton).toHaveAttribute('disabled')
49 |
50 | const testData = {...mockUser, tagline: 'test tagline', bio: 'test bio'}
51 | userEvent.type(taglineInput, testData.tagline)
52 | userEvent.type(bioInput, testData.bio)
53 |
54 | // changed form enables submit and reset
55 | expect(submitButton).toHaveTextContent(/submit/i)
56 | expect(submitButton).not.toHaveAttribute('disabled')
57 | expect(resetButton).not.toHaveAttribute('disabled')
58 |
59 | const updatedUser = {...mockUser, ...testData}
60 | userClient.updateUser.mockImplementationOnce(() =>
61 | Promise.resolve(updatedUser),
62 | )
63 |
64 | userEvent.click(submitButton)
65 |
66 | // pending form sets the submit button to ... and disables the submit and reset buttons
67 | expect(submitButton).toHaveTextContent(/\.\.\./i)
68 | expect(submitButton).toHaveAttribute('disabled')
69 | expect(resetButton).toHaveAttribute('disabled')
70 | // submitting the form invokes userClient.updateUser
71 | expect(userClient.updateUser).toHaveBeenCalledTimes(1)
72 | expect(userClient.updateUser).toHaveBeenCalledWith(mockUser, testData)
73 | userClient.updateUser.mockClear()
74 |
75 | // once the submit button changes from ... then we know the request is over
76 | await waitForLoading()
77 |
78 | // make sure all the text that should appear is there and the button state is correct
79 | expect(submitButton).toHaveAttribute('disabled')
80 | expect(submitButton).toHaveTextContent(/✔/)
81 | expect(resetButton).toHaveAttribute('disabled')
82 |
83 | // make sure the inputs have the right value
84 | expect(taglineInput.value).toBe(updatedUser.tagline)
85 | expect(bioInput.value).toBe(updatedUser.bio)
86 |
87 | // make sure the display data is correct
88 | expect(getDisplayData()).toEqual(updatedUser)
89 | })
90 |
91 | test('reset works', () => {
92 | const {resetButton, taglineInput} = renderApp()
93 |
94 | userEvent.type(taglineInput, 'foo')
95 | userEvent.click(resetButton)
96 | expect(taglineInput.value).toBe(mockUser.tagline)
97 | })
98 |
99 | test('failure works', async () => {
100 | const {
101 | submitButton,
102 | resetButton,
103 | taglineInput,
104 | bioInput,
105 | waitForLoading,
106 | getDisplayData,
107 | } = renderApp()
108 |
109 | const testData = {...mockUser, bio: 'test bio'}
110 | userEvent.type(bioInput, testData.bio)
111 | const testErrorMessage = 'test error message'
112 | userClient.updateUser.mockImplementationOnce(() =>
113 | Promise.reject({message: testErrorMessage}),
114 | )
115 |
116 | const updatedUser = {...mockUser, ...testData}
117 |
118 | userEvent.click(submitButton)
119 |
120 | await waitForLoading()
121 |
122 | expect(submitButton).toHaveTextContent(/try again/i)
123 | screen.getByText(testErrorMessage)
124 | expect(getDisplayData()).toEqual(mockUser)
125 |
126 | userClient.updateUser.mockClear()
127 |
128 | userClient.updateUser.mockImplementationOnce(() =>
129 | Promise.resolve(updatedUser),
130 | )
131 | userEvent.click(submitButton)
132 |
133 | await waitForLoading()
134 |
135 | expect(submitButton).toHaveTextContent(/✔/)
136 | expect(resetButton).toHaveAttribute('disabled')
137 |
138 | // make sure the inputs have the right value
139 | expect(taglineInput.value).toBe(updatedUser.tagline)
140 | expect(bioInput.value).toBe(updatedUser.bio)
141 |
142 | // make sure the display data is correct
143 | expect(getDisplayData()).toEqual(updatedUser)
144 | })
145 |
--------------------------------------------------------------------------------
/src/final/06.extra-2.js:
--------------------------------------------------------------------------------
1 | // Control Props
2 | // 💯 add a controlled state warning
3 | // http://localhost:3000/isolated/final/06.extra-2.js
4 |
5 | import * as React from 'react'
6 | import warning from 'warning'
7 | import {Switch} from '../switch'
8 |
9 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
10 |
11 | const actionTypes = {
12 | toggle: 'toggle',
13 | reset: 'reset',
14 | }
15 |
16 | function toggleReducer(state, {type, initialState}) {
17 | switch (type) {
18 | case actionTypes.toggle: {
19 | return {on: !state.on}
20 | }
21 | case actionTypes.reset: {
22 | return initialState
23 | }
24 | default: {
25 | throw new Error(`Unsupported type: ${type}`)
26 | }
27 | }
28 | }
29 |
30 | function useToggle({
31 | initialOn = false,
32 | reducer = toggleReducer,
33 | onChange,
34 | on: controlledOn,
35 | readOnly = false,
36 | } = {}) {
37 | const {current: initialState} = React.useRef({on: initialOn})
38 | const [state, dispatch] = React.useReducer(reducer, initialState)
39 |
40 | const onIsControlled = controlledOn != null
41 | const on = onIsControlled ? controlledOn : state.on
42 |
43 | const {current: onWasControlled} = React.useRef(onIsControlled)
44 | React.useEffect(() => {
45 | warning(
46 | !(onIsControlled && !onWasControlled),
47 | `\`useToggle\` 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 \`useToggle\` for the lifetime of the component. Check the \`on\` prop.`,
48 | )
49 | warning(
50 | !(!onIsControlled && onWasControlled),
51 | `\`useToggle\` 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 \`useToggle\` for the lifetime of the component. Check the \`on\` prop.`,
52 | )
53 | }, [onIsControlled, onWasControlled])
54 |
55 | const hasOnChange = Boolean(onChange)
56 | React.useEffect(() => {
57 | warning(
58 | !(!hasOnChange && onIsControlled && !readOnly),
59 | `An \`on\` prop was provided to useToggle without an \`onChange\` handler. This will render a read-only toggle. If you want it to be mutable, use \`initialOn\`. Otherwise, set either \`onChange\` or \`readOnly\`.`,
60 | )
61 | }, [hasOnChange, onIsControlled, readOnly])
62 |
63 | function dispatchWithOnChange(action) {
64 | if (!onIsControlled) {
65 | dispatch(action)
66 | }
67 | onChange?.(reducer({...state, on}, action), action)
68 | }
69 |
70 | const toggle = () => dispatchWithOnChange({type: actionTypes.toggle})
71 | const reset = () =>
72 | dispatchWithOnChange({type: actionTypes.reset, initialState})
73 |
74 | function getTogglerProps({onClick, ...props} = {}) {
75 | return {
76 | 'aria-pressed': on,
77 | onClick: callAll(onClick, toggle),
78 | ...props,
79 | }
80 | }
81 |
82 | function getResetterProps({onClick, ...props} = {}) {
83 | return {
84 | onClick: callAll(onClick, reset),
85 | ...props,
86 | }
87 | }
88 |
89 | return {
90 | on,
91 | reset,
92 | toggle,
93 | getTogglerProps,
94 | getResetterProps,
95 | }
96 | }
97 |
98 | function Toggle({on: controlledOn, onChange, readOnly}) {
99 | const {on, getTogglerProps} = useToggle({
100 | on: controlledOn,
101 | onChange,
102 | readOnly,
103 | })
104 | const props = getTogglerProps({on})
105 | return
106 | }
107 |
108 | function App() {
109 | const [bothOn, setBothOn] = React.useState(false)
110 | const [timesClicked, setTimesClicked] = React.useState(0)
111 |
112 | function handleToggleChange(state, action) {
113 | if (action.type === actionTypes.toggle && timesClicked > 4) {
114 | return
115 | }
116 | setBothOn(state.on)
117 | setTimesClicked(c => c + 1)
118 | }
119 |
120 | function handleResetClick() {
121 | setBothOn(false)
122 | setTimesClicked(0)
123 | }
124 |
125 | return (
126 |
127 |
128 |
129 |
130 |
131 | {timesClicked > 4 ? (
132 |
133 | Whoa, you clicked too much!
134 |
135 |
136 | ) : (
137 |
Click count: {timesClicked}
138 | )}
139 |
Reset
140 |
141 |
142 |
Uncontrolled Toggle:
143 |
145 | console.info('Uncontrolled Toggle onChange', ...args)
146 | }
147 | />
148 |
149 |
150 | )
151 | }
152 |
153 | export default App
154 | // we're adding the Toggle export for tests
155 | export {Toggle}
156 |
157 | /*
158 | eslint
159 | no-unused-expressions: "off",
160 | */
161 |
--------------------------------------------------------------------------------
/src/exercise/02.md:
--------------------------------------------------------------------------------
1 | # Compound Components
2 |
3 | ## 📝 Your Notes
4 |
5 | Elaborate on your learnings here in `src/exercise/02.md`
6 |
7 | ## Background
8 |
9 | **One liner:** The Compound Components Pattern enables you to provide a set of
10 | components that implicitely share state for a simple yet powerful declarative
11 | API for reusable components.
12 |
13 | Compound components are components that work together to form a complete UI. The
14 | classic example of this is `` and `` in HTML:
15 |
16 | ```html
17 |
18 | Option 1
19 | Option 2
20 |
21 | ```
22 |
23 | The `` is the element responsible for managing the state of the UI, and
24 | the `` elements are essentially more configuration for how the select
25 | should operate (specifically, which options are available and their values).
26 |
27 | Let's imagine that we were going to implement this native control manually. A
28 | naive implementation would look something like this:
29 |
30 | ```jsx
31 |
37 | ```
38 |
39 | This works fine, but it's less extensible/flexible than a compound components
40 | API. For example. What if I want to supply additional attributes on the
41 | ` ` that's rendered, or I want the `display` to change based on whether
42 | it's selected? We can easily add API surface area to support these use cases,
43 | but that's just more for us to code and more for users to learn. That's where
44 | compound components come in really handy!
45 |
46 | **Real World Projects that use this pattern:**
47 |
48 | - [`@reach/tabs`](https://reacttraining.com/reach-ui/tabs)
49 | - Actually most of [Reach UI](https://reacttraining.com/reach-ui) implements
50 | this pattern
51 |
52 | ## Exercise
53 |
54 | Production deploys:
55 |
56 | - [Exercise](http://advanced-react-patterns.netlify.app/isolated/exercise/02.js)
57 | - [Final](http://advanced-react-patterns.netlify.app/isolated/final/02.js)
58 |
59 | Every reusable component starts out as a simple implementation for a specific
60 | use case. It's advisable to not overcomplicate your components and try to solve
61 | every conceivable problem that you don't yet have (and likely will never have).
62 | But as changes come (and they almost always do), then you'll want the
63 | implementation of your component to be flexible and changeable. Learning how to
64 | do that is the point of much of this workshop.
65 |
66 | This is why we're starting with a super simple ` ` component.
67 |
68 | In this exercise we're going to make ` ` the parent of a few compound
69 | components:
70 |
71 | - ` ` renders children when the `on` state is `true`
72 | - ` ` renders children when the `on` state is `false`
73 | - ` ` renders the ` ` with the `on` prop set to the `on`
74 | state and the `onClick` prop set to `toggle`.
75 |
76 | We have a Toggle component that manages the state, and we want to render
77 | different parts of the UI however we want. We want control over the presentation
78 | of the UI.
79 |
80 | 🦉 The fundamental challenge you face with an API like this is the state shared
81 | between the components is implicit, meaning that the developer using your
82 | component cannot actually see or interact with the state (`on`) or the
83 | mechanisms for updating that state (`toggle`) that are being shared between the
84 | components.
85 |
86 | So in this exercise, we'll solve that problem by providing the compound
87 | components with the props they need implicitly using `React.cloneElement`.
88 |
89 | Here's a simple example of using `React.Children.map` and `React.cloneElement`:
90 |
91 | ```javascript
92 | function Foo({children}) {
93 | return React.Children.map(children, (child, index) => {
94 | return React.cloneElement(child, {
95 | id: `i-am-child-${index}`,
96 | })
97 | })
98 | }
99 |
100 | function Bar() {
101 | return (
102 |
103 | I will have id "i-am-child-0"
104 | I will have id "i-am-child-1"
105 | I will have id "i-am-child-2"
106 |
107 | )
108 | }
109 | ```
110 |
111 | ## Extra Credit
112 |
113 | ### 1. 💯 Support DOM component children
114 |
115 | [Production deploy](http://advanced-react-patterns.netlify.app/isolated/final/02.extra-1.js)
116 |
117 | > A DOM component is a built-in component like `
`, ` `, or
118 | > ` `. A composite component is a custom component like ` ` or
119 | > ` `.
120 |
121 | Try updating the `App` to this:
122 |
123 | ```javascript
124 | function App() {
125 | return (
126 |
127 |
128 | The button is on
129 | The button is off
130 | Hello
131 |
132 |
133 |
134 | )
135 | }
136 | ```
137 |
138 | Notice the error message in the console and try to fix it.
139 |
140 | ## 🦉 Feedback
141 |
142 | Fill out
143 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Patterns%20%F0%9F%A4%AF&e=02%3A%20Compound%20Components&em=).
144 |
--------------------------------------------------------------------------------
/src/exercise/01.js:
--------------------------------------------------------------------------------
1 | // Context Module Functions
2 | // http://localhost:3000/isolated/exercise/01.js
3 |
4 | import * as React from 'react'
5 | import {dequal} from 'dequal'
6 |
7 | import * as userClient from '../user-client'
8 | import {useAuth} from '../auth-context'
9 |
10 | const UserContext = React.createContext()
11 | UserContext.displayName = 'UserContext'
12 |
13 | function userReducer(state, action) {
14 | switch (action.type) {
15 | case 'start update': {
16 | return {
17 | ...state,
18 | user: {...state.user, ...action.updates},
19 | status: 'pending',
20 | storedUser: state.user,
21 | }
22 | }
23 | case 'finish update': {
24 | return {
25 | ...state,
26 | user: action.updatedUser,
27 | status: 'resolved',
28 | storedUser: null,
29 | error: null,
30 | }
31 | }
32 | case 'fail update': {
33 | return {
34 | ...state,
35 | status: 'rejected',
36 | error: action.error,
37 | user: state.storedUser,
38 | storedUser: null,
39 | }
40 | }
41 | case 'reset': {
42 | return {
43 | ...state,
44 | status: null,
45 | error: null,
46 | }
47 | }
48 | default: {
49 | throw new Error(`Unhandled action type: ${action.type}`)
50 | }
51 | }
52 | }
53 |
54 | function UserProvider({children}) {
55 | const {user} = useAuth()
56 | const [state, dispatch] = React.useReducer(userReducer, {
57 | status: null,
58 | error: null,
59 | storedUser: user,
60 | user,
61 | })
62 | const value = [state, dispatch]
63 | return {children}
64 | }
65 |
66 | function useUser() {
67 | const context = React.useContext(UserContext)
68 | if (context === undefined) {
69 | throw new Error(`useUser must be used within a UserProvider`)
70 | }
71 | return context
72 | }
73 |
74 | function updateUser(dispatch, user, updates) {
75 | dispatch({type: 'start update', updates})
76 | userClient.updateUser(user, updates).then(
77 | updatedUser => dispatch({type: 'finish update', updatedUser}),
78 | error => dispatch({type: 'fail update', error}),
79 | )
80 | }
81 |
82 | function UserSettings() {
83 | const [{user, status, error}, userDispatch] = useUser()
84 |
85 | const isPending = status === 'pending'
86 | const isRejected = status === 'rejected'
87 |
88 | const [formState, setFormState] = React.useState(user)
89 |
90 | const isChanged = !dequal(user, formState)
91 |
92 | function handleChange(e) {
93 | setFormState({...formState, [e.target.name]: e.target.value})
94 | }
95 |
96 | function handleSubmit(event) {
97 | event.preventDefault()
98 | updateUser(userDispatch, user, formState);
99 | }
100 |
101 | return (
102 |
166 | )
167 | }
168 |
169 | function UserDataDisplay() {
170 | const [{user}] = useUser()
171 | return {JSON.stringify(user, null, 2)}
172 | }
173 |
174 | function App() {
175 | return (
176 |
185 |
186 |
187 |
188 |
189 |
190 | )
191 | }
192 |
193 | export default App
194 |
--------------------------------------------------------------------------------
/src/final/01.js:
--------------------------------------------------------------------------------
1 | // Context Module Functions
2 | // http://localhost:3000/isolated/final/01.js
3 |
4 | import * as React from 'react'
5 | import {dequal} from 'dequal'
6 |
7 | // ./context/user-context.js
8 |
9 | import * as userClient from '../user-client'
10 | import {useAuth} from '../auth-context'
11 |
12 | const UserContext = React.createContext()
13 | UserContext.displayName = 'UserContext'
14 |
15 | function userReducer(state, action) {
16 | switch (action.type) {
17 | case 'start update': {
18 | return {
19 | ...state,
20 | user: {...state.user, ...action.updates},
21 | status: 'pending',
22 | storedUser: state.user,
23 | }
24 | }
25 | case 'finish update': {
26 | return {
27 | ...state,
28 | user: action.updatedUser,
29 | status: 'resolved',
30 | storedUser: null,
31 | error: null,
32 | }
33 | }
34 | case 'fail update': {
35 | return {
36 | ...state,
37 | status: 'rejected',
38 | error: action.error,
39 | user: state.storedUser,
40 | storedUser: null,
41 | }
42 | }
43 | case 'reset': {
44 | return {
45 | ...state,
46 | status: null,
47 | error: null,
48 | }
49 | }
50 | default: {
51 | throw new Error(`Unhandled action type: ${action.type}`)
52 | }
53 | }
54 | }
55 |
56 | function UserProvider({children}) {
57 | const {user} = useAuth()
58 | const [state, dispatch] = React.useReducer(userReducer, {
59 | status: null,
60 | error: null,
61 | storedUser: user,
62 | user,
63 | })
64 | const value = [state, dispatch]
65 | return {children}
66 | }
67 |
68 | function useUser() {
69 | const context = React.useContext(UserContext)
70 | if (context === undefined) {
71 | throw new Error(`useUser must be used within a UserProvider`)
72 | }
73 | return context
74 | }
75 |
76 | // got this idea from Dan and I love it:
77 | // https://twitter.com/dan_abramov/status/1125773153584676864
78 | async function updateUser(dispatch, user, updates) {
79 | dispatch({type: 'start update', updates})
80 | try {
81 | const updatedUser = await userClient.updateUser(user, updates)
82 | dispatch({type: 'finish update', updatedUser})
83 | return updatedUser
84 | } catch (error) {
85 | dispatch({type: 'fail update', error})
86 | return Promise.reject(error)
87 | }
88 | }
89 |
90 | // export {UserProvider, useUserState, updateUser}
91 |
92 | // src/screens/user-profile.js
93 | // import {UserProvider, useUserState, updateUser} from './context/user-context'
94 | function UserSettings() {
95 | const [{user, status, error}, userDispatch] = useUser()
96 |
97 | const isPending = status === 'pending'
98 | const isRejected = status === 'rejected'
99 |
100 | const [formState, setFormState] = React.useState(user)
101 |
102 | const isChanged = !dequal(user, formState)
103 |
104 | function handleChange(e) {
105 | setFormState({...formState, [e.target.name]: e.target.value})
106 | }
107 |
108 | function handleSubmit(event) {
109 | event.preventDefault()
110 | updateUser(userDispatch, user, formState).catch(() => {
111 | /* ignore the error */
112 | })
113 | }
114 |
115 | return (
116 |
117 |
118 |
119 | Username
120 |
121 |
129 |
130 |
131 |
132 | Tagline
133 |
134 |
141 |
142 |
143 |
144 | Biography
145 |
146 |
153 |
154 |
155 |
{
158 | setFormState(user)
159 | userDispatch({type: 'reset'})
160 | }}
161 | disabled={!isChanged || isPending}
162 | >
163 | Reset
164 |
165 |
169 | {isPending
170 | ? '...'
171 | : isRejected
172 | ? '✖ Try again'
173 | : isChanged
174 | ? 'Submit'
175 | : '✔'}
176 |
177 | {isRejected ?
{error.message} : null}
178 |
179 |
180 | )
181 | }
182 |
183 | function UserDataDisplay() {
184 | const [{user}] = useUser()
185 | return {JSON.stringify(user, null, 2)}
186 | }
187 |
188 | function App() {
189 | return (
190 |
199 |
200 |
201 |
202 |
203 |
204 | )
205 | }
206 |
207 | export default App
208 |
--------------------------------------------------------------------------------
/src/final/06.extra-3.js:
--------------------------------------------------------------------------------
1 | // Control Props
2 | // 💯 extract warnings to a custom hook
3 | // http://localhost:3000/isolated/final/06.extra-3.js
4 |
5 | import * as React from 'react'
6 | import warning from 'warning'
7 | import {Switch} from '../switch'
8 |
9 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
10 |
11 | const actionTypes = {
12 | toggle: 'toggle',
13 | reset: 'reset',
14 | }
15 |
16 | function toggleReducer(state, {type, initialState}) {
17 | switch (type) {
18 | case actionTypes.toggle: {
19 | return {on: !state.on}
20 | }
21 | case actionTypes.reset: {
22 | return initialState
23 | }
24 | default: {
25 | throw new Error(`Unsupported type: ${type}`)
26 | }
27 | }
28 | }
29 |
30 | function useControlledSwitchWarning(
31 | controlPropValue,
32 | controlPropName,
33 | componentName,
34 | ) {
35 | const isControlled = controlPropValue != null
36 | const {current: wasControlled} = React.useRef(isControlled)
37 |
38 | React.useEffect(() => {
39 | warning(
40 | !(isControlled && !wasControlled),
41 | `\`${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.`,
42 | )
43 | warning(
44 | !(!isControlled && wasControlled),
45 | `\`${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.`,
46 | )
47 | }, [componentName, controlPropName, isControlled, wasControlled])
48 | }
49 |
50 | function useOnChangeReadOnlyWarning(
51 | controlPropValue,
52 | controlPropName,
53 | componentName,
54 | hasOnChange,
55 | readOnly,
56 | readOnlyProp,
57 | initialValueProp,
58 | onChangeProp,
59 | ) {
60 | const isControlled = controlPropValue != null
61 | React.useEffect(() => {
62 | warning(
63 | !(!hasOnChange && isControlled && !readOnly),
64 | `A \`${controlPropName}\` prop was provided to \`${componentName}\` without an \`${onChangeProp}\` handler. This will result in a read-only \`${controlPropName}\` value. If you want it to be mutable, use \`${initialValueProp}\`. Otherwise, set either \`${onChangeProp}\` or \`${readOnlyProp}\`.`,
65 | )
66 | }, [
67 | componentName,
68 | controlPropName,
69 | isControlled,
70 | hasOnChange,
71 | readOnly,
72 | onChangeProp,
73 | initialValueProp,
74 | readOnlyProp,
75 | ])
76 | }
77 |
78 | function useToggle({
79 | initialOn = false,
80 | reducer = toggleReducer,
81 | onChange,
82 | on: controlledOn,
83 | readOnly = false,
84 | } = {}) {
85 | const {current: initialState} = React.useRef({on: initialOn})
86 | const [state, dispatch] = React.useReducer(reducer, initialState)
87 |
88 | const onIsControlled = controlledOn != null
89 | const on = onIsControlled ? controlledOn : state.on
90 |
91 | useControlledSwitchWarning(controlledOn, 'on', 'useToggle')
92 | useOnChangeReadOnlyWarning(
93 | controlledOn,
94 | 'on',
95 | 'useToggle',
96 | Boolean(onChange),
97 | readOnly,
98 | 'readOnly',
99 | 'initialOn',
100 | 'onChange',
101 | )
102 |
103 | function dispatchWithOnChange(action) {
104 | if (!onIsControlled) {
105 | dispatch(action)
106 | }
107 | onChange?.(reducer({...state, on}, action), action)
108 | }
109 |
110 | const toggle = () => dispatchWithOnChange({type: actionTypes.toggle})
111 | const reset = () =>
112 | dispatchWithOnChange({type: actionTypes.reset, initialState})
113 |
114 | function getTogglerProps({onClick, ...props} = {}) {
115 | return {
116 | 'aria-pressed': on,
117 | onClick: callAll(onClick, toggle),
118 | ...props,
119 | }
120 | }
121 |
122 | function getResetterProps({onClick, ...props} = {}) {
123 | return {
124 | onClick: callAll(onClick, reset),
125 | ...props,
126 | }
127 | }
128 |
129 | return {
130 | on,
131 | reset,
132 | toggle,
133 | getTogglerProps,
134 | getResetterProps,
135 | }
136 | }
137 |
138 | function Toggle({on: controlledOn, onChange, readOnly}) {
139 | const {on, getTogglerProps} = useToggle({
140 | on: controlledOn,
141 | onChange,
142 | readOnly,
143 | })
144 | const props = getTogglerProps({on})
145 | return
146 | }
147 |
148 | function App() {
149 | const [bothOn, setBothOn] = React.useState(false)
150 | const [timesClicked, setTimesClicked] = React.useState(0)
151 |
152 | function handleToggleChange(state, action) {
153 | if (action.type === actionTypes.toggle && timesClicked > 4) {
154 | return
155 | }
156 | setBothOn(state.on)
157 | setTimesClicked(c => c + 1)
158 | }
159 |
160 | function handleResetClick() {
161 | setBothOn(false)
162 | setTimesClicked(0)
163 | }
164 |
165 | return (
166 |
167 |
168 |
169 |
170 |
171 | {timesClicked > 4 ? (
172 |
173 | Whoa, you clicked too much!
174 |
175 |
176 | ) : (
177 |
Click count: {timesClicked}
178 | )}
179 |
Reset
180 |
181 |
182 |
Uncontrolled Toggle:
183 |
185 | console.info('Uncontrolled Toggle onChange', ...args)
186 | }
187 | />
188 |
189 |
190 | )
191 | }
192 |
193 | export default App
194 | // we're adding the Toggle export for tests
195 | export {Toggle}
196 |
197 | /*
198 | eslint
199 | no-unused-expressions: "off",
200 | */
201 |
--------------------------------------------------------------------------------
/src/final/06.extra-4.js:
--------------------------------------------------------------------------------
1 | // Control Props
2 | // 💯 don't warn in production
3 | // http://localhost:3000/isolated/final/06.extra-4.js
4 |
5 | import * as React from 'react'
6 | import warning from 'warning'
7 | import {Switch} from '../switch'
8 |
9 | const callAll = (...fns) => (...args) => fns.forEach(fn => fn?.(...args))
10 |
11 | const actionTypes = {
12 | toggle: 'toggle',
13 | reset: 'reset',
14 | }
15 |
16 | function toggleReducer(state, {type, initialState}) {
17 | switch (type) {
18 | case actionTypes.toggle: {
19 | return {on: !state.on}
20 | }
21 | case actionTypes.reset: {
22 | return initialState
23 | }
24 | default: {
25 | throw new Error(`Unsupported type: ${type}`)
26 | }
27 | }
28 | }
29 |
30 | function useControlledSwitchWarning(
31 | controlPropValue,
32 | controlPropName,
33 | componentName,
34 | ) {
35 | const isControlled = controlPropValue != null
36 | const {current: wasControlled} = React.useRef(isControlled)
37 |
38 | React.useEffect(() => {
39 | warning(
40 | !(isControlled && !wasControlled),
41 | `\`${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.`,
42 | )
43 | warning(
44 | !(!isControlled && wasControlled),
45 | `\`${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.`,
46 | )
47 | }, [componentName, controlPropName, isControlled, wasControlled])
48 | }
49 |
50 | function useOnChangeReadOnlyWarning(
51 | controlPropValue,
52 | controlPropName,
53 | componentName,
54 | hasOnChange,
55 | readOnly,
56 | readOnlyProp,
57 | initialValueProp,
58 | onChangeProp,
59 | ) {
60 | const isControlled = controlPropValue != null
61 | React.useEffect(() => {
62 | warning(
63 | !(!hasOnChange && isControlled && !readOnly),
64 | `A \`${controlPropName}\` prop was provided to \`${componentName}\` without an \`${onChangeProp}\` handler. This will result in a read-only \`${controlPropName}\` value. If you want it to be mutable, use \`${initialValueProp}\`. Otherwise, set either \`${onChangeProp}\` or \`${readOnlyProp}\`.`,
65 | )
66 | }, [
67 | componentName,
68 | controlPropName,
69 | isControlled,
70 | hasOnChange,
71 | readOnly,
72 | onChangeProp,
73 | initialValueProp,
74 | readOnlyProp,
75 | ])
76 | }
77 |
78 | function useToggle({
79 | initialOn = false,
80 | reducer = toggleReducer,
81 | onChange,
82 | on: controlledOn,
83 | readOnly = false,
84 | } = {}) {
85 | const {current: initialState} = React.useRef({on: initialOn})
86 | const [state, dispatch] = React.useReducer(reducer, initialState)
87 |
88 | if (process.env.NODE_ENV !== 'production') {
89 | // eslint-disable-next-line react-hooks/rules-of-hooks
90 | useControlledSwitchWarning(controlledOn, 'on', 'useToggle')
91 | // eslint-disable-next-line react-hooks/rules-of-hooks
92 | useOnChangeReadOnlyWarning(
93 | controlledOn,
94 | 'on',
95 | 'useToggle',
96 | Boolean(onChange),
97 | readOnly,
98 | 'readOnly',
99 | 'initialOn',
100 | 'onChange',
101 | )
102 | }
103 |
104 | const onIsControlled = controlledOn != null
105 | const on = onIsControlled ? controlledOn : state.on
106 |
107 | function dispatchWithOnChange(action) {
108 | if (!onIsControlled) {
109 | dispatch(action)
110 | }
111 | onChange?.(reducer({...state, on}, action), action)
112 | }
113 |
114 | const toggle = () => dispatchWithOnChange({type: actionTypes.toggle})
115 | const reset = () =>
116 | dispatchWithOnChange({type: actionTypes.reset, initialState})
117 |
118 | function getTogglerProps({onClick, ...props} = {}) {
119 | return {
120 | 'aria-pressed': on,
121 | onClick: callAll(onClick, toggle),
122 | ...props,
123 | }
124 | }
125 |
126 | function getResetterProps({onClick, ...props} = {}) {
127 | return {
128 | onClick: callAll(onClick, reset),
129 | ...props,
130 | }
131 | }
132 |
133 | return {
134 | on,
135 | reset,
136 | toggle,
137 | getTogglerProps,
138 | getResetterProps,
139 | }
140 | }
141 |
142 | function Toggle({on: controlledOn, onChange, readOnly}) {
143 | const {on, getTogglerProps} = useToggle({
144 | on: controlledOn,
145 | onChange,
146 | readOnly,
147 | })
148 | const props = getTogglerProps({on})
149 | return
150 | }
151 |
152 | function App() {
153 | const [bothOn, setBothOn] = React.useState(false)
154 | const [timesClicked, setTimesClicked] = React.useState(0)
155 |
156 | function handleToggleChange(state, action) {
157 | if (action.type === actionTypes.toggle && timesClicked > 4) {
158 | return
159 | }
160 | setBothOn(state.on)
161 | setTimesClicked(c => c + 1)
162 | }
163 |
164 | function handleResetClick() {
165 | setBothOn(false)
166 | setTimesClicked(0)
167 | }
168 |
169 | return (
170 |
171 |
172 |
173 |
174 |
175 | {timesClicked > 4 ? (
176 |
177 | Whoa, you clicked too much!
178 |
179 |
180 | ) : (
181 |
Click count: {timesClicked}
182 | )}
183 |
Reset
184 |
185 |
186 |
Uncontrolled Toggle:
187 |
189 | console.info('Uncontrolled Toggle onChange', ...args)
190 | }
191 | />
192 |
193 |
194 | )
195 | }
196 |
197 | export default App
198 | // we're adding the Toggle export for tests
199 | export {Toggle}
200 |
201 | /*
202 | eslint
203 | no-unused-expressions: "off",
204 | */
205 |
--------------------------------------------------------------------------------
/src/exercise/06.md:
--------------------------------------------------------------------------------
1 | # Control Props
2 |
3 | ## 📝 Your Notes
4 |
5 | Elaborate on your learnings here in `src/exercise/06.md`
6 |
7 | ## Background
8 |
9 | **One liner:** The Control Props pattern allows users to completely control
10 | state values within your component. This differs from the state reducer pattern
11 | in the fact that you can not only change the state changes based on actions
12 | dispatched but you _also_ can trigger state changes from outside the component
13 | or hook as well.
14 |
15 | Sometimes, people want to be able to manage the internal state of our component
16 | from the outside. The state reducer allows them to manage what state changes are
17 | made when a state change happens, but sometimes people may want to make state
18 | changes themselves. We can allow them to do this with a feature called "Control
19 | Props."
20 |
21 | This concept is basically the same as controlled form elements in React that
22 | you've probably used many times: 📜
23 | https://reactjs.org/docs/forms.html#controlled-components
24 |
25 | ```javascript
26 | function MyCapitalizedInput() {
27 | const [capitalizedValue, setCapitalizedValue] = React.useState('')
28 |
29 | return (
30 | setCapitalizedValue(e.target.value.toUpperCase())}
33 | />
34 | )
35 | }
36 | ```
37 |
38 | In this case, the "component" that's implemented the "control props" pattern is
39 | the ` `. Normally it controls state itself (like if you render
40 | ` ` by itself with no `value` prop). But once you add the `value` prop,
41 | suddenly the ` ` takes the back seat and instead makes "suggestions" to
42 | you via the `onChange` prop on the state updates that it would normally make
43 | itself.
44 |
45 | This flexibility allows us to change how the state is managed (by capitalizing
46 | the value), and it also allows us to programmatically change the state whenever
47 | we want to, which enables this kind of synchronized input situation:
48 |
49 | ```javascript
50 | function MyTwoInputs() {
51 | const [capitalizedValue, setCapitalizedValue] = React.useState('')
52 | const [lowerCasedValue, setLowerCasedValue] = React.useState('')
53 |
54 | function handleInputChange(e) {
55 | setCapitalizedValue(e.target.value.toUpperCase())
56 | setLowerCasedValue(e.target.value.toLowerCase())
57 | }
58 |
59 | return (
60 | <>
61 |
62 |
63 | >
64 | )
65 | }
66 | ```
67 |
68 | **Real World Projects that use this pattern:**
69 |
70 | - [downshift](https://github.com/downshift-js/downshift)
71 | - [`@reach/listbox`](https://reacttraining.com/reach-ui/listbox)
72 |
73 | ## Exercise
74 |
75 | Production deploys:
76 |
77 | - [Exercise](http://advanced-react-patterns.netlify.app/isolated/exercise/06.js)
78 | - [Final](http://advanced-react-patterns.netlify.app/isolated/final/06.js)
79 |
80 | In this exercise, we've created a ` ` component which can accept a prop
81 | called `on` and another called `onChange`. These work similar to the `value` and
82 | `onChange` props of ` `. Your job is to make those props actually
83 | control the state of `on` and call the `onChange` with the suggested changes.
84 |
85 | ## Extra Credit
86 |
87 | ### 1. 💯 add read only warning
88 |
89 | [Production deploy](http://advanced-react-patterns.netlify.app/isolated/final/06.extra-1.js)
90 |
91 | Take a look at the example in `./src/examples/warnings.js` (you can pull it up
92 | at
93 | [/isolated/examples/warnings.js](http://localhost:3000/isolated/examples/warnings.js)).
94 |
95 | Notice the warnings when you click the buttons. You should see the following
96 | warnings all related to controlled inputs:
97 |
98 | ```
99 | Warning: Failed prop type: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.
100 | ```
101 |
102 | ```
103 | Warning: A component is changing an uncontrolled input of type undefined to be controlled. Input elements should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://fb.me/react-controlled-components
104 | ```
105 |
106 | ```
107 | Warning: A component is changing a controlled input of type undefined to be uncontrolled. Input elements should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://fb.me/react-controlled-components
108 | ```
109 |
110 | We should issue the same warnings for people who misuse our controlled props:
111 |
112 | 1. Passing `on` without `onChange`
113 | 2. Passing a value for `on` and later passing `undefined` or `null`
114 | 3. Passing `undefined` or `null` for `on` and later passing a value
115 |
116 | For this first extra credit, create a warning for the read-only situation (the
117 | other extra credits will handle the other cases).
118 |
119 | 💰 You can use the `warning` package to do this:
120 |
121 | ```javascript
122 | warning(doNotWarn, 'Warning message')
123 |
124 | // so:
125 | warning(false, 'This will warn')
126 | warning(true, 'This will not warn')
127 | ```
128 |
129 | A real-world component that does this is
130 | [`@reach/listbox`](https://reacttraining.com/reach-ui/listbox/)
131 |
132 | ### 2. 💯 add a controlled state warning
133 |
134 | [Production deploy](http://advanced-react-patterns.netlify.app/isolated/final/06.extra-2.js)
135 |
136 | With that read-only warning in place, next try and add a warning for when the
137 | user changes from controlled to uncontrolled or vice-versa.
138 |
139 | ### 3. 💯 extract warnings to a custom hook
140 |
141 | [Production deploy](http://advanced-react-patterns.netlify.app/isolated/final/06.extra-3.js)
142 |
143 | Both of those warnings could be useful anywhere so let's go ahead and make a
144 | custom hook for them.
145 |
146 | Shout out to the Reach UI team for
147 | [the implementation of the `useControlledSwitchWarning`](https://github.com/reach/reach-ui/blob/a376daec462ccb53d33f4471306dff35383a03a5/packages/utils/src/index.tsx#L407-L443)
148 |
149 | ### 4. 💯 don't warn in production
150 |
151 | [Production deploy](http://advanced-react-patterns.netlify.app/isolated/final/06.extra-4.js)
152 |
153 | Runtime warnings are helpful during development, but probably not useful in
154 | production. See if you can make this not warn in production.
155 |
156 | > You can tell whether we're running in production with
157 | > `process.env.NODE_ENV === 'production'`
158 |
159 | ## 🦉 Feedback
160 |
161 | Fill out
162 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Patterns%20%F0%9F%A4%AF&e=06%3A%20Control%20Props&em=).
163 |
--------------------------------------------------------------------------------
/src/exercise/01.md:
--------------------------------------------------------------------------------
1 | # Context Module Functions
2 |
3 | ## 📝 Your Notes
4 |
5 | Elaborate on your learnings here in `src/exercise/01.md`
6 |
7 | ## Background
8 |
9 | **One liner:** The Context Module Functions Pattern allows you to encapsulate a
10 | complex set of state changes into a utility function which can be tree-shaken
11 | and lazily loaded.
12 |
13 | Let's take a look at an example of a simple context and a reducer combo:
14 |
15 | ```javascript
16 | // src/context/counter.js
17 | const CounterContext = React.createContext()
18 |
19 | function CounterProvider({step = 1, initialCount = 0, ...props}) {
20 | const [state, dispatch] = React.useReducer(
21 | (state, action) => {
22 | const change = action.step ?? step
23 | switch (action.type) {
24 | case 'increment': {
25 | return {...state, count: state.count + change}
26 | }
27 | case 'decrement': {
28 | return {...state, count: state.count - change}
29 | }
30 | default: {
31 | throw new Error(`Unhandled action type: ${action.type}`)
32 | }
33 | }
34 | },
35 | {count: initialCount},
36 | )
37 |
38 | const value = [state, dispatch]
39 | return
40 | }
41 |
42 | function useCounter() {
43 | const context = React.useContext(CounterContext)
44 | if (context === undefined) {
45 | throw new Error(`useCounter must be used within a CounterProvider`)
46 | }
47 | return context
48 | }
49 |
50 | export {CounterProvider, useCounter}
51 | ```
52 |
53 | ```javascript
54 | // src/screens/counter.js
55 | import {useCounter} from 'context/counter'
56 |
57 | function Counter() {
58 | const [state, dispatch] = useCounter()
59 | const increment = () => dispatch({type: 'increment'})
60 | const decrement = () => dispatch({type: 'decrement'})
61 | return (
62 |
63 |
Current Count: {state.count}
64 |
-
65 |
+
66 |
67 | )
68 | }
69 | ```
70 |
71 | ```javascript
72 | // src/index.js
73 | import {CounterProvider} from 'context/counter'
74 |
75 | function App() {
76 | return (
77 |
78 |
79 |
80 | )
81 | }
82 | ```
83 |
84 | > You can pull this example up here:
85 | > http://localhost:3000/isolated/examples/counter-before.js
86 |
87 | I want to focus in on the user of our reducer (the `Counter` component). Notice
88 | that they have to create their own `increment` and `decrement` functions which
89 | call `dispatch`. I don't think that's a super great API. It becomes even more of
90 | an annoyance when you have a sequence of `dispatch` functions that need to be
91 | called (like you'll see in our exercise).
92 |
93 | The first inclination is to create "helper" functions and include them in the
94 | context. Let's do that. You'll notice that we have to put it in
95 | `React.useCallback` so we can list our "helper" functions in dependency lists):
96 |
97 | ```javascript
98 | const increment = React.useCallback(
99 | () => dispatch({type: 'increment'}),
100 | [dispatch],
101 | )
102 | const decrement = React.useCallback(
103 | () => dispatch({type: 'decrement'}),
104 | [dispatch],
105 | )
106 | const value = {state, increment, decrement}
107 | return
108 |
109 | // now users can consume it like this:
110 |
111 | const {state, increment, decrement} = useCounter()
112 | ```
113 |
114 | This isn't a _bad_ solution necessarily. But
115 | [as my friend Dan says](https://twitter.com/dan_abramov/status/1125758606765383680):
116 |
117 | > Helper methods are object junk that we need to recreate and compare for no
118 | > purpose other than superficially nicer looking syntax.
119 |
120 | What Dan recommends (and what Facebook does) is pass dispatch as we had
121 | originally. And to solve the annoyance we were trying to solve in the first
122 | place, they use importable "helpers" that accept `dispatch`. Let's take a look
123 | at how that would look:
124 |
125 | ```javascript
126 | // src/context/counter.js
127 | const CounterContext = React.createContext()
128 |
129 | function CounterProvider({step = 1, initialCount = 0, ...props}) {
130 | const [state, dispatch] = React.useReducer(
131 | (state, action) => {
132 | const change = action.step ?? step
133 | switch (action.type) {
134 | case 'increment': {
135 | return {...state, count: state.count + change}
136 | }
137 | case 'decrement': {
138 | return {...state, count: state.count - change}
139 | }
140 | default: {
141 | throw new Error(`Unhandled action type: ${action.type}`)
142 | }
143 | }
144 | },
145 | {count: initialCount},
146 | )
147 |
148 | const value = [state, dispatch]
149 |
150 | return
151 | }
152 |
153 | function useCounter() {
154 | const context = React.useContext(CounterContext)
155 | if (context === undefined) {
156 | throw new Error(`useCounter must be used within a CounterProvider`)
157 | }
158 | return context
159 | }
160 |
161 | const increment = dispatch => dispatch({type: 'increment'})
162 | const decrement = dispatch => dispatch({type: 'decrement'})
163 |
164 | export {CounterProvider, useCounter, increment, decrement}
165 | ```
166 |
167 | ```javascript
168 | // src/screens/counter.js
169 | import {useCounter, increment, decrement} from 'context/counter'
170 |
171 | function Counter() {
172 | const [state, dispatch] = useCounter()
173 | return (
174 |
175 |
Current Count: {state.count}
176 |
decrement(dispatch)}>-
177 |
increment(dispatch)}>+
178 |
179 | )
180 | }
181 | ```
182 |
183 | **This may look like overkill, and it is.** However, in some situations this
184 | pattern can not only help you reduce duplication, but it also
185 | [helps improve performance](https://twitter.com/dan_abramov/status/1125774170154065920)
186 | and helps you avoid mistakes in dependency lists.
187 |
188 | I wouldn't recommend this all the time, but sometimes it can be a help!
189 |
190 | 📜 If you need to review the context API, here are the docs:
191 |
192 | - https://reactjs.org/docs/context.html
193 | - https://reactjs.org/docs/hooks-reference.html#usecontext
194 |
195 | 🦉 Tip: You may notice that the context provider/consumers in React DevTools
196 | just display as `Context.Provider` and `Context.Consumer`. That doesn't do a
197 | good job differentiating itself from other contexts that may be in your app.
198 | Luckily, you can set the context `displayName` and it'll display that name for
199 | the `Provider` and `Consumer`. Hopefully in the future this will happen
200 | automatically ([learn more](https://github.com/babel/babel/issues/11241)).
201 |
202 | ```javascript
203 | const MyContext = React.createContext()
204 | MyContext.displayName = 'MyContext'
205 | ```
206 |
207 | ## Exercise
208 |
209 | Production deploys:
210 |
211 | - [Exercise](http://advanced-react-patterns.netlify.app/isolated/exercise/01.js)
212 | - [Final](http://advanced-react-patterns.netlify.app/isolated/final/01.js)
213 |
214 | 👨💼 We have a user settings page where we render a form for the user's
215 | information. We'll be storing the user's information in context and we'll follow
216 | some patterns for exposing ways to keep that context updated as well as
217 | interacting with the backend.
218 |
219 | > 💰 In this exercise, if you enter the text "fail" in the tagline or biography
220 | > input, then the "backend" will reject the promise so you can test the error
221 | > case.
222 |
223 | Right now the `UserSettings` form is calling `userDispatch` directly. Your job
224 | is to move that to a module-level "helper" function that accepts dispatch as
225 | well as the rest of the information that's needed to execute the sequence of
226 | dispatches.
227 |
228 | > 🦉 To keep things simple we're leaving everything in one file, but normally
229 | > you'll put the context in a separate module.
230 |
231 | ## 🦉 Feedback
232 |
233 | Fill out
234 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Patterns%20%F0%9F%A4%AF&e=01%3A%20Context%20Module%20Functions&em=).
235 |
--------------------------------------------------------------------------------
/public/mockServiceWorker.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Mock Service Worker.
3 | * @see https://github.com/mswjs/msw
4 | * - Please do NOT modify this file.
5 | * - Please do NOT serve this file on production.
6 | */
7 | /* eslint-disable */
8 | /* tslint:disable */
9 |
10 | const INTEGRITY_CHECKSUM = '82ef9b96d8393b6da34527d1d6e19187'
11 | const bypassHeaderName = 'x-msw-bypass'
12 | const activeClientIds = new Set()
13 |
14 | self.addEventListener('install', function () {
15 | return self.skipWaiting()
16 | })
17 |
18 | self.addEventListener('activate', async function (event) {
19 | return self.clients.claim()
20 | })
21 |
22 | self.addEventListener('message', async function (event) {
23 | const clientId = event.source.id
24 |
25 | if (!clientId || !self.clients) {
26 | return
27 | }
28 |
29 | const client = await self.clients.get(clientId)
30 |
31 | if (!client) {
32 | return
33 | }
34 |
35 | const allClients = await self.clients.matchAll()
36 |
37 | switch (event.data) {
38 | case 'KEEPALIVE_REQUEST': {
39 | sendToClient(client, {
40 | type: 'KEEPALIVE_RESPONSE',
41 | })
42 | break
43 | }
44 |
45 | case 'INTEGRITY_CHECK_REQUEST': {
46 | sendToClient(client, {
47 | type: 'INTEGRITY_CHECK_RESPONSE',
48 | payload: INTEGRITY_CHECKSUM,
49 | })
50 | break
51 | }
52 |
53 | case 'MOCK_ACTIVATE': {
54 | activeClientIds.add(clientId)
55 |
56 | sendToClient(client, {
57 | type: 'MOCKING_ENABLED',
58 | payload: true,
59 | })
60 | break
61 | }
62 |
63 | case 'MOCK_DEACTIVATE': {
64 | activeClientIds.delete(clientId)
65 | break
66 | }
67 |
68 | case 'CLIENT_CLOSED': {
69 | activeClientIds.delete(clientId)
70 |
71 | const remainingClients = allClients.filter((client) => {
72 | return client.id !== clientId
73 | })
74 |
75 | // Unregister itself when there are no more clients
76 | if (remainingClients.length === 0) {
77 | self.registration.unregister()
78 | }
79 |
80 | break
81 | }
82 | }
83 | })
84 |
85 | // Resolve the "master" client for the given event.
86 | // Client that issues a request doesn't necessarily equal the client
87 | // that registered the worker. It's with the latter the worker should
88 | // communicate with during the response resolving phase.
89 | async function resolveMasterClient(event) {
90 | const client = await self.clients.get(event.clientId)
91 |
92 | if (client.frameType === 'top-level') {
93 | return client
94 | }
95 |
96 | const allClients = await self.clients.matchAll()
97 |
98 | return allClients
99 | .filter((client) => {
100 | // Get only those clients that are currently visible.
101 | return client.visibilityState === 'visible'
102 | })
103 | .find((client) => {
104 | // Find the client ID that's recorded in the
105 | // set of clients that have registered the worker.
106 | return activeClientIds.has(client.id)
107 | })
108 | }
109 |
110 | async function handleRequest(event, requestId) {
111 | const client = await resolveMasterClient(event)
112 | const response = await getResponse(event, client, requestId)
113 |
114 | // Send back the response clone for the "response:*" life-cycle events.
115 | // Ensure MSW is active and ready to handle the message, otherwise
116 | // this message will pend indefinitely.
117 | if (client && activeClientIds.has(client.id)) {
118 | ;(async function () {
119 | const clonedResponse = response.clone()
120 | sendToClient(client, {
121 | type: 'RESPONSE',
122 | payload: {
123 | requestId,
124 | type: clonedResponse.type,
125 | ok: clonedResponse.ok,
126 | status: clonedResponse.status,
127 | statusText: clonedResponse.statusText,
128 | body:
129 | clonedResponse.body === null ? null : await clonedResponse.text(),
130 | headers: serializeHeaders(clonedResponse.headers),
131 | redirected: clonedResponse.redirected,
132 | },
133 | })
134 | })()
135 | }
136 |
137 | return response
138 | }
139 |
140 | async function getResponse(event, client, requestId) {
141 | const { request } = event
142 | const requestClone = request.clone()
143 | const getOriginalResponse = () => fetch(requestClone)
144 |
145 | // Bypass mocking when the request client is not active.
146 | if (!client) {
147 | return getOriginalResponse()
148 | }
149 |
150 | // Bypass initial page load requests (i.e. static assets).
151 | // The absence of the immediate/parent client in the map of the active clients
152 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
153 | // and is not ready to handle requests.
154 | if (!activeClientIds.has(client.id)) {
155 | return await getOriginalResponse()
156 | }
157 |
158 | // Bypass requests with the explicit bypass header
159 | if (requestClone.headers.get(bypassHeaderName) === 'true') {
160 | const cleanRequestHeaders = serializeHeaders(requestClone.headers)
161 |
162 | // Remove the bypass header to comply with the CORS preflight check.
163 | delete cleanRequestHeaders[bypassHeaderName]
164 |
165 | const originalRequest = new Request(requestClone, {
166 | headers: new Headers(cleanRequestHeaders),
167 | })
168 |
169 | return fetch(originalRequest)
170 | }
171 |
172 | // Send the request to the client-side MSW.
173 | const reqHeaders = serializeHeaders(request.headers)
174 | const body = await request.text()
175 |
176 | const clientMessage = await sendToClient(client, {
177 | type: 'REQUEST',
178 | payload: {
179 | id: requestId,
180 | url: request.url,
181 | method: request.method,
182 | headers: reqHeaders,
183 | cache: request.cache,
184 | mode: request.mode,
185 | credentials: request.credentials,
186 | destination: request.destination,
187 | integrity: request.integrity,
188 | redirect: request.redirect,
189 | referrer: request.referrer,
190 | referrerPolicy: request.referrerPolicy,
191 | body,
192 | bodyUsed: request.bodyUsed,
193 | keepalive: request.keepalive,
194 | },
195 | })
196 |
197 | switch (clientMessage.type) {
198 | case 'MOCK_SUCCESS': {
199 | return delayPromise(
200 | () => respondWithMock(clientMessage),
201 | clientMessage.payload.delay,
202 | )
203 | }
204 |
205 | case 'MOCK_NOT_FOUND': {
206 | return getOriginalResponse()
207 | }
208 |
209 | case 'NETWORK_ERROR': {
210 | const { name, message } = clientMessage.payload
211 | const networkError = new Error(message)
212 | networkError.name = name
213 |
214 | // Rejecting a request Promise emulates a network error.
215 | throw networkError
216 | }
217 |
218 | case 'INTERNAL_ERROR': {
219 | const parsedBody = JSON.parse(clientMessage.payload.body)
220 |
221 | console.error(
222 | `\
223 | [MSW] Request handler function for "%s %s" has thrown the following exception:
224 |
225 | ${parsedBody.errorType}: ${parsedBody.message}
226 | (see more detailed error stack trace in the mocked response body)
227 |
228 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error.
229 | If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
230 | `,
231 | request.method,
232 | request.url,
233 | )
234 |
235 | return respondWithMock(clientMessage)
236 | }
237 | }
238 |
239 | return getOriginalResponse()
240 | }
241 |
242 | self.addEventListener('fetch', function (event) {
243 | const { request } = event
244 |
245 | // Bypass navigation requests.
246 | if (request.mode === 'navigate') {
247 | return
248 | }
249 |
250 | // Opening the DevTools triggers the "only-if-cached" request
251 | // that cannot be handled by the worker. Bypass such requests.
252 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
253 | return
254 | }
255 |
256 | // Bypass all requests when there are no active clients.
257 | // Prevents the self-unregistered worked from handling requests
258 | // after it's been deleted (still remains active until the next reload).
259 | if (activeClientIds.size === 0) {
260 | return
261 | }
262 |
263 | const requestId = uuidv4()
264 |
265 | return event.respondWith(
266 | handleRequest(event, requestId).catch((error) => {
267 | console.error(
268 | '[MSW] Failed to mock a "%s" request to "%s": %s',
269 | request.method,
270 | request.url,
271 | error,
272 | )
273 | }),
274 | )
275 | })
276 |
277 | function serializeHeaders(headers) {
278 | const reqHeaders = {}
279 | headers.forEach((value, name) => {
280 | reqHeaders[name] = reqHeaders[name]
281 | ? [].concat(reqHeaders[name]).concat(value)
282 | : value
283 | })
284 | return reqHeaders
285 | }
286 |
287 | function sendToClient(client, message) {
288 | return new Promise((resolve, reject) => {
289 | const channel = new MessageChannel()
290 |
291 | channel.port1.onmessage = (event) => {
292 | if (event.data && event.data.error) {
293 | return reject(event.data.error)
294 | }
295 |
296 | resolve(event.data)
297 | }
298 |
299 | client.postMessage(JSON.stringify(message), [channel.port2])
300 | })
301 | }
302 |
303 | function delayPromise(cb, duration) {
304 | return new Promise((resolve) => {
305 | setTimeout(() => resolve(cb()), duration)
306 | })
307 | }
308 |
309 | function respondWithMock(clientMessage) {
310 | return new Response(clientMessage.payload.body, {
311 | ...clientMessage.payload,
312 | headers: clientMessage.payload.headers,
313 | })
314 | }
315 |
316 | function uuidv4() {
317 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
318 | const r = (Math.random() * 16) | 0
319 | const v = c == 'x' ? r : (r & 0x3) | 0x8
320 | return v.toString(16)
321 | })
322 | }
323 |
--------------------------------------------------------------------------------
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "projectName": "advanced-react-patterns",
3 | "projectOwner": "kentcdodds",
4 | "repoType": "github",
5 | "files": [
6 | "README.md"
7 | ],
8 | "imageSize": 100,
9 | "commit": false,
10 | "contributors": [
11 | {
12 | "login": "kentcdodds",
13 | "name": "Kent C. Dodds",
14 | "avatar_url": "https://avatars.githubusercontent.com/u/1500684?v=3",
15 | "profile": "https://kentcdodds.com",
16 | "contributions": [
17 | "code",
18 | "doc",
19 | "infra",
20 | "test"
21 | ]
22 | },
23 | {
24 | "login": "FWeinb",
25 | "name": "FWeinb",
26 | "avatar_url": "https://avatars0.githubusercontent.com/u/1250430?v=4",
27 | "profile": "https://github.com/FWeinb",
28 | "contributions": [
29 | "bug",
30 | "ideas"
31 | ]
32 | },
33 | {
34 | "login": "dlannoye",
35 | "name": "David Lannoye",
36 | "avatar_url": "https://avatars2.githubusercontent.com/u/1383720?v=4",
37 | "profile": "https://github.com/dlannoye",
38 | "contributions": [
39 | "bug",
40 | "doc"
41 | ]
42 | },
43 | {
44 | "login": "colinrcummings",
45 | "name": "Colin Cummings",
46 | "avatar_url": "https://avatars2.githubusercontent.com/u/9815009?s=460&v=4",
47 | "profile": "https://github.com/colinrcummings",
48 | "contributions": [
49 | "code",
50 | "test"
51 | ]
52 | },
53 | {
54 | "login": "bkoltai",
55 | "name": "Benji Koltai",
56 | "avatar_url": "https://avatars2.githubusercontent.com/u/464764?v=4",
57 | "profile": "https://github.com/bkoltai",
58 | "contributions": [
59 | "doc"
60 | ]
61 | },
62 | {
63 | "login": "baggasumit",
64 | "name": "Sumit Bagga",
65 | "avatar_url": "https://avatars1.githubusercontent.com/u/1779959?v=4",
66 | "profile": "http://baggasumit.github.io",
67 | "contributions": [
68 | "doc"
69 | ]
70 | },
71 | {
72 | "login": "Tarabyte",
73 | "name": "Yury Tarabanko",
74 | "avatar_url": "https://avatars0.githubusercontent.com/u/2027010?v=4",
75 | "profile": "https://github.com/Tarabyte",
76 | "contributions": [
77 | "code"
78 | ]
79 | },
80 | {
81 | "login": "themostcolm",
82 | "name": "Alex Wendte",
83 | "avatar_url": "https://avatars2.githubusercontent.com/u/5779538?v=4",
84 | "profile": "http://www.wendtedesigns.com/",
85 | "contributions": [
86 | "code"
87 | ]
88 | },
89 | {
90 | "login": "CompuIves",
91 | "name": "Ives van Hoorne",
92 | "avatar_url": "https://avatars3.githubusercontent.com/u/587016?v=4",
93 | "profile": "https://twitter.com/CompuIves",
94 | "contributions": [
95 | "code",
96 | "test"
97 | ]
98 | },
99 | {
100 | "login": "lgandecki",
101 | "name": "Łukasz Gandecki",
102 | "avatar_url": "https://avatars1.githubusercontent.com/u/4002543?v=4",
103 | "profile": "http://team.thebrain.pro",
104 | "contributions": [
105 | "doc"
106 | ]
107 | },
108 | {
109 | "login": "deniztetik",
110 | "name": "Deniz Tetik",
111 | "avatar_url": "https://avatars0.githubusercontent.com/u/14167019?v=4",
112 | "profile": "https://github.com/deniztetik",
113 | "contributions": [
114 | "content"
115 | ]
116 | },
117 | {
118 | "login": "Ruffeng",
119 | "name": "Ruffeng",
120 | "avatar_url": "https://avatars1.githubusercontent.com/u/18511772?v=4",
121 | "profile": "https://github.com/Ruffeng",
122 | "contributions": [
123 | "content",
124 | "code"
125 | ]
126 | },
127 | {
128 | "login": "jdorfman",
129 | "name": "Justin Dorfman",
130 | "avatar_url": "https://avatars1.githubusercontent.com/u/398230?v=4",
131 | "profile": "https://stackshare.io/jdorfman/decisions",
132 | "contributions": [
133 | "fundingFinding"
134 | ]
135 | },
136 | {
137 | "login": "AlexMunoz",
138 | "name": "Alex Munoz",
139 | "avatar_url": "https://avatars3.githubusercontent.com/u/3093946?v=4",
140 | "profile": "http://alexmunoz.github.io",
141 | "contributions": [
142 | "doc"
143 | ]
144 | },
145 | {
146 | "login": "marcosvega91",
147 | "name": "Marco Moretti",
148 | "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4",
149 | "profile": "https://github.com/marcosvega91",
150 | "contributions": [
151 | "code"
152 | ]
153 | },
154 | {
155 | "login": "emipc",
156 | "name": "Emili",
157 | "avatar_url": "https://avatars1.githubusercontent.com/u/26004903?v=4",
158 | "profile": "https://github.com/emipc",
159 | "contributions": [
160 | "doc"
161 | ]
162 | },
163 | {
164 | "login": "balavishnuvj",
165 | "name": "balavishnuvj",
166 | "avatar_url": "https://avatars3.githubusercontent.com/u/13718688?v=4",
167 | "profile": "https://github.com/balavishnuvj",
168 | "contributions": [
169 | "code"
170 | ]
171 | },
172 | {
173 | "login": "PritamSangani",
174 | "name": "Pritam Sangani",
175 | "avatar_url": "https://avatars3.githubusercontent.com/u/22857896?v=4",
176 | "profile": "https://www.linkedin.com/in/pritamsangani/",
177 | "contributions": [
178 | "code"
179 | ]
180 | },
181 | {
182 | "login": "kocvrek",
183 | "name": "Kasia Kosturek",
184 | "avatar_url": "https://avatars3.githubusercontent.com/u/36547835?v=4",
185 | "profile": "http://linkedin.com/in/katarzynakosturek/",
186 | "contributions": [
187 | "doc"
188 | ]
189 | },
190 | {
191 | "login": "emzoumpo",
192 | "name": "Emmanouil Zoumpoulakis",
193 | "avatar_url": "https://avatars2.githubusercontent.com/u/2103443?v=4",
194 | "profile": "https://github.com/emzoumpo",
195 | "contributions": [
196 | "doc"
197 | ]
198 | },
199 | {
200 | "login": "Aprillion",
201 | "name": "Peter Hozák",
202 | "avatar_url": "https://avatars0.githubusercontent.com/u/1087670?v=4",
203 | "profile": "http://peter.hozak.info/",
204 | "contributions": [
205 | "code"
206 | ]
207 | },
208 | {
209 | "login": "nawok",
210 | "name": "Pavel Fomchenkov",
211 | "avatar_url": "https://avatars3.githubusercontent.com/u/159773?v=4",
212 | "profile": "https://github.com/nawok",
213 | "contributions": [
214 | "doc"
215 | ]
216 | },
217 | {
218 | "login": "seemaullal",
219 | "name": "Seema Ullal",
220 | "avatar_url": "https://avatars0.githubusercontent.com/u/8728285?v=4",
221 | "profile": "http://www.seemaullal.com",
222 | "contributions": [
223 | "doc"
224 | ]
225 | },
226 | {
227 | "login": "patrickclery",
228 | "name": "Patrick Clery",
229 | "avatar_url": "https://avatars0.githubusercontent.com/u/25733135?v=4",
230 | "profile": "https://git.io/JfYj5",
231 | "contributions": [
232 | "doc"
233 | ]
234 | },
235 | {
236 | "login": "degeens",
237 | "name": "Stijn Geens",
238 | "avatar_url": "https://avatars2.githubusercontent.com/u/33414262?v=4",
239 | "profile": "https://github.com/degeens",
240 | "contributions": [
241 | "doc"
242 | ]
243 | },
244 | {
245 | "login": "MichaelDeBoey",
246 | "name": "Michaël De Boey",
247 | "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4",
248 | "profile": "https://michaeldeboey.be",
249 | "contributions": [
250 | "code"
251 | ]
252 | },
253 | {
254 | "login": "DaleSeo",
255 | "name": "Dale Seo",
256 | "avatar_url": "https://avatars1.githubusercontent.com/u/5466341?v=4",
257 | "profile": "https://www.daleseo.com",
258 | "contributions": [
259 | "doc"
260 | ]
261 | },
262 | {
263 | "login": "bobbywarner",
264 | "name": "Bobby Warner",
265 | "avatar_url": "https://avatars0.githubusercontent.com/u/554961?v=4",
266 | "profile": "http://bobbywarner.com",
267 | "contributions": [
268 | "code"
269 | ]
270 | },
271 | {
272 | "login": "sophiabrandt",
273 | "name": "Sophia Brandt",
274 | "avatar_url": "https://avatars0.githubusercontent.com/u/16630701?v=4",
275 | "profile": "https://www.sophiabrandt.com",
276 | "contributions": [
277 | "doc"
278 | ]
279 | },
280 | {
281 | "login": "ph08n1x",
282 | "name": "ph08n1x",
283 | "avatar_url": "https://avatars.githubusercontent.com/u/4249732?v=4",
284 | "profile": "https://github.com/ph08n1x",
285 | "contributions": [
286 | "doc"
287 | ]
288 | },
289 | {
290 | "login": "Suhas010",
291 | "name": "Suhas R More",
292 | "avatar_url": "https://avatars.githubusercontent.com/u/8597576?v=4",
293 | "profile": "https://www.suhas010.com",
294 | "contributions": [
295 | "code"
296 | ]
297 | },
298 | {
299 | "login": "0xnoob",
300 | "name": "0xnoob",
301 | "avatar_url": "https://avatars.githubusercontent.com/u/49793844?v=4",
302 | "profile": "https://github.com/0xnoob",
303 | "contributions": [
304 | "code"
305 | ]
306 | },
307 | {
308 | "login": "pvinis",
309 | "name": "Pavlos Vinieratos",
310 | "avatar_url": "https://avatars.githubusercontent.com/u/100233?v=4",
311 | "profile": "http://pavlos.dev",
312 | "contributions": [
313 | "doc"
314 | ]
315 | },
316 | {
317 | "login": "infoxicator",
318 | "name": "Ruben Casas",
319 | "avatar_url": "https://avatars.githubusercontent.com/u/17012976?v=4",
320 | "profile": "https://github.com/infoxicator",
321 | "contributions": [
322 | "code",
323 | "doc"
324 | ]
325 | }
326 | ],
327 | "repoHost": "https://github.com",
328 | "contributorsPerLine": 7,
329 | "skipCi": true
330 | }
331 |
--------------------------------------------------------------------------------
/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 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | [![Build Status][build-badge]][build]
25 | [![All Contributors][all-contributors-badge]](#contributors)
26 | [![GPL 3.0 License][license-badge]][license]
27 | [![Code of Conduct][coc-badge]][coc]
28 |
29 |
30 | ## Prerequisites
31 |
32 | - Read my blog post
33 | [Inversion of Control](https://kentcdodds.com/blog/inversion-of-control). Or
34 | watch
35 | [Implement Inversion of Control](https://egghead.io/lessons/egghead-implement-inversion-of-control?pl=kent-s-blog-posts-as-screencasts-eefa540c&af=5236ad)
36 | - The more experience you have with building React abstractions, the more
37 | helpful this workshop will be for you.
38 |
39 | ## System Requirements
40 |
41 | - [git][git] v2.13 or greater
42 | - [NodeJS][node] `12 || 14 || 15 || 16`
43 | - [npm][npm] v6 or greater
44 |
45 | All of these must be available in your `PATH`. To verify things are set up
46 | properly, you can run this:
47 |
48 | ```shell
49 | git --version
50 | node --version
51 | npm --version
52 | ```
53 |
54 | If you have trouble with any of these, learn more about the PATH environment
55 | variable and how to fix it here for [windows][win-path] or
56 | [mac/linux][mac-path].
57 |
58 | ## Setup
59 |
60 | > If you want to commit and push your work as you go, you'll want to
61 | > [fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo)
62 | > first and then clone your fork rather than this repo directly.
63 |
64 | After you've made sure to have the correct things (and versions) installed, you
65 | should be able to just run a few commands to get set up:
66 |
67 | ```
68 | git clone https://github.com/kentcdodds/advanced-react-patterns.git
69 | cd advanced-react-patterns
70 | node setup
71 | ```
72 |
73 | This may take a few minutes. **It will ask you for your email.** This is
74 | optional and just automatically adds your email to the links in the project to
75 | make filling out some forms easier.
76 |
77 | If you get any errors, please read through them and see if you can find out what
78 | the problem is. If you can't work it out on your own then please [file an
79 | issue][issue] and provide _all_ the output from the commands you ran (even if
80 | it's a lot).
81 |
82 | If you can't get the setup script to work, then just make sure you have the
83 | right versions of the requirements listed above, and run the following commands:
84 |
85 | ```
86 | npm install
87 | npm run validate
88 | ```
89 |
90 | If you are still unable to fix issues and you know how to use Docker 🐳 you can
91 | setup the project with the following command:
92 |
93 | ```
94 | docker-compose up
95 | ```
96 |
97 | It's recommended you run everything locally in the same environment you work in
98 | every day, but if you're having issues getting things set up, you can also set
99 | this up using [GitHub Codespaces](https://github.com/features/codespaces)
100 | ([video demo](https://www.youtube.com/watch?v=gCoVJm3hGk4)) or
101 | [Codesandbox](https://codesandbox.io/s/github/kentcdodds/advanced-react-patterns).
102 |
103 | ## Running the app
104 |
105 | To get the app up and running (and really see if it worked), run:
106 |
107 | ```shell
108 | npm start
109 | ```
110 |
111 | This should start up your browser. If you're familiar, this is a standard
112 | [react-scripts](https://create-react-app.dev/) application.
113 |
114 | You can also open
115 | [the deployment of the app on Netlify](https://advanced-react-patterns.netlify.app/).
116 |
117 | ## Running the tests
118 |
119 | ```shell
120 | npm test
121 | ```
122 |
123 | This will start [Jest](https://jestjs.io/) in watch mode. Read the output and
124 | play around with it. The tests are there to help you reach the final version,
125 | however _sometimes_ you can accomplish the task and the tests still fail if you
126 | implement things differently than I do in my solution, so don't look to them as
127 | a complete authority.
128 |
129 | ### Exercises
130 |
131 | - `src/exercise/00.md`: Background, Exercise Instructions, Extra Credit
132 | - `src/exercise/00.js`: Exercise with Emoji helpers
133 | - `src/__tests__/00.js`: Tests
134 | - `src/final/00.js`: Final version
135 | - `src/final/00.extra-0.js`: Final version of extra credit
136 |
137 | The purpose of the exercise is **not** for you to work through all the material.
138 | It's intended to get your brain thinking about the right questions to ask me as
139 | _I_ walk through the material.
140 |
141 | ### Helpful Emoji 🐨 💰 💯 📝 🦉 📜 💣 💪 🏁 👨💼 🚨
142 |
143 | Each exercise has comments in it to help you get through the exercise. These fun
144 | emoji characters are here to help you.
145 |
146 | - **Kody the Koala** 🐨 will tell you when there's something specific you should
147 | do
148 | - **Marty the Money Bag** 💰 will give you specific tips (and sometimes code)
149 | along the way
150 | - **Hannah the Hundred** 💯 will give you extra challenges you can do if you
151 | finish the exercises early.
152 | - **Nancy the Notepad** 📝 will encourage you to take notes on what you're
153 | learning
154 | - **Olivia the Owl** 🦉 will give you useful tidbits/best practice notes and a
155 | link for elaboration and feedback.
156 | - **Dominic the Document** 📜 will give you links to useful documentation
157 | - **Berry the Bomb** 💣 will be hanging around anywhere you need to blow stuff
158 | up (delete code)
159 | - **Matthew the Muscle** 💪 will indicate that you're working with an exercise
160 | - **Chuck the Checkered Flag** 🏁 will indicate that you're working with a final
161 | - **Peter the Product Manager** 👨💼 helps us know what our users want
162 | - **Alfred the Alert** 🚨 will occasionally show up in the test failures with
163 | potential explanations for why the tests are failing.
164 |
165 | ## Contributors
166 |
167 | Thanks goes to these wonderful people
168 | ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)):
169 |
170 |
171 |
172 |
173 |
219 |
220 |
221 |
222 |
223 |
224 |
225 | This project follows the
226 | [all-contributors](https://github.com/kentcdodds/all-contributors)
227 | specification. Contributions of any kind welcome!
228 |
229 | ## Workshop Feedback
230 |
231 | Each exercise has an Elaboration and Feedback link. Please fill that out after
232 | the exercise and instruction.
233 |
234 | At the end of the workshop, please go to this URL to give overall feedback.
235 | Thank you! https://kcd.im/arp-ws-feedback
236 |
237 |
238 | [npm]: https://www.npmjs.com/
239 | [node]: https://nodejs.org
240 | [git]: https://git-scm.com/
241 | [build-badge]: https://img.shields.io/github/workflow/status/kentcdodds/advanced-react-patterns/validate/main?logo=github&style=flat-square
242 | [build]: https://github.com/kentcdodds/advanced-react-patterns/actions?query=workflow%3Avalidate
243 | [license-badge]: https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square
244 | [license]: https://github.com/kentcdodds/advanced-react-patterns/blob/main/LICENSE.md
245 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square
246 | [coc]: https://github.com/kentcdodds/advanced-react-patterns/blob/main/CODE_OF_CONDUCT.md
247 | [emojis]: https://github.com/kentcdodds/all-contributors#emoji-key
248 | [all-contributors]: https://github.com/kentcdodds/all-contributors
249 | [all-contributors-badge]: https://img.shields.io/github/all-contributors/kentcdodds/advanced-react-patterns?color=orange&style=flat-square
250 | [win-path]: https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/
251 | [mac-path]: http://stackoverflow.com/a/24322978/971592
252 | [issue]: https://github.com/kentcdodds/advanced-react-patterns/issues/new
253 |
254 |
--------------------------------------------------------------------------------