├── .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 | 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 | 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 |
10 |
11 | 14 |
15 |
16 | 17 | 21 |
22 |
23 | 24 | 28 |
29 |
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 | 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 | 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 | 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 | 52 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 ` 18 | 19 | 20 | 21 | ``` 22 | 23 | The ` 115 | 116 |
117 | 120 | 127 |
128 |
129 | 132 |