├── .gitattributes ├── public ├── _redirects ├── favicon.ico ├── img │ └── pokemon │ │ ├── ditto.jpg │ │ ├── mew.jpg │ │ ├── mewtwo.jpg │ │ ├── pikachu.jpg │ │ ├── bulbasaur.jpg │ │ ├── charizard.jpg │ │ └── fallback-pokemon.jpg ├── _headers ├── manifest.json ├── serve.json ├── index.html └── mockServiceWorker.js ├── setup.js ├── .eslintignore ├── .prettierignore ├── src ├── setupTests.js ├── exercise │ ├── 06-devtools-after.png │ ├── 06-devtools-before.png │ ├── 01.js │ ├── 06.js │ ├── 03.js │ ├── 04.md │ ├── 06.md │ ├── 04.js │ ├── 05.md │ ├── 05.js │ ├── 03.extra-2.js │ ├── 02.js │ ├── 03.md │ ├── 01.md │ └── 02.md ├── index.js ├── final │ ├── 01.js │ ├── 01.extra-1.js │ ├── 01.extra-2.js │ ├── 01.extra-3.js │ ├── 01.extra-4.js │ ├── 03.js │ ├── 03.extra-1.js │ ├── 06.js │ ├── 06.extra-1.js │ ├── 02.extra-2.js │ ├── 02.extra-1.js │ ├── 02.js │ ├── 04.js │ ├── 05.js │ ├── 03.extra-2.js │ └── 02.extra-3.js ├── __tests__ │ ├── 03.js │ ├── 01.js │ ├── 04.js │ ├── 05.js │ ├── 06.extra-1.js │ ├── 02.js │ ├── 06.js │ ├── 03.extra-2.js │ └── 02.extra-3.js ├── utils.js ├── styles.css ├── backend.js └── pokemon.js ├── .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 ├── 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 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | build 5 | .idea/ 6 | .vscode/ 7 | .eslintcache 8 | -------------------------------------------------------------------------------- /scripts/fix-links: -------------------------------------------------------------------------------- 1 | npx https://gist.github.com/kentcdodds/436a77ff8977269e5fee39d9d89956de 2 | npm run format 3 | -------------------------------------------------------------------------------- /public/img/pokemon/ditto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/public/img/pokemon/ditto.jpg -------------------------------------------------------------------------------- /public/img/pokemon/mew.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/public/img/pokemon/mew.jpg -------------------------------------------------------------------------------- /public/img/pokemon/mewtwo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/public/img/pokemon/mewtwo.jpg -------------------------------------------------------------------------------- /public/img/pokemon/pikachu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/public/img/pokemon/pikachu.jpg -------------------------------------------------------------------------------- /public/img/pokemon/bulbasaur.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/public/img/pokemon/bulbasaur.jpg -------------------------------------------------------------------------------- /public/img/pokemon/charizard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/public/img/pokemon/charizard.jpg -------------------------------------------------------------------------------- /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/exercise/06-devtools-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/src/exercise/06-devtools-after.png -------------------------------------------------------------------------------- /src/exercise/06-devtools-before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/src/exercise/06-devtools-before.png -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "node", 3 | "container": { 4 | "startScript": "start", 5 | "port": 3000 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /public/img/pokemon/fallback-pokemon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/advanced-react-hooks/main/public/img/pokemon/fallback-pokemon.jpg -------------------------------------------------------------------------------- /scripts/update-deps: -------------------------------------------------------------------------------- 1 | npx npm-check-updates --upgrade --reject husky 2 | rm -rf node_modules package-lock.json 3 | npm install 4 | npm run validate 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './styles.css' 2 | import codegen from 'codegen.macro' 3 | 4 | codegen`module.exports = require('@kentcdodds/react-workshop-app/codegen')` 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /img/* 2 | # we want to cache these images for one hour 3 | cache-control: public,max-age=3600,immutable 4 | /img/pokemon/* 5 | # we want to cache these images for one hour 6 | cache-control: public,max-age=3600,immutable -------------------------------------------------------------------------------- /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 Hooks", 3 | "name": "Advanced React Hooks", 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 | -------------------------------------------------------------------------------- /public/serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": [ 3 | { 4 | "source": "/img/pokemon/*", 5 | "headers": [ 6 | { 7 | "key": "cache-control", 8 | "value": "public,max-age=3600,immutable" 9 | } 10 | ] 11 | }, 12 | { 13 | "source": "/img/*", 14 | "headers": [ 15 | { 16 | "key": "cache-control", 17 | "value": "public,max-age=3600,immutable" 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/final/01.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // http://localhost:3000/isolated/final/01.js 3 | 4 | import * as React from 'react' 5 | 6 | const countReducer = (state, newState) => newState 7 | 8 | function Counter({initialCount = 0, step = 1}) { 9 | const [count, setCount] = React.useReducer(countReducer, initialCount) 10 | const increment = () => setCount(count + step) 11 | return 12 | } 13 | 14 | function App() { 15 | return 16 | } 17 | 18 | export default App 19 | -------------------------------------------------------------------------------- /src/final/01.extra-1.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // 💯 accept the step as the action 3 | // http://localhost:3000/isolated/final/01.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | const countReducer = (count, change) => count + change 8 | 9 | function Counter({initialCount = 0, step = 1}) { 10 | const [count, changeCount] = React.useReducer(countReducer, initialCount) 11 | const increment = () => changeCount(step) 12 | return 13 | } 14 | 15 | function Usage() { 16 | return 17 | } 18 | 19 | export default Usage 20 | -------------------------------------------------------------------------------- /src/__tests__/03.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import App from '../final/03' 5 | // import App from '../exercise/03' 6 | 7 | test('clicking the button increments the count', () => { 8 | render() 9 | const button = screen.getByText(/increment count/i) 10 | const display = screen.getByText(/the current count/i) 11 | expect(display).toHaveTextContent(/0/) 12 | userEvent.click(button) 13 | expect(display).toHaveTextContent(/1/) 14 | userEvent.click(button) 15 | expect(display).toHaveTextContent(/2/) 16 | }) 17 | -------------------------------------------------------------------------------- /src/final/01.extra-2.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // 💯 simulate setState with an object 3 | // http://localhost:3000/isolated/final/01.extra-2.js 4 | 5 | import * as React from 'react' 6 | 7 | const countReducer = (state, action) => ({...state, ...action}) 8 | 9 | function Counter({initialCount = 0, step = 1}) { 10 | const [state, setState] = React.useReducer(countReducer, { 11 | count: initialCount, 12 | }) 13 | const {count} = state 14 | const increment = () => setState({count: count + step}) 15 | return 16 | } 17 | 18 | function App() { 19 | return 20 | } 21 | 22 | export default App 23 | -------------------------------------------------------------------------------- /.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/01.extra-3.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // 💯 simulate setState with an object OR function 3 | // http://localhost:3000/isolated/final/01.extra-3.js 4 | 5 | import * as React from 'react' 6 | 7 | const countReducer = (state, action) => ({ 8 | ...state, 9 | ...(typeof action === 'function' ? action(state) : action), 10 | }) 11 | 12 | function Counter({initialCount = 0, step = 1}) { 13 | const [state, setState] = React.useReducer(countReducer, { 14 | count: initialCount, 15 | }) 16 | const {count} = state 17 | const increment = () => 18 | setState(currentState => ({count: currentState.count + step})) 19 | return 20 | } 21 | 22 | function App() { 23 | return 24 | } 25 | 26 | export default App 27 | -------------------------------------------------------------------------------- /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 Hooks 🔥 13 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /src/exercise/01.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // http://localhost:3000/isolated/exercise/01.js 3 | 4 | import * as React from 'react' 5 | 6 | function Counter({initialCount = 0, step = 1}) { 7 | // 🐨 replace React.useState with React.useReducer. 8 | // 💰 React.useReducer(countReducer, initialCount) 9 | const [count, setCount] = React.useState(initialCount) 10 | 11 | // 💰 you can write the countReducer function so you don't have to make any 12 | // changes to the next two lines of code! Remember: 13 | // The 1st argument is called "state" - the current value of count 14 | // The 2nd argument is called "newState" - the value passed to setCount 15 | const increment = () => setCount(count + step) 16 | return 17 | } 18 | 19 | function App() { 20 | return 21 | } 22 | 23 | export default App 24 | -------------------------------------------------------------------------------- /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/__tests__/01.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils' 3 | import {render} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import App from '../final/01' 6 | // import App from '../exercise/01' 7 | 8 | test('clicking the button increments the count with useReducer', () => { 9 | jest.spyOn(React, 'useReducer') 10 | 11 | const {container} = render() 12 | const button = container.querySelector('button') 13 | userEvent.click(button) 14 | expect(button).toHaveTextContent('1') 15 | userEvent.click(button) 16 | expect(button).toHaveTextContent('2') 17 | 18 | alfredTip(() => { 19 | expect(React.useReducer).toHaveBeenCalled() 20 | }, 'The Counter component that is rendered must call "useReducer" to get the "state" and "dispatch" function and you should get rid of that useState call.') 21 | }) 22 | -------------------------------------------------------------------------------- /src/final/01.extra-4.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // 💯 traditional dispatch object with a type and switch statement 3 | // http://localhost:3000/isolated/final/01.extra-4.js 4 | 5 | import * as React from 'react' 6 | 7 | function countReducer(state, action) { 8 | const {type, step} = action 9 | switch (type) { 10 | case 'increment': { 11 | return { 12 | ...state, 13 | count: state.count + step, 14 | } 15 | } 16 | default: { 17 | throw new Error(`Unsupported action type: ${type}`) 18 | } 19 | } 20 | } 21 | 22 | function Counter({initialCount = 0, step = 1}) { 23 | const [state, dispatch] = React.useReducer(countReducer, { 24 | count: initialCount, 25 | }) 26 | const {count} = state 27 | const increment = () => dispatch({type: 'increment', step}) 28 | return 29 | } 30 | 31 | function App() { 32 | return 33 | } 34 | 35 | export default App 36 | -------------------------------------------------------------------------------- /src/final/03.js: -------------------------------------------------------------------------------- 1 | // useContext: simple Counter 2 | // http://localhost:3000/isolated/final/03.js 3 | 4 | import * as React from 'react' 5 | 6 | const CountContext = React.createContext() 7 | 8 | function CountProvider(props) { 9 | const [count, setCount] = React.useState(0) 10 | const value = [count, setCount] 11 | // could also do it like this: 12 | // const value = React.useState(0) 13 | return 14 | } 15 | 16 | function CountDisplay() { 17 | const [count] = React.useContext(CountContext) 18 | return
{`The current count is ${count}`}
19 | } 20 | 21 | function Counter() { 22 | const [, setCount] = React.useContext(CountContext) 23 | const increment = () => setCount(c => c + 1) 24 | return 25 | } 26 | 27 | function App() { 28 | return ( 29 |
30 | 31 | 32 | 33 | 34 |
35 | ) 36 | } 37 | 38 | export default App 39 | -------------------------------------------------------------------------------- /src/final/03.extra-1.js: -------------------------------------------------------------------------------- 1 | // useContext: simple Counter 2 | // 💯 create a consumer hook 3 | // http://localhost:3000/isolated/final/03.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | const CountContext = React.createContext() 8 | 9 | function CountProvider(props) { 10 | const [count, setCount] = React.useState(0) 11 | const value = [count, setCount] 12 | return 13 | } 14 | 15 | function useCount() { 16 | const context = React.useContext(CountContext) 17 | if (!context) { 18 | throw new Error('useCount must be used within a CountProvider') 19 | } 20 | return context 21 | } 22 | 23 | function CountDisplay() { 24 | const [count] = useCount() 25 | return
{`The current count is ${count}`}
26 | } 27 | 28 | function Counter() { 29 | const [, setCount] = useCount() 30 | const increment = () => setCount(c => c + 1) 31 | return 32 | } 33 | 34 | function App() { 35 | return ( 36 |
37 | 38 | 39 | 40 | 41 |
42 | ) 43 | } 44 | 45 | export default App 46 | -------------------------------------------------------------------------------- /src/final/06.js: -------------------------------------------------------------------------------- 1 | // useDebugValue: useMedia 2 | // http://localhost:3000/isolated/final/06.js 3 | 4 | import * as React from 'react' 5 | 6 | function useMedia(query, initialState = false) { 7 | const [state, setState] = React.useState(initialState) 8 | React.useDebugValue(`\`${query}\` => ${state}`) 9 | 10 | React.useEffect(() => { 11 | let mounted = true 12 | const mql = window.matchMedia(query) 13 | function onChange() { 14 | if (!mounted) { 15 | return 16 | } 17 | setState(Boolean(mql.matches)) 18 | } 19 | 20 | mql.addListener(onChange) 21 | setState(mql.matches) 22 | 23 | return () => { 24 | mounted = false 25 | mql.removeListener(onChange) 26 | } 27 | }, [query]) 28 | 29 | return state 30 | } 31 | 32 | function Box() { 33 | const isBig = useMedia('(min-width: 1000px)') 34 | const isMedium = useMedia('(max-width: 999px) and (min-width: 700px)') 35 | const isSmall = useMedia('(max-width: 699px)') 36 | const color = isBig ? 'green' : isMedium ? 'yellow' : isSmall ? 'red' : null 37 | 38 | return
39 | } 40 | 41 | function App() { 42 | return 43 | } 44 | 45 | export default App 46 | -------------------------------------------------------------------------------- /src/exercise/06.js: -------------------------------------------------------------------------------- 1 | // useDebugValue: useMedia 2 | // http://localhost:3000/isolated/exercise/06.js 3 | 4 | import * as React from 'react' 5 | 6 | function useMedia(query, initialState = false) { 7 | const [state, setState] = React.useState(initialState) 8 | // 🐨 call React.useDebugValue here. 9 | // 💰 here's the formatted label I use: `\`${query}\` => ${state}` 10 | 11 | React.useEffect(() => { 12 | let mounted = true 13 | const mql = window.matchMedia(query) 14 | function onChange() { 15 | if (!mounted) { 16 | return 17 | } 18 | setState(Boolean(mql.matches)) 19 | } 20 | 21 | mql.addListener(onChange) 22 | setState(mql.matches) 23 | 24 | return () => { 25 | mounted = false 26 | mql.removeListener(onChange) 27 | } 28 | }, [query]) 29 | 30 | return state 31 | } 32 | 33 | function Box() { 34 | const isBig = useMedia('(min-width: 1000px)') 35 | const isMedium = useMedia('(max-width: 999px) and (min-width: 700px)') 36 | const isSmall = useMedia('(max-width: 699px)') 37 | const color = isBig ? 'green' : isMedium ? 'yellow' : isSmall ? 'red' : null 38 | 39 | return
40 | } 41 | 42 | function App() { 43 | return 44 | } 45 | 46 | export default App 47 | -------------------------------------------------------------------------------- /src/exercise/03.js: -------------------------------------------------------------------------------- 1 | // useContext: simple Counter 2 | // http://localhost:3000/isolated/exercise/03.js 3 | 4 | import * as React from 'react' 5 | 6 | // 🐨 create your CountContext here with React.createContext 7 | 8 | // 🐨 create a CountProvider component here that does this: 9 | // 🐨 get the count state and setCount updater with React.useState 10 | // 🐨 create a `value` array with count and setCount 11 | // 🐨 return your context provider with the value assigned to that array and forward all the other props 12 | // 💰 more specifically, we need the children prop forwarded to the context provider 13 | 14 | function CountDisplay() { 15 | // 🐨 get the count from useContext with the CountContext 16 | const count = 0 17 | return
{`The current count is ${count}`}
18 | } 19 | 20 | function Counter() { 21 | // 🐨 get the setCount from useContext with the CountContext 22 | const setCount = () => {} 23 | const increment = () => setCount(c => c + 1) 24 | return 25 | } 26 | 27 | function App() { 28 | return ( 29 |
30 | {/* 31 | 🐨 wrap these two components in the CountProvider so they can access 32 | the CountContext value 33 | */} 34 | 35 | 36 |
37 | ) 38 | } 39 | 40 | export default App 41 | -------------------------------------------------------------------------------- /src/final/06.extra-1.js: -------------------------------------------------------------------------------- 1 | // useDebugValue: useMedia 2 | // 💯 use the format function 3 | // http://localhost:3000/isolated/final/06.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | const formatDebugValue = ({query, state}) => `\`${query}\` => ${state}` 8 | 9 | function useMedia(query, initialState = false) { 10 | const [state, setState] = React.useState(initialState) 11 | React.useDebugValue({query, state}, formatDebugValue) 12 | 13 | React.useEffect(() => { 14 | let mounted = true 15 | const mql = window.matchMedia(query) 16 | function onChange() { 17 | if (!mounted) { 18 | return 19 | } 20 | setState(Boolean(mql.matches)) 21 | } 22 | 23 | mql.addListener(onChange) 24 | setState(mql.matches) 25 | 26 | return () => { 27 | mounted = false 28 | mql.removeListener(onChange) 29 | } 30 | }, [query]) 31 | 32 | return state 33 | } 34 | 35 | function Box() { 36 | const isBig = useMedia('(min-width: 1000px)') 37 | const isMedium = useMedia('(max-width: 999px) and (min-width: 700px)') 38 | const isSmall = useMedia('(max-width: 699px)') 39 | const color = isBig ? 'green' : isMedium ? 'yellow' : isSmall ? 'red' : null 40 | 41 | return
42 | } 43 | 44 | function App() { 45 | return 46 | } 47 | 48 | export default App 49 | -------------------------------------------------------------------------------- /src/__tests__/04.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import App from '../final/04' 5 | // import App from '../exercise/04' 6 | 7 | test('adds and removes children from the log', () => { 8 | const {getByText, getByRole} = render() 9 | const log = getByRole('log') 10 | const chatCount = log.children.length 11 | const add = getByText(/add/i) 12 | const remove = getByText(/remove/i) 13 | userEvent.click(add) 14 | expect(log.children).toHaveLength(chatCount + 1) 15 | userEvent.click(remove) 16 | expect(log.children).toHaveLength(chatCount) 17 | }) 18 | 19 | test('scrolls to the bottom', () => { 20 | const {getByText, getByRole} = render() 21 | const log = getByRole('log') 22 | const add = getByText(/add/i) 23 | const remove = getByText(/remove/i) 24 | const scrollTopSetter = jest.fn() 25 | Object.defineProperties(log, { 26 | scrollHeight: { 27 | get() { 28 | return 100 29 | }, 30 | }, 31 | scrollTop: { 32 | get() { 33 | return 0 34 | }, 35 | set: scrollTopSetter, 36 | }, 37 | }) 38 | 39 | userEvent.click(add) 40 | expect(scrollTopSetter).toHaveBeenCalledTimes(1) 41 | expect(scrollTopSetter).toHaveBeenCalledWith(log.scrollHeight) 42 | 43 | scrollTopSetter.mockClear() 44 | 45 | userEvent.click(remove) 46 | expect(scrollTopSetter).toHaveBeenCalledTimes(1) 47 | expect(scrollTopSetter).toHaveBeenCalledWith(log.scrollHeight) 48 | }) 49 | -------------------------------------------------------------------------------- /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-hooks.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-hooks/issues 39 | -------------------------------------------------------------------------------- /src/__tests__/05.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import App from '../final/05' 5 | // import App from '../exercise/05' 6 | 7 | test('adds and removes children from the log', () => { 8 | const {getByText, getByRole} = render() 9 | const log = getByRole('log') 10 | const chatCount = log.children.length 11 | const add = getByText(/add/i) 12 | const remove = getByText(/remove/i) 13 | userEvent.click(add) 14 | expect(log.children).toHaveLength(chatCount + 1) 15 | userEvent.click(remove) 16 | expect(log.children).toHaveLength(chatCount) 17 | }) 18 | 19 | test('scroll to top scrolls to the top', () => { 20 | const {getByText, getByRole} = render() 21 | const log = getByRole('log') 22 | const scrollToTop = getByText(/scroll to top/i) 23 | const scrollToBottom = getByText(/scroll to bottom/i) 24 | const scrollTopSetter = jest.fn() 25 | Object.defineProperties(log, { 26 | scrollHeight: { 27 | get() { 28 | return 100 29 | }, 30 | }, 31 | scrollTop: { 32 | get() { 33 | return 0 34 | }, 35 | set: scrollTopSetter, 36 | }, 37 | }) 38 | userEvent.click(scrollToTop) 39 | expect(scrollTopSetter).toHaveBeenCalledTimes(1) 40 | expect(scrollTopSetter).toHaveBeenCalledWith(0) 41 | 42 | scrollTopSetter.mockClear() 43 | 44 | userEvent.click(scrollToBottom) 45 | expect(scrollTopSetter).toHaveBeenCalledTimes(1) 46 | expect(scrollTopSetter).toHaveBeenCalledWith(log.scrollHeight) 47 | }) 48 | -------------------------------------------------------------------------------- /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/__tests__/06.extra-1.js: -------------------------------------------------------------------------------- 1 | import matchMediaPolyfill from 'mq-polyfill' 2 | import * as React from 'react' 3 | import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils' 4 | import {render} from '@testing-library/react' 5 | import App from '../final/06.extra-1' 6 | // import App from '../exercise/06' 7 | 8 | beforeAll(() => { 9 | matchMediaPolyfill(window) 10 | }) 11 | 12 | test('works', () => { 13 | jest.spyOn(React, 'useDebugValue') 14 | render() 15 | alfredTip( 16 | () => expect(React.useDebugValue).toHaveBeenCalled(), 17 | `Make sure \`useDebugValue\` is called`, 18 | ) 19 | 20 | const [[debugData, formatDebugValue]] = React.useDebugValue.mock.calls 21 | alfredTip( 22 | () => 23 | expect(debugData).toEqual({ 24 | query: expect.any(String), 25 | state: expect.any(Boolean), 26 | }), 27 | `Make sure \`useDebugValue\` is called with an object as the first argument with query (string) and state (boolean) properties`, 28 | ) 29 | alfredTip( 30 | () => expect(formatDebugValue).toEqual(expect.any(Function)), 31 | `Make sure \`useDebugValue\` is called with a function as the second argument`, 32 | ) 33 | 34 | alfredTip(() => { 35 | expect(formatDebugValue({query: '(max-width: 699px)', state: false})).toBe( 36 | `\`(max-width: 699px)\` => false`, 37 | ) 38 | expect(formatDebugValue({query: '(max-width: 699px)', state: true})).toBe( 39 | `\`(max-width: 699px)\` => true`, 40 | ) 41 | }, `Make sure the format returned from your debug format function is \`{query}\` => {state}. Right now, we're getting: "${formatDebugValue({query: '(max-width: 699px)', state: false})}"`) 42 | }) 43 | -------------------------------------------------------------------------------- /src/exercise/04.md: -------------------------------------------------------------------------------- 1 | # useLayoutEffect: auto-scrolling textarea 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/04.md` 6 | 7 | ## Background 8 | 9 | There are two ways to tell React to run side-effects after it renders: 10 | 11 | 1. `useEffect` 12 | 2. `useLayoutEffect` 13 | 14 | The difference about these is subtle (they have the exact same API), but 15 | significant. 99% of the time `useEffect` is what you want, but sometimes 16 | `useLayoutEffect` can improve your user experience. 17 | 18 | To learn about the difference, read 19 | [useEffect vs useLayoutEffect](https://kentcdodds.com/blog/useeffect-vs-uselayouteffect) 20 | 21 | And check out the [hook flow diagram](https://github.com/donavon/hook-flow) as 22 | well. 23 | 24 | ## Exercise 25 | 26 | Production deploys: 27 | 28 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/04.js) 29 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/04.js) 30 | 31 | There's no exercise for this one because basically you just need to replace 32 | `useEffect` with `useLayoutEffect` and you're good. So you pretty much just need 33 | to experiment with things a bit. 34 | 35 | Before you do that though, compare the finished example with the exercise. 36 | Add/remove messages and you'll find that there's a janky experience with the 37 | exercise version because we're using `useEffect` and there's a gap between the 38 | time that the DOM is visually updated and our code runs. 39 | 40 | Here's the simple rule for when you should use `useLayoutEffect`: If you are 41 | making observable changes to the DOM, then it should happen in 42 | `useLayoutEffect`, otherwise `useEffect`. 43 | 44 | ## 🦉 Feedback 45 | 46 | Fill out 47 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=04%3A%20useLayoutEffect%3A%20auto-scrolling%20textarea&em=nfurlan%40alley.confurlan%40alley.co). 48 | -------------------------------------------------------------------------------- /src/__tests__/02.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 App from '../final/02' 6 | // import App from '../exercise/02' 7 | 8 | beforeEach(() => { 9 | jest.spyOn(window, 'fetch') 10 | jest.spyOn(console, 'error') 11 | }) 12 | 13 | afterEach(() => { 14 | window.fetch.mockRestore() 15 | console.error.mockRestore() 16 | }) 17 | 18 | test('displays the pokemon', async () => { 19 | render() 20 | const input = screen.getByLabelText(/pokemon/i) 21 | const submit = screen.getByText(/^submit$/i) 22 | 23 | // verify that an initial request is made when mounted 24 | userEvent.type(input, 'pikachu') 25 | userEvent.click(submit) 26 | 27 | await screen.findByRole('heading', {name: /pikachu/i}) 28 | 29 | // verify that a request is made when props change 30 | userEvent.clear(input) 31 | userEvent.type(input, 'ditto') 32 | userEvent.click(submit) 33 | 34 | await screen.findByRole('heading', {name: /ditto/i}) 35 | 36 | // verify that when props remain the same a request is not made 37 | window.fetch.mockClear() 38 | 39 | userEvent.click(submit) 40 | 41 | await screen.findByRole('heading', {name: /ditto/i}) 42 | 43 | alfredTip( 44 | () => expect(window.fetch).not.toHaveBeenCalled(), 45 | 'Make certain that you are providing a dependencies list in useEffect!', 46 | ) 47 | 48 | // verify error handling 49 | console.error.mockImplementation(() => {}) 50 | 51 | userEvent.clear(input) 52 | userEvent.type(input, 'george') 53 | userEvent.click(submit) 54 | expect(await screen.findByRole('alert')).toHaveTextContent( 55 | /There was an error.*Unsupported pokemon.*george/, 56 | ) 57 | expect(console.error).toHaveBeenCalledTimes(2) 58 | 59 | console.error.mockReset() 60 | }) 61 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | function useSafeDispatch(dispatch) { 4 | const mounted = React.useRef(false) 5 | 6 | React.useLayoutEffect(() => { 7 | mounted.current = true 8 | return () => { 9 | mounted.current = false 10 | } 11 | }, []) 12 | 13 | return React.useCallback( 14 | (...args) => (mounted.current ? dispatch(...args) : void 0), 15 | [dispatch], 16 | ) 17 | } 18 | 19 | function asyncReducer(state, action) { 20 | switch (action.type) { 21 | case 'pending': { 22 | return {status: 'pending', data: null, error: null} 23 | } 24 | case 'resolved': { 25 | return {status: 'resolved', data: action.data, error: null} 26 | } 27 | case 'rejected': { 28 | return {status: 'rejected', data: null, error: action.error} 29 | } 30 | default: { 31 | throw new Error(`Unhandled action type: ${action.type}`) 32 | } 33 | } 34 | } 35 | 36 | function useAsync(initialState) { 37 | const [state, unsafeDispatch] = React.useReducer(asyncReducer, { 38 | status: 'idle', 39 | data: null, 40 | error: null, 41 | ...initialState, 42 | }) 43 | 44 | const dispatch = useSafeDispatch(unsafeDispatch) 45 | 46 | const {data, error, status} = state 47 | 48 | const run = React.useCallback( 49 | promise => { 50 | dispatch({type: 'pending'}) 51 | promise.then( 52 | data => { 53 | dispatch({type: 'resolved', data}) 54 | }, 55 | error => { 56 | dispatch({type: 'rejected', error}) 57 | }, 58 | ) 59 | }, 60 | [dispatch], 61 | ) 62 | 63 | const setData = React.useCallback( 64 | data => dispatch({type: 'resolved', data}), 65 | [dispatch], 66 | ) 67 | const setError = React.useCallback( 68 | error => dispatch({type: 'rejected', error}), 69 | [dispatch], 70 | ) 71 | 72 | return { 73 | setData, 74 | setError, 75 | error, 76 | status, 77 | data, 78 | run, 79 | } 80 | } 81 | 82 | export {useAsync} 83 | -------------------------------------------------------------------------------- /src/__tests__/06.js: -------------------------------------------------------------------------------- 1 | import matchMediaPolyfill from 'mq-polyfill' 2 | import * as React from 'react' 3 | import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils' 4 | import {render, act} from '@testing-library/react' 5 | import App from '../final/06' 6 | // import App from '../exercise/06' 7 | 8 | beforeAll(() => { 9 | matchMediaPolyfill(window) 10 | window.resizeTo = function resizeTo(width, height) { 11 | Object.assign(this, { 12 | innerWidth: width, 13 | innerHeight: height, 14 | outerWidth: width, 15 | outerHeight: height, 16 | }).dispatchEvent(new this.Event('resize')) 17 | } 18 | }) 19 | 20 | test('works', () => { 21 | jest.spyOn(React, 'useDebugValue') 22 | const {container} = render() 23 | alfredTip( 24 | () => expect(React.useDebugValue).toHaveBeenCalled(), 25 | `Make sure to call \`useDebugValue\` with the formatted value`, 26 | ) 27 | alfredTip( 28 | () => 29 | expect(React.useDebugValue).toHaveBeenCalledWith( 30 | expect.stringContaining('max-width: 699px'), 31 | ), 32 | `Make sure to call \`useDebugValue\` with the formatted value`, 33 | ) 34 | alfredTip( 35 | () => 36 | expect(React.useDebugValue).toHaveBeenCalledWith( 37 | expect.stringContaining('max-width: 999px'), 38 | ), 39 | `Make sure to call \`useDebugValue\` with the formatted value`, 40 | ) 41 | alfredTip( 42 | () => 43 | expect(React.useDebugValue).toHaveBeenCalledWith( 44 | expect.stringContaining('min-width: 1000px'), 45 | ), 46 | `Make sure to call \`useDebugValue\` with the formatted value`, 47 | ) 48 | const box = container.querySelector('[style]') 49 | 50 | act(() => { 51 | window.resizeTo(1001, 1001) 52 | }) 53 | expect(box).toHaveStyle(`background-color: green;`) 54 | 55 | act(() => { 56 | window.resizeTo(800, 800) 57 | }) 58 | expect(box).toHaveStyle(`background-color: yellow;`) 59 | 60 | act(() => { 61 | window.resizeTo(600, 600) 62 | }) 63 | expect(box).toHaveStyle(`background-color: red;`) 64 | }) 65 | -------------------------------------------------------------------------------- /.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/__tests__/03.extra-2.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 App from '../final/03.extra-2' 6 | // import App from '../exercise/03.extra-2' 7 | 8 | beforeEach(() => { 9 | jest.spyOn(window, 'fetch') 10 | jest.spyOn(console, 'error') 11 | }) 12 | 13 | afterEach(() => { 14 | window.fetch.mockRestore() 15 | console.error.mockRestore() 16 | }) 17 | 18 | test('displays the pokemon', async () => { 19 | render() 20 | const input = screen.getByLabelText(/pokemon/i) 21 | const submit = screen.getByText(/^submit$/i) 22 | 23 | // verify that an initial request is made when mounted 24 | userEvent.type(input, 'pikachu') 25 | userEvent.click(submit) 26 | 27 | await screen.findByRole('heading', {name: /pikachu/i}) 28 | 29 | // verify that a request is made when props change 30 | userEvent.clear(input) 31 | userEvent.type(input, 'ditto') 32 | userEvent.click(submit) 33 | 34 | await screen.findByRole('heading', {name: /ditto/i}) 35 | 36 | // verify that when props remain the same a request is not made 37 | window.fetch.mockClear() 38 | 39 | userEvent.click(submit) 40 | 41 | await screen.findByRole('heading', {name: /ditto/i}) 42 | 43 | alfredTip( 44 | () => expect(window.fetch).not.toHaveBeenCalled(), 45 | 'Make certain that you are providing a dependencies list in useEffect!', 46 | ) 47 | 48 | // verify error handling 49 | console.error.mockImplementation(() => {}) 50 | 51 | userEvent.clear(input) 52 | userEvent.type(input, 'george') 53 | userEvent.click(submit) 54 | expect(await screen.findByRole('alert')).toHaveTextContent( 55 | /There was an error.*Unsupported pokemon.*george/, 56 | ) 57 | expect(console.error).toHaveBeenCalledTimes(2) 58 | 59 | console.error.mockReset() 60 | window.fetch.mockClear() 61 | 62 | // use the cached value 63 | userEvent.click(screen.getByRole('button', {name: /ditto/i})) 64 | expect(window.fetch).not.toHaveBeenCalled() 65 | await screen.findByRole('heading', {name: /ditto/i}) 66 | }) 67 | -------------------------------------------------------------------------------- /src/__tests__/02.extra-3.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils' 3 | import {render, screen, act} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import App from '../final/02.extra-3' 6 | // import App from '../exercise/02' 7 | 8 | beforeEach(() => { 9 | jest.spyOn(window, 'fetch') 10 | jest.spyOn(console, 'error') 11 | }) 12 | 13 | afterEach(() => { 14 | window.fetch.mockRestore() 15 | console.error.mockRestore() 16 | }) 17 | 18 | test('displays the pokemon', async () => { 19 | const {unmount} = render() 20 | const input = screen.getByLabelText(/pokemon/i) 21 | const submit = screen.getByText(/^submit$/i) 22 | 23 | // verify that an initial request is made when mounted 24 | userEvent.type(input, 'pikachu') 25 | userEvent.click(submit) 26 | 27 | await screen.findByRole('heading', {name: /pikachu/i}) 28 | 29 | // verify that a request is made when props change 30 | userEvent.clear(input) 31 | userEvent.type(input, 'ditto') 32 | userEvent.click(submit) 33 | 34 | await screen.findByRole('heading', {name: /ditto/i}) 35 | 36 | // verify that when props remain the same a request is not made 37 | window.fetch.mockClear() 38 | 39 | userEvent.click(submit) 40 | 41 | await screen.findByRole('heading', {name: /ditto/i}) 42 | 43 | alfredTip( 44 | () => expect(window.fetch).not.toHaveBeenCalled(), 45 | 'Make certain that you are providing a dependencies list in useEffect!', 46 | ) 47 | 48 | // verify error handling 49 | console.error.mockImplementation(() => {}) 50 | 51 | userEvent.clear(input) 52 | userEvent.type(input, 'george') 53 | userEvent.click(submit) 54 | expect(await screen.findByRole('alert')).toHaveTextContent( 55 | /There was an error.*Unsupported pokemon.*george/, 56 | ) 57 | expect(console.error).toHaveBeenCalledTimes(2) 58 | 59 | // restore the original implementation 60 | console.error.mockRestore() 61 | // but we still want to make sure it's not called 62 | jest.spyOn(console, 'error') 63 | 64 | userEvent.type(input, 'mew') 65 | userEvent.click(submit) 66 | 67 | // verify unmounting does not result in an error 68 | unmount() 69 | // wait for a bit for the mocked request to resolve: 70 | await act(() => new Promise(r => setTimeout(r, 100))) 71 | alfredTip( 72 | () => expect(console.error).not.toHaveBeenCalled(), 73 | 'Make sure that when the component is unmounted the component does not attempt to trigger a rerender with `dispatch`', 74 | ) 75 | }) 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "advanced-react-hooks", 3 | "title": "Advanced React Hooks 🔥", 4 | "description": "The best resources for you to learn advanced react hooks", 5 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 6 | "version": "1.0.0", 7 | "private": true, 8 | "keywords": [], 9 | "homepage": "https://advanced-react-hooks.netlify.com/", 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.1.0", 18 | "@testing-library/react": "^11.2.6", 19 | "@testing-library/user-event": "^13.1.5", 20 | "chalk": "^4.1.0", 21 | "codegen.macro": "^4.1.0", 22 | "mq-polyfill": "^1.1.8", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2", 25 | "react-error-boundary": "^3.1.1" 26 | }, 27 | "devDependencies": { 28 | "@types/react": "^17.0.3", 29 | "@types/react-dom": "^17.0.3", 30 | "husky": "^4.3.8", 31 | "npm-run-all": "^4.1.5", 32 | "prettier": "^2.2.1", 33 | "react-scripts": "^4.0.3" 34 | }, 35 | "scripts": { 36 | "start": "react-scripts start", 37 | "build": "react-scripts build", 38 | "test": "react-scripts test", 39 | "test:coverage": "npm run test -- --watchAll=false", 40 | "test:exercises": "npm run test -- testing.*exercises\\/ --onlyChanged", 41 | "setup": "node setup", 42 | "lint": "eslint .", 43 | "format": "prettier --write \"./src\"", 44 | "validate": "npm-run-all --parallel build test:coverage lint" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "pre-commit": "node ./scripts/pre-commit", 49 | "pre-push": "node ./scripts/pre-push" 50 | } 51 | }, 52 | "jest": { 53 | "collectCoverageFrom": [ 54 | "src/final/**/*.js" 55 | ] 56 | }, 57 | "eslintConfig": { 58 | "extends": "react-app" 59 | }, 60 | "babel": { 61 | "presets": [ 62 | "babel-preset-react-app" 63 | ] 64 | }, 65 | "browserslist": { 66 | "development": [ 67 | "last 2 chrome versions", 68 | "last 2 firefox versions", 69 | "last 2 edge versions" 70 | ], 71 | "production": [ 72 | ">1%", 73 | "last 4 versions", 74 | "Firefox ESR", 75 | "not ie < 11" 76 | ] 77 | }, 78 | "repository": { 79 | "type": "git", 80 | "url": "git+https://github.com/kentcdodds/advanced-react-hooks.git" 81 | }, 82 | "bugs": { 83 | "url": "https://github.com/kentcdodds/advanced-react-hooks/issues" 84 | }, 85 | "msw": { 86 | "workerDirectory": "public" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/final/02.extra-2.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // 💯 return a memoized `run` function from useAsync 3 | // http://localhost:3000/isolated/final/02.extra-2.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonForm, 9 | PokemonDataView, 10 | PokemonInfoFallback, 11 | PokemonErrorBoundary, 12 | } from '../pokemon' 13 | 14 | function asyncReducer(state, action) { 15 | switch (action.type) { 16 | case 'pending': { 17 | return {status: 'pending', data: null, error: null} 18 | } 19 | case 'resolved': { 20 | return {status: 'resolved', data: action.data, error: null} 21 | } 22 | case 'rejected': { 23 | return {status: 'rejected', data: null, error: action.error} 24 | } 25 | default: { 26 | throw new Error(`Unhandled action type: ${action.type}`) 27 | } 28 | } 29 | } 30 | 31 | function useAsync(initialState) { 32 | const [state, dispatch] = React.useReducer(asyncReducer, { 33 | status: 'idle', 34 | data: null, 35 | error: null, 36 | ...initialState, 37 | }) 38 | 39 | const {data, error, status} = state 40 | 41 | const run = React.useCallback(promise => { 42 | dispatch({type: 'pending'}) 43 | promise.then( 44 | data => { 45 | dispatch({type: 'resolved', data}) 46 | }, 47 | error => { 48 | dispatch({type: 'rejected', error}) 49 | }, 50 | ) 51 | }, []) 52 | 53 | return { 54 | error, 55 | status, 56 | data, 57 | run, 58 | } 59 | } 60 | 61 | function PokemonInfo({pokemonName}) { 62 | const {data: pokemon, status, error, run} = useAsync({ 63 | status: pokemonName ? 'pending' : 'idle', 64 | }) 65 | 66 | React.useEffect(() => { 67 | if (!pokemonName) { 68 | return 69 | } 70 | run(fetchPokemon(pokemonName)) 71 | }, [pokemonName, run]) 72 | 73 | if (status === 'idle') { 74 | return 'Submit a pokemon' 75 | } else if (status === 'pending') { 76 | return 77 | } else if (status === 'rejected') { 78 | throw error 79 | } else if (status === 'resolved') { 80 | return 81 | } 82 | 83 | throw new Error('This should be impossible') 84 | } 85 | 86 | function App() { 87 | const [pokemonName, setPokemonName] = React.useState('') 88 | 89 | function handleSubmit(newPokemonName) { 90 | setPokemonName(newPokemonName) 91 | } 92 | 93 | function handleReset() { 94 | setPokemonName('') 95 | } 96 | 97 | return ( 98 |
99 | 100 |
101 |
102 | 103 | 104 | 105 |
106 |
107 | ) 108 | } 109 | 110 | function AppWithUnmountCheckbox() { 111 | const [mountApp, setMountApp] = React.useState(true) 112 | return ( 113 |
114 | 122 |
123 | {mountApp ? : null} 124 |
125 | ) 126 | } 127 | 128 | export default AppWithUnmountCheckbox 129 | -------------------------------------------------------------------------------- /src/final/02.extra-1.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // 💯 use useCallback to empower the user to customize memoization 3 | // http://localhost:3000/isolated/final/02.extra-1.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonForm, 9 | PokemonDataView, 10 | PokemonInfoFallback, 11 | PokemonErrorBoundary, 12 | } from '../pokemon' 13 | 14 | function asyncReducer(state, action) { 15 | switch (action.type) { 16 | case 'pending': { 17 | return {status: 'pending', data: null, error: null} 18 | } 19 | case 'resolved': { 20 | return {status: 'resolved', data: action.data, error: null} 21 | } 22 | case 'rejected': { 23 | return {status: 'rejected', data: null, error: action.error} 24 | } 25 | default: { 26 | throw new Error(`Unhandled action type: ${action.type}`) 27 | } 28 | } 29 | } 30 | 31 | function useAsync(asyncCallback, initialState) { 32 | const [state, dispatch] = React.useReducer(asyncReducer, { 33 | status: 'idle', 34 | data: null, 35 | error: null, 36 | ...initialState, 37 | }) 38 | React.useEffect(() => { 39 | const promise = asyncCallback() 40 | if (!promise) { 41 | return 42 | } 43 | dispatch({type: 'pending'}) 44 | promise.then( 45 | data => { 46 | dispatch({type: 'resolved', data}) 47 | }, 48 | error => { 49 | dispatch({type: 'rejected', error}) 50 | }, 51 | ) 52 | }, [asyncCallback]) 53 | return state 54 | } 55 | 56 | function PokemonInfo({pokemonName}) { 57 | const asyncCallback = React.useCallback(() => { 58 | if (!pokemonName) { 59 | return 60 | } 61 | return fetchPokemon(pokemonName) 62 | }, [pokemonName]) 63 | 64 | const state = useAsync(asyncCallback, { 65 | status: pokemonName ? 'pending' : 'idle', 66 | }) 67 | const {data: pokemon, status, error} = state 68 | 69 | if (status === 'idle') { 70 | return 'Submit a pokemon' 71 | } else if (status === 'pending') { 72 | return 73 | } else if (status === 'rejected') { 74 | throw error 75 | } else if (status === 'resolved') { 76 | return 77 | } 78 | 79 | throw new Error('This should be impossible') 80 | } 81 | 82 | function App() { 83 | const [pokemonName, setPokemonName] = React.useState('') 84 | 85 | function handleSubmit(newPokemonName) { 86 | setPokemonName(newPokemonName) 87 | } 88 | 89 | function handleReset() { 90 | setPokemonName('') 91 | } 92 | 93 | return ( 94 |
95 | 96 |
97 |
98 | 99 | 100 | 101 |
102 |
103 | ) 104 | } 105 | 106 | function AppWithUnmountCheckbox() { 107 | const [mountApp, setMountApp] = React.useState(true) 108 | return ( 109 |
110 | 118 |
119 | {mountApp ? : null} 120 |
121 | ) 122 | } 123 | 124 | export default AppWithUnmountCheckbox 125 | -------------------------------------------------------------------------------- /src/final/02.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // http://localhost:3000/isolated/final/02.js 3 | 4 | import * as React from 'react' 5 | import { 6 | fetchPokemon, 7 | PokemonForm, 8 | PokemonDataView, 9 | PokemonInfoFallback, 10 | PokemonErrorBoundary, 11 | } from '../pokemon' 12 | 13 | function asyncReducer(state, action) { 14 | switch (action.type) { 15 | case 'pending': { 16 | return {status: 'pending', data: null, error: null} 17 | } 18 | case 'resolved': { 19 | return {status: 'resolved', data: action.data, error: null} 20 | } 21 | case 'rejected': { 22 | return {status: 'rejected', data: null, error: action.error} 23 | } 24 | default: { 25 | throw new Error(`Unhandled action type: ${action.type}`) 26 | } 27 | } 28 | } 29 | 30 | function useAsync(asyncCallback, initialState, dependencies) { 31 | const [state, dispatch] = React.useReducer(asyncReducer, { 32 | status: 'idle', 33 | data: null, 34 | error: null, 35 | ...initialState, 36 | }) 37 | 38 | React.useEffect(() => { 39 | const promise = asyncCallback() 40 | if (!promise) { 41 | return 42 | } 43 | dispatch({type: 'pending'}) 44 | promise.then( 45 | data => { 46 | dispatch({type: 'resolved', data}) 47 | }, 48 | error => { 49 | dispatch({type: 'rejected', error}) 50 | }, 51 | ) 52 | // too bad the eslint plugin can't statically analyze this :-( 53 | // eslint-disable-next-line react-hooks/exhaustive-deps 54 | }, dependencies) 55 | 56 | return state 57 | } 58 | 59 | function PokemonInfo({pokemonName}) { 60 | const state = useAsync( 61 | () => { 62 | if (!pokemonName) { 63 | return 64 | } 65 | return fetchPokemon(pokemonName) 66 | }, 67 | {status: pokemonName ? 'pending' : 'idle'}, 68 | [pokemonName], 69 | ) 70 | 71 | const {data: pokemon, status, error} = state 72 | 73 | if (status === 'idle') { 74 | return 'Submit a pokemon' 75 | } else if (status === 'pending') { 76 | return 77 | } else if (status === 'rejected') { 78 | throw error 79 | } else if (status === 'resolved') { 80 | return 81 | } 82 | 83 | throw new Error('This should be impossible') 84 | } 85 | 86 | function App() { 87 | const [pokemonName, setPokemonName] = React.useState('') 88 | 89 | function handleSubmit(newPokemonName) { 90 | setPokemonName(newPokemonName) 91 | } 92 | 93 | function handleReset() { 94 | setPokemonName('') 95 | } 96 | 97 | return ( 98 |
99 | 100 |
101 |
102 | 103 | 104 | 105 |
106 |
107 | ) 108 | } 109 | 110 | function AppWithUnmountCheckbox() { 111 | const [mountApp, setMountApp] = React.useState(true) 112 | return ( 113 |
114 | 122 |
123 | {mountApp ? : null} 124 |
125 | ) 126 | } 127 | 128 | export default AppWithUnmountCheckbox 129 | -------------------------------------------------------------------------------- /src/exercise/06.md: -------------------------------------------------------------------------------- 1 | # useDebugValue: useMedia 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/06.md` 6 | 7 | ## Background 8 | 9 | [The React DevTools browser extension](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) 10 | is a must-have for any React developer. When you start writing custom hooks, it 11 | can be useful to give them a special label. This is especially useful to 12 | differentiate different usages of the same hook in a given component. 13 | 14 | This is where `useDebugValue` comes in. You use it in a custom hook, and you 15 | call it like so: 16 | 17 | ```javascript 18 | function useCount({initialCount = 0, step = 1} = {}) { 19 | React.useDebugValue({initialCount, step}) 20 | const [count, setCount] = React.useState(0) 21 | const increment = () => setCount(c => c + 1) 22 | return [count, increment] 23 | } 24 | ``` 25 | 26 | So now when people use the `useCount` hook, they'll see the `initialCount` and 27 | `step` values for that particular hook. 28 | 29 | ## Exercise 30 | 31 | Production deploys: 32 | 33 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/06.js) 34 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/06.js) 35 | 36 | In this exercise, we have a custom `useMedia` hook which uses 37 | `window.matchMedia` to determine whether the user-agent satisfies a given media 38 | query. In our `Box` component, we're using it three times to determine whether 39 | the screen is big, medium, or small and we change the color of the box based on 40 | that. 41 | 42 | Now, take a look at the png files associated with this exercise. You'll notice 43 | that the before doesn't give any useful information for you to know which hook 44 | record references which hook. In the after version, you'll see a really nice 45 | label associated with each hook which makes it obvious which is which. 46 | 47 | If you don't have the browser extension installed, install it now and open the 48 | React tab in the DevTools. Select the `` component in the React tree. 49 | Your job is to use `useDebugValue` to provide a nice label. 50 | 51 | > Note: your hooks may look a tiny bit different from the screenshots thanks to 52 | > the fact that we're using 53 | > [`stop-runaway-react-effects`](https://github.com/kentcdodds/stop-runaway-react-effects). 54 | > Just focus on the label. That should be the same. 55 | 56 | ## Extra Credit 57 | 58 | ### 1. 💯 use the format function 59 | 60 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/06.extra-1.js) 61 | 62 | `useDebugValue` also takes a second argument which is an optional formatter 63 | function, allowing you to do stuff like this if you like: 64 | 65 | ```javascript 66 | const formatCountDebugValue = ({initialCount, step}) => 67 | `init: ${initialCount}; step: ${step}` 68 | 69 | function useCount({initialCount = 0, step = 1} = {}) { 70 | React.useDebugValue({initialCount, step}, formatCountDebugValue) 71 | const [count, setCount] = React.useState(0) 72 | const increment = () => setCount(c => c + 1) 73 | return [count, increment] 74 | } 75 | ``` 76 | 77 | This is only really useful for situations where computing the debug value is 78 | computationally expensive (and therefore you only want it calculated when the 79 | DevTools are open and not when your users are using the app). In our case this 80 | is not necessary, however, go ahead and give it a try anyway. 81 | 82 | ## 🦉 Feedback 83 | 84 | Fill out 85 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=06%3A%20useDebugValue%3A%20useMedia&em=nfurlan%40alley.confurlan%40alley.co). 86 | -------------------------------------------------------------------------------- /src/final/04.js: -------------------------------------------------------------------------------- 1 | // useLayoutEffect: auto-scrolling textarea 2 | // http://localhost:3000/isolated/final/04.js 3 | 4 | import * as React from 'react' 5 | 6 | function MessagesDisplay({messages}) { 7 | const containerRef = React.useRef() 8 | React.useLayoutEffect(() => { 9 | containerRef.current.scrollTop = containerRef.current.scrollHeight 10 | }) 11 | 12 | return ( 13 |
14 | {messages.map((message, index, array) => ( 15 |
16 | {message.author}: {message.content} 17 | {array.length - 1 === index ? null :
} 18 |
19 | ))} 20 |
21 | ) 22 | } 23 | 24 | // this is to simulate major computation/big rendering tree/etc. 25 | function sleep(time = 0) { 26 | const wakeUpTime = Date.now() + time 27 | while (Date.now() < wakeUpTime) {} 28 | } 29 | 30 | function SlooooowSibling() { 31 | // try this with useLayoutEffect as well to see 32 | // how it impacts interactivity of the page before updates. 33 | React.useEffect(() => { 34 | // increase this number to see a more stark difference 35 | sleep(300) 36 | }) 37 | return null 38 | } 39 | 40 | function App() { 41 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8)) 42 | const addMessage = () => 43 | messages.length < allMessages.length 44 | ? setMessages(allMessages.slice(0, messages.length + 1)) 45 | : null 46 | const removeMessage = () => 47 | messages.length > 0 48 | ? setMessages(allMessages.slice(0, messages.length - 1)) 49 | : null 50 | 51 | return ( 52 |
53 |
54 | 55 | 56 |
57 |
58 | 59 | 60 |
61 | ) 62 | } 63 | 64 | export default App 65 | 66 | const allMessages = [ 67 | `Leia: Aren't you a little short to be a stormtrooper?`, 68 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`, 69 | `Leia: You're who?`, 70 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`, 71 | `Leia: Ben Kenobi is here! Where is he?`, 72 | `Luke: Come on!`, 73 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`, 74 | `Leia: Put that thing away! You're going to get us all killed.`, 75 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`, 76 | `Leia: It could be worse...`, 77 | `Han: It's worse.`, 78 | `Luke: There's something alive in here!`, 79 | `Han: That's your imagination.`, 80 | `Luke: Something just moves past my leg! Look! Did you see that?`, 81 | `Han: What?`, 82 | `Luke: Help!`, 83 | `Han: Luke! Luke! Luke!`, 84 | `Leia: Luke!`, 85 | `Leia: Luke, Luke, grab a hold of this.`, 86 | `Luke: Blast it, will you! My gun's jammed.`, 87 | `Han: Where?`, 88 | `Luke: Anywhere! Oh!!`, 89 | `Han: Luke! Luke!`, 90 | `Leia: Grab him!`, 91 | `Leia: What happened?`, 92 | `Luke: I don't know, it just let go of me and disappeared...`, 93 | `Han: I've got a very bad feeling about this.`, 94 | `Luke: The walls are moving!`, 95 | `Leia: Don't just stand there. Try to brace it with something.`, 96 | `Luke: Wait a minute!`, 97 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`, 98 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]})) 99 | -------------------------------------------------------------------------------- /src/exercise/04.js: -------------------------------------------------------------------------------- 1 | // useLayoutEffect: auto-scrolling textarea 2 | // http://localhost:3000/isolated/exercise/04.js 3 | 4 | import * as React from 'react' 5 | 6 | function MessagesDisplay({messages}) { 7 | const containerRef = React.useRef() 8 | // 🐨 replace useEffect with useLayoutEffect 9 | React.useEffect(() => { 10 | containerRef.current.scrollTop = containerRef.current.scrollHeight 11 | }) 12 | 13 | return ( 14 |
15 | {messages.map((message, index, array) => ( 16 |
17 | {message.author}: {message.content} 18 | {array.length - 1 === index ? null :
} 19 |
20 | ))} 21 |
22 | ) 23 | } 24 | 25 | // this is to simulate major computation/big rendering tree/etc. 26 | function sleep(time = 0) { 27 | const wakeUpTime = Date.now() + time 28 | while (Date.now() < wakeUpTime) {} 29 | } 30 | 31 | function SlooooowSibling() { 32 | // try this with useLayoutEffect as well to see 33 | // how it impacts interactivity of the page before updates. 34 | React.useEffect(() => { 35 | // increase this number to see a more stark difference 36 | sleep(300) 37 | }) 38 | return null 39 | } 40 | 41 | function App() { 42 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8)) 43 | const addMessage = () => 44 | messages.length < allMessages.length 45 | ? setMessages(allMessages.slice(0, messages.length + 1)) 46 | : null 47 | const removeMessage = () => 48 | messages.length > 0 49 | ? setMessages(allMessages.slice(0, messages.length - 1)) 50 | : null 51 | 52 | return ( 53 |
54 |
55 | 56 | 57 |
58 |
59 | 60 | 61 |
62 | ) 63 | } 64 | 65 | export default App 66 | 67 | const allMessages = [ 68 | `Leia: Aren't you a little short to be a stormtrooper?`, 69 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`, 70 | `Leia: You're who?`, 71 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`, 72 | `Leia: Ben Kenobi is here! Where is he?`, 73 | `Luke: Come on!`, 74 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`, 75 | `Leia: Put that thing away! You're going to get us all killed.`, 76 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`, 77 | `Leia: It could be worse...`, 78 | `Han: It's worse.`, 79 | `Luke: There's something alive in here!`, 80 | `Han: That's your imagination.`, 81 | `Luke: Something just moves past my leg! Look! Did you see that?`, 82 | `Han: What?`, 83 | `Luke: Help!`, 84 | `Han: Luke! Luke! Luke!`, 85 | `Leia: Luke!`, 86 | `Leia: Luke, Luke, grab a hold of this.`, 87 | `Luke: Blast it, will you! My gun's jammed.`, 88 | `Han: Where?`, 89 | `Luke: Anywhere! Oh!!`, 90 | `Han: Luke! Luke!`, 91 | `Leia: Grab him!`, 92 | `Leia: What happened?`, 93 | `Luke: I don't know, it just let go of me and disappeared...`, 94 | `Han: I've got a very bad feeling about this.`, 95 | `Luke: The walls are moving!`, 96 | `Leia: Don't just stand there. Try to brace it with something.`, 97 | `Luke: Wait a minute!`, 98 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`, 99 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]})) 100 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* For exercise 2 and 3, we're handling errors with an error boundary */ 2 | body[class*='2'] :not(.render-container) > iframe, 3 | body[class*='2'] > iframe, 4 | body[class*='3'] :not(.render-container) > iframe, 5 | body[class*='3'] > iframe { 6 | display: none; 7 | } 8 | 9 | .pokemon-info-app a { 10 | color: #cc0000; 11 | } 12 | 13 | .pokemon-info-app a:focus, 14 | .pokemon-info-app a:hover, 15 | .pokemon-info-app a:active { 16 | color: #8a0000; 17 | } 18 | 19 | .pokemon-info-app input { 20 | line-height: 2; 21 | font-size: 16px; 22 | box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); 23 | border: none; 24 | border-radius: 2px; 25 | padding-left: 10px; 26 | padding-right: 10px; 27 | background-color: #eee; 28 | } 29 | 30 | .pokemon-info-app button { 31 | font-size: 1rem; 32 | font-family: inherit; 33 | border: 1px solid #ff0000; 34 | background-color: #cc0000; 35 | cursor: pointer; 36 | padding: 8px 10px; 37 | color: #eee; 38 | border-radius: 3px; 39 | } 40 | 41 | .pokemon-info-app button:disabled { 42 | border-color: #dc9494; 43 | background-color: #f16161; 44 | cursor: unset; 45 | } 46 | 47 | .pokemon-info-app button:hover:not(:disabled), 48 | .pokemon-info-app button:active:not(:disabled), 49 | .pokemon-info-app button:focus:not(:disabled) { 50 | border-color: #cc0000; 51 | background-color: #8a0000; 52 | } 53 | 54 | .pokemon-info-app .totally-centered { 55 | width: 100%; 56 | height: 100%; 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | } 61 | 62 | .pokemon-info-app { 63 | max-width: 500px; 64 | margin: auto; 65 | } 66 | [class*='_isolated'] .pokemon-info-app { 67 | margin-top: 50px; 68 | } 69 | 70 | .pokemon-form { 71 | display: flex; 72 | flex-direction: column; 73 | align-items: center; 74 | } 75 | 76 | .pokemon-form input { 77 | margin-top: 10px; 78 | margin-right: 10px; 79 | } 80 | 81 | .pokemon-info { 82 | height: 400px; 83 | width: 300px; 84 | margin: auto; 85 | overflow: auto; 86 | background-color: #eee; 87 | border-radius: 4px; 88 | padding: 10px; 89 | position: relative; 90 | } 91 | 92 | .pokemon-info.pokemon-loading { 93 | opacity: 0.6; 94 | transition: opacity 0s; 95 | /* note: the transition delay is the same as the busyDelayMs config */ 96 | transition-delay: 0.4s; 97 | } 98 | 99 | .pokemon-info h2 { 100 | font-weight: bold; 101 | text-align: center; 102 | margin-top: 0.3em; 103 | } 104 | 105 | .pokemon-info img { 106 | max-width: 100%; 107 | max-height: 200px; 108 | } 109 | 110 | .pokemon-info .pokemon-info__img-wrapper { 111 | text-align: center; 112 | margin-top: 20px; 113 | } 114 | 115 | .pokemon-info .pokemon-info__fetch-time { 116 | position: absolute; 117 | top: 6px; 118 | right: 10px; 119 | } 120 | 121 | .pokemon-info-app button.invisible-button { 122 | border: none; 123 | padding: inherit; 124 | font-size: inherit; 125 | font-family: inherit; 126 | cursor: pointer; 127 | font-weight: inherit; 128 | background-color: transparent; 129 | color: #000; 130 | } 131 | .pokemon-info-app button.invisible-button:hover, 132 | .pokemon-info-app button.invisible-button:active, 133 | .pokemon-info-app button.invisible-button:focus { 134 | border: none; 135 | background-color: transparent; 136 | } 137 | 138 | .messaging-app { 139 | max-width: 350px; 140 | margin: auto; 141 | } 142 | 143 | [class*='_isolated'] .messaging-app { 144 | margin-top: 50px; 145 | } 146 | 147 | .messaging-app [role='log'] { 148 | margin: auto; 149 | height: 300px; 150 | overflow-y: scroll; 151 | width: 300px; 152 | outline: 1px solid black; 153 | padding: 30px 10px; 154 | } 155 | 156 | .messaging-app [role='log'] hr { 157 | margin-top: 8px; 158 | margin-bottom: 8px; 159 | } 160 | -------------------------------------------------------------------------------- /src/exercise/05.md: -------------------------------------------------------------------------------- 1 | # useImperativeHandle: scroll to top/bottom 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/05.md` 6 | 7 | ## Background 8 | 9 | When we had class components, we could do stuff like this: 10 | 11 | ```javascript 12 | class MyInput extends React.Component { 13 | _inputRef = React.createRef() 14 | focusInput = () => this._inputRef.current.focus() 15 | render() { 16 | return 17 | } 18 | } 19 | 20 | class App extends React.Component { 21 | _myInputRef = React.createRef() 22 | handleClick = () => this._myInputRef.current.focusInput() 23 | render() { 24 | return ( 25 |
26 | 27 | 28 |
29 | ) 30 | } 31 | } 32 | ``` 33 | 34 | The key I want to point out in the example here is that bit above that says: 35 | ``. What this does is give you access to the 36 | component instance. 37 | 38 | With function components, there is no component instance, so this won't work: 39 | 40 | ```javascript 41 | function MyInput() { 42 | const inputRef = React.useRef() 43 | const focusInput = () => inputRef.current.focus() 44 | // where do I put the focusInput method?? 45 | return 46 | } 47 | ``` 48 | 49 | You'll actually get an error if you try to pass a `ref` prop to a function 50 | component. So how do we solve this? Well, React has had this feature called 51 | `forwardRef` for quite a while. So we could do that: 52 | 53 | ```javascript 54 | const MyInput = React.forwardRef(function MyInput(props, ref) { 55 | const inputRef = React.useRef() 56 | ref.current = { 57 | focusInput: () => inputRef.current.focus(), 58 | } 59 | return 60 | }) 61 | ``` 62 | 63 | This actually works, however there are some edge case bugs with this approach 64 | when applied in React's future concurrent mode/suspense feature (also it doesn't 65 | support callback refs). So instead, we'll use the `useImperativeHandle` hook to 66 | do this: 67 | 68 | ```javascript 69 | const MyInput = React.forwardRef(function MyInput(props, ref) { 70 | const inputRef = React.useRef() 71 | React.useImperativeHandle(ref, () => { 72 | return { 73 | focusInput: () => inputRef.current.focus(), 74 | } 75 | }) 76 | return 77 | }) 78 | ``` 79 | 80 | This allows us to expose imperative methods to developers who pass a ref prop to 81 | our component which can be useful when you have something that needs to happen 82 | and is hard to deal with declaratively. 83 | 84 | > NOTE: most of the time you should not need `useImperativeHandle`. Before you 85 | > reach for it, really ask yourself whether there's ANY other way to accomplish 86 | > what you're trying to do. Imperative code can sometimes be really hard to 87 | > follow and it's much better to make your APIs declarative if possible. For 88 | > more on this, read 89 | > [Imperative vs Declarative Programming](https://tylermcginnis.com/imperative-vs-declarative-programming/) 90 | 91 | ## Exercise 92 | 93 | Production deploys: 94 | 95 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/05.js) 96 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/05.js) 97 | 98 | For this exercise, we're going to use the simulated chat from the last exercise, 99 | except we've added scroll to top and scroll to bottom buttons. Your job is to 100 | expose the imperative methods `scrollToTop` and `scrollToBottom` on a ref so the 101 | parent component can call those directly. 102 | 103 | ## 🦉 Feedback 104 | 105 | Fill out 106 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=05%3A%20useImperativeHandle%3A%20scroll%20to%20top%2Fbottom&em=nfurlan%40alley.confurlan%40alley.co). 107 | -------------------------------------------------------------------------------- /src/final/05.js: -------------------------------------------------------------------------------- 1 | // useImperativeHandle: scroll to top/bottom 2 | // http://localhost:3000/isolated/final/05.js 3 | 4 | import * as React from 'react' 5 | 6 | const MessagesDisplay = React.forwardRef(function MessagesDisplay( 7 | {messages}, 8 | ref, 9 | ) { 10 | const containerRef = React.useRef() 11 | React.useLayoutEffect(() => { 12 | scrollToBottom() 13 | }) 14 | function scrollToTop() { 15 | containerRef.current.scrollTop = 0 16 | } 17 | function scrollToBottom() { 18 | containerRef.current.scrollTop = containerRef.current.scrollHeight 19 | } 20 | React.useImperativeHandle(ref, () => ({ 21 | scrollToTop, 22 | scrollToBottom, 23 | })) 24 | 25 | return ( 26 |
27 | {messages.map((message, index, array) => ( 28 |
29 | {message.author}: {message.content} 30 | {array.length - 1 === index ? null :
} 31 |
32 | ))} 33 |
34 | ) 35 | }) 36 | 37 | function App() { 38 | const messageDisplayRef = React.useRef() 39 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8)) 40 | const addMessage = () => 41 | messages.length < allMessages.length 42 | ? setMessages(allMessages.slice(0, messages.length + 1)) 43 | : null 44 | const removeMessage = () => 45 | messages.length > 0 46 | ? setMessages(allMessages.slice(0, messages.length - 1)) 47 | : null 48 | 49 | const scrollToTop = () => messageDisplayRef.current.scrollToTop() 50 | const scrollToBottom = () => messageDisplayRef.current.scrollToBottom() 51 | 52 | return ( 53 |
54 |
55 | 56 | 57 |
58 |
59 |
60 | 61 |
62 | 63 |
64 | 65 |
66 |
67 | ) 68 | } 69 | 70 | export default App 71 | 72 | const allMessages = [ 73 | `Leia: Aren't you a little short to be a stormtrooper?`, 74 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`, 75 | `Leia: You're who?`, 76 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`, 77 | `Leia: Ben Kenobi is here! Where is he?`, 78 | `Luke: Come on!`, 79 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`, 80 | `Leia: Put that thing away! You're going to get us all killed.`, 81 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`, 82 | `Leia: It could be worse...`, 83 | `Han: It's worse.`, 84 | `Luke: There's something alive in here!`, 85 | `Han: That's your imagination.`, 86 | `Luke: Something just moves past my leg! Look! Did you see that?`, 87 | `Han: What?`, 88 | `Luke: Help!`, 89 | `Han: Luke! Luke! Luke!`, 90 | `Leia: Luke!`, 91 | `Leia: Luke, Luke, grab a hold of this.`, 92 | `Luke: Blast it, will you! My gun's jammed.`, 93 | `Han: Where?`, 94 | `Luke: Anywhere! Oh!!`, 95 | `Han: Luke! Luke!`, 96 | `Leia: Grab him!`, 97 | `Leia: What happened?`, 98 | `Luke: I don't know, it just let go of me and disappeared...`, 99 | `Han: I've got a very bad feeling about this.`, 100 | `Luke: The walls are moving!`, 101 | `Leia: Don't just stand there. Try to brace it with something.`, 102 | `Luke: Wait a minute!`, 103 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`, 104 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]})) 105 | -------------------------------------------------------------------------------- /src/exercise/05.js: -------------------------------------------------------------------------------- 1 | // useImperativeHandle: scroll to top/bottom 2 | // http://localhost:3000/isolated/exercise/05.js 3 | 4 | import * as React from 'react' 5 | 6 | // 🐨 wrap this in a React.forwardRef and accept `ref` as the second argument 7 | function MessagesDisplay({messages}) { 8 | const containerRef = React.useRef() 9 | React.useLayoutEffect(() => { 10 | scrollToBottom() 11 | }) 12 | 13 | // 💰 you're gonna want this as part of your imperative methods 14 | // function scrollToTop() { 15 | // containerRef.current.scrollTop = 0 16 | // } 17 | function scrollToBottom() { 18 | containerRef.current.scrollTop = containerRef.current.scrollHeight 19 | } 20 | 21 | // 🐨 call useImperativeHandle here with your ref and a callback function 22 | // that returns an object with scrollToTop and scrollToBottom 23 | 24 | return ( 25 |
26 | {messages.map((message, index, array) => ( 27 |
28 | {message.author}: {message.content} 29 | {array.length - 1 === index ? null :
} 30 |
31 | ))} 32 |
33 | ) 34 | } 35 | 36 | function App() { 37 | const messageDisplayRef = React.useRef() 38 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8)) 39 | const addMessage = () => 40 | messages.length < allMessages.length 41 | ? setMessages(allMessages.slice(0, messages.length + 1)) 42 | : null 43 | const removeMessage = () => 44 | messages.length > 0 45 | ? setMessages(allMessages.slice(0, messages.length - 1)) 46 | : null 47 | 48 | const scrollToTop = () => messageDisplayRef.current.scrollToTop() 49 | const scrollToBottom = () => messageDisplayRef.current.scrollToBottom() 50 | 51 | return ( 52 |
53 |
54 | 55 | 56 |
57 |
58 |
59 | 60 |
61 | 62 |
63 | 64 |
65 |
66 | ) 67 | } 68 | 69 | export default App 70 | 71 | const allMessages = [ 72 | `Leia: Aren't you a little short to be a stormtrooper?`, 73 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`, 74 | `Leia: You're who?`, 75 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`, 76 | `Leia: Ben Kenobi is here! Where is he?`, 77 | `Luke: Come on!`, 78 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`, 79 | `Leia: Put that thing away! You're going to get us all killed.`, 80 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`, 81 | `Leia: It could be worse...`, 82 | `Han: It's worse.`, 83 | `Luke: There's something alive in here!`, 84 | `Han: That's your imagination.`, 85 | `Luke: Something just moves past my leg! Look! Did you see that?`, 86 | `Han: What?`, 87 | `Luke: Help!`, 88 | `Han: Luke! Luke! Luke!`, 89 | `Leia: Luke!`, 90 | `Leia: Luke, Luke, grab a hold of this.`, 91 | `Luke: Blast it, will you! My gun's jammed.`, 92 | `Han: Where?`, 93 | `Luke: Anywhere! Oh!!`, 94 | `Han: Luke! Luke!`, 95 | `Leia: Grab him!`, 96 | `Leia: What happened?`, 97 | `Luke: I don't know, it just let go of me and disappeared...`, 98 | `Han: I've got a very bad feeling about this.`, 99 | `Luke: The walls are moving!`, 100 | `Leia: Don't just stand there. Try to brace it with something.`, 101 | `Luke: Wait a minute!`, 102 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`, 103 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]})) 104 | -------------------------------------------------------------------------------- /src/final/03.extra-2.js: -------------------------------------------------------------------------------- 1 | // useContext: Caching response data in context 2 | // 💯 caching in a context provider (final) 3 | // http://localhost:3000/isolated/final/03.extra-2.js 4 | 5 | // you can edit this here and look at the isolated page or you can copy/paste 6 | // this in the regular exercise file. 7 | 8 | import * as React from 'react' 9 | import {useAsync} from '../utils' 10 | import { 11 | fetchPokemon, 12 | PokemonForm, 13 | PokemonDataView, 14 | PokemonInfoFallback, 15 | PokemonErrorBoundary, 16 | } from '../pokemon' 17 | 18 | const PokemonCacheContext = React.createContext() 19 | 20 | function pokemonCacheReducer(state, action) { 21 | switch (action.type) { 22 | case 'ADD_POKEMON': { 23 | return {...state, [action.pokemonName]: action.pokemonData} 24 | } 25 | default: { 26 | throw new Error(`Unhandled action type: ${action.type}`) 27 | } 28 | } 29 | } 30 | 31 | function PokemonCacheProvider(props) { 32 | const [cache, dispatch] = React.useReducer(pokemonCacheReducer, {}) 33 | return 34 | } 35 | 36 | function usePokemonCache() { 37 | const context = React.useContext(PokemonCacheContext) 38 | if (!context) { 39 | throw new Error( 40 | 'usePokemonCache must be used within a PokemonCacheProvider', 41 | ) 42 | } 43 | return context 44 | } 45 | 46 | function PokemonInfo({pokemonName}) { 47 | const [cache, dispatch] = usePokemonCache() 48 | 49 | const {data: pokemon, status, error, run, setData} = useAsync({ 50 | status: pokemonName ? 'pending' : 'idle', 51 | }) 52 | 53 | React.useEffect(() => { 54 | if (!pokemonName) { 55 | return 56 | } else if (cache[pokemonName]) { 57 | setData(cache[pokemonName]) 58 | } else { 59 | run( 60 | fetchPokemon(pokemonName).then(pokemonData => { 61 | dispatch({type: 'ADD_POKEMON', pokemonName, pokemonData}) 62 | return pokemonData 63 | }), 64 | ) 65 | } 66 | }, [cache, dispatch, pokemonName, run, setData]) 67 | 68 | if (status === 'idle') { 69 | return 'Submit a pokemon' 70 | } else if (status === 'pending') { 71 | return 72 | } else if (status === 'rejected') { 73 | throw error 74 | } else if (status === 'resolved') { 75 | return 76 | } 77 | 78 | throw new Error('This should be impossible') 79 | } 80 | 81 | function PreviousPokemon({onSelect}) { 82 | const [cache] = usePokemonCache() 83 | return ( 84 |
85 | Previous Pokemon 86 |
    87 | {Object.keys(cache).map(pokemonName => ( 88 |
  • 89 | 95 |
  • 96 | ))} 97 |
98 |
99 | ) 100 | } 101 | 102 | function PokemonSection({onSelect, pokemonName}) { 103 | return ( 104 | 105 |
106 | 107 |
108 | onSelect('')} 110 | resetKeys={[pokemonName]} 111 | > 112 | 113 | 114 |
115 |
116 |
117 | ) 118 | } 119 | 120 | function App() { 121 | const [pokemonName, setPokemonName] = React.useState(null) 122 | 123 | function handleSubmit(newPokemonName) { 124 | setPokemonName(newPokemonName) 125 | } 126 | 127 | function handleSelect(newPokemonName) { 128 | setPokemonName(newPokemonName) 129 | } 130 | 131 | return ( 132 |
133 | 134 |
135 | 136 |
137 | ) 138 | } 139 | 140 | export default App 141 | -------------------------------------------------------------------------------- /src/exercise/03.extra-2.js: -------------------------------------------------------------------------------- 1 | // useContext: Caching response data in context 2 | // 💯 caching in a context provider (exercise) 3 | // http://localhost:3000/isolated/exercise/03.extra-2.js 4 | 5 | // you can edit this here and look at the isolated page or you can copy/paste 6 | // this in the regular exercise file. 7 | 8 | import * as React from 'react' 9 | import { 10 | fetchPokemon, 11 | PokemonForm, 12 | PokemonDataView, 13 | PokemonInfoFallback, 14 | PokemonErrorBoundary, 15 | } from '../pokemon' 16 | import {useAsync} from '../utils' 17 | 18 | // 🐨 Create a PokemonCacheContext 19 | 20 | // 🐨 create a PokemonCacheProvider function 21 | // 🐨 useReducer with pokemonCacheReducer in your PokemonCacheProvider 22 | // 💰 you can grab the one that's in PokemonInfo 23 | // 🐨 return your context provider with the value assigned to what you get back from useReducer 24 | // 💰 value={[cache, dispatch]} 25 | // 💰 make sure you forward the props.children! 26 | 27 | function pokemonCacheReducer(state, action) { 28 | switch (action.type) { 29 | case 'ADD_POKEMON': { 30 | return {...state, [action.pokemonName]: action.pokemonData} 31 | } 32 | default: { 33 | throw new Error(`Unhandled action type: ${action.type}`) 34 | } 35 | } 36 | } 37 | 38 | function PokemonInfo({pokemonName}) { 39 | // 💣 remove the useReducer here (or move it up to your PokemonCacheProvider) 40 | const [cache, dispatch] = React.useReducer(pokemonCacheReducer, {}) 41 | // 🐨 get the cache and dispatch from useContext with PokemonCacheContext 42 | 43 | const {data: pokemon, status, error, run, setData} = useAsync() 44 | 45 | React.useEffect(() => { 46 | if (!pokemonName) { 47 | return 48 | } else if (cache[pokemonName]) { 49 | setData(cache[pokemonName]) 50 | } else { 51 | run( 52 | fetchPokemon(pokemonName).then(pokemonData => { 53 | dispatch({type: 'ADD_POKEMON', pokemonName, pokemonData}) 54 | return pokemonData 55 | }), 56 | ) 57 | } 58 | }, [cache, pokemonName, run, setData]) 59 | 60 | if (status === 'idle') { 61 | return 'Submit a pokemon' 62 | } else if (status === 'pending') { 63 | return 64 | } else if (status === 'rejected') { 65 | throw error 66 | } else if (status === 'resolved') { 67 | return 68 | } 69 | } 70 | 71 | function PreviousPokemon({onSelect}) { 72 | // 🐨 get the cache from useContext with PokemonCacheContext 73 | const cache = {} 74 | return ( 75 |
76 | Previous Pokemon 77 |
    78 | {Object.keys(cache).map(pokemonName => ( 79 |
  • 80 | 86 |
  • 87 | ))} 88 |
89 |
90 | ) 91 | } 92 | 93 | function PokemonSection({onSelect, pokemonName}) { 94 | // 🐨 wrap this in the PokemonCacheProvider so the PreviousPokemon 95 | // and PokemonInfo components have access to that context. 96 | return ( 97 |
98 | 99 |
100 | onSelect('')} 102 | resetKeys={[pokemonName]} 103 | > 104 | 105 | 106 |
107 |
108 | ) 109 | } 110 | 111 | function App() { 112 | const [pokemonName, setPokemonName] = React.useState(null) 113 | 114 | function handleSubmit(newPokemonName) { 115 | setPokemonName(newPokemonName) 116 | } 117 | 118 | function handleSelect(newPokemonName) { 119 | setPokemonName(newPokemonName) 120 | } 121 | 122 | return ( 123 |
124 | 125 |
126 | 127 |
128 | ) 129 | } 130 | 131 | export default App 132 | -------------------------------------------------------------------------------- /src/exercise/02.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // http://localhost:3000/isolated/exercise/02.js 3 | 4 | import * as React from 'react' 5 | import { 6 | fetchPokemon, 7 | PokemonForm, 8 | PokemonDataView, 9 | PokemonInfoFallback, 10 | PokemonErrorBoundary, 11 | } from '../pokemon' 12 | 13 | // 🐨 this is going to be our generic asyncReducer 14 | function pokemonInfoReducer(state, action) { 15 | switch (action.type) { 16 | case 'pending': { 17 | // 🐨 replace "pokemon" with "data" 18 | return {status: 'pending', pokemon: null, error: null} 19 | } 20 | case 'resolved': { 21 | // 🐨 replace "pokemon" with "data" (in the action too!) 22 | return {status: 'resolved', pokemon: action.pokemon, error: null} 23 | } 24 | case 'rejected': { 25 | // 🐨 replace "pokemon" with "data" 26 | return {status: 'rejected', pokemon: null, error: action.error} 27 | } 28 | default: { 29 | throw new Error(`Unhandled action type: ${action.type}`) 30 | } 31 | } 32 | } 33 | 34 | function PokemonInfo({pokemonName}) { 35 | // 🐨 move both the useReducer and useEffect hooks to a custom hook called useAsync 36 | // here's how you use it: 37 | // const state = useAsync( 38 | // () => { 39 | // if (!pokemonName) { 40 | // return 41 | // } 42 | // return fetchPokemon(pokemonName) 43 | // }, 44 | // {status: pokemonName ? 'pending' : 'idle'}, 45 | // [pokemonName], 46 | // ) 47 | // 🐨 so your job is to create a useAsync function that makes this work. 48 | const [state, dispatch] = React.useReducer(pokemonInfoReducer, { 49 | status: pokemonName ? 'pending' : 'idle', 50 | // 🐨 this will need to be "data" instead of "pokemon" 51 | pokemon: null, 52 | error: null, 53 | }) 54 | 55 | React.useEffect(() => { 56 | // 💰 this first early-exit bit is a little tricky, so let me give you a hint: 57 | // const promise = asyncCallback() 58 | // if (!promise) { 59 | // return 60 | // } 61 | // then you can dispatch and handle the promise etc... 62 | if (!pokemonName) { 63 | return 64 | } 65 | dispatch({type: 'pending'}) 66 | fetchPokemon(pokemonName).then( 67 | pokemon => { 68 | dispatch({type: 'resolved', pokemon}) 69 | }, 70 | error => { 71 | dispatch({type: 'rejected', error}) 72 | }, 73 | ) 74 | // 🐨 you'll accept dependencies as an array and pass that here. 75 | // 🐨 because of limitations with ESLint, you'll need to ignore 76 | // the react-hooks/exhaustive-deps rule. We'll fix this in an extra credit. 77 | }, [pokemonName]) 78 | 79 | // 🐨 this will change from "pokemon" to "data" 80 | const {pokemon, status, error} = state 81 | 82 | if (status === 'idle' || !pokemonName) { 83 | return 'Submit a pokemon' 84 | } else if (status === 'pending') { 85 | return 86 | } else if (status === 'rejected') { 87 | throw error 88 | } else if (status === 'resolved') { 89 | return 90 | } 91 | 92 | throw new Error('This should be impossible') 93 | } 94 | 95 | function App() { 96 | const [pokemonName, setPokemonName] = React.useState('') 97 | 98 | function handleSubmit(newPokemonName) { 99 | setPokemonName(newPokemonName) 100 | } 101 | 102 | function handleReset() { 103 | setPokemonName('') 104 | } 105 | 106 | return ( 107 |
108 | 109 |
110 |
111 | 112 | 113 | 114 |
115 |
116 | ) 117 | } 118 | 119 | function AppWithUnmountCheckbox() { 120 | const [mountApp, setMountApp] = React.useState(true) 121 | return ( 122 |
123 | 131 |
132 | {mountApp ? : null} 133 |
134 | ) 135 | } 136 | 137 | export default AppWithUnmountCheckbox 138 | -------------------------------------------------------------------------------- /src/final/02.extra-3.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // 💯 make safeDispatch with useCallback, useRef, and useEffect 3 | // http://localhost:3000/isolated/final/02.extra-3.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonForm, 9 | PokemonDataView, 10 | PokemonInfoFallback, 11 | PokemonErrorBoundary, 12 | } from '../pokemon' 13 | 14 | function useSafeDispatch(dispatch) { 15 | const mountedRef = React.useRef(false) 16 | 17 | // to make this even more generic you should use the useLayoutEffect hook to 18 | // make sure that you are correctly setting the mountedRef.current immediately 19 | // after React updates the DOM. Even though this effect does not interact 20 | // with the dom another side effect inside a useLayoutEffect which does 21 | // interact with the dom may depend on the value being set 22 | React.useEffect(() => { 23 | mountedRef.current = true 24 | return () => { 25 | mountedRef.current = false 26 | } 27 | }, []) 28 | 29 | return React.useCallback( 30 | (...args) => (mountedRef.current ? dispatch(...args) : void 0), 31 | [dispatch], 32 | ) 33 | } 34 | 35 | function asyncReducer(state, action) { 36 | switch (action.type) { 37 | case 'pending': { 38 | return {status: 'pending', data: null, error: null} 39 | } 40 | case 'resolved': { 41 | return {status: 'resolved', data: action.data, error: null} 42 | } 43 | case 'rejected': { 44 | return {status: 'rejected', data: null, error: action.error} 45 | } 46 | default: { 47 | throw new Error(`Unhandled action type: ${action.type}`) 48 | } 49 | } 50 | } 51 | 52 | function useAsync(initialState) { 53 | const [state, unsafeDispatch] = React.useReducer(asyncReducer, { 54 | status: 'idle', 55 | data: null, 56 | error: null, 57 | ...initialState, 58 | }) 59 | 60 | const dispatch = useSafeDispatch(unsafeDispatch) 61 | 62 | const {data, error, status} = state 63 | 64 | const run = React.useCallback( 65 | promise => { 66 | dispatch({type: 'pending'}) 67 | promise.then( 68 | data => { 69 | dispatch({type: 'resolved', data}) 70 | }, 71 | error => { 72 | dispatch({type: 'rejected', error}) 73 | }, 74 | ) 75 | }, 76 | [dispatch], 77 | ) 78 | 79 | return { 80 | error, 81 | status, 82 | data, 83 | run, 84 | } 85 | } 86 | 87 | function PokemonInfo({pokemonName}) { 88 | const {data: pokemon, status, error, run} = useAsync({ 89 | status: pokemonName ? 'pending' : 'idle', 90 | }) 91 | 92 | React.useEffect(() => { 93 | if (!pokemonName) { 94 | return 95 | } 96 | run(fetchPokemon(pokemonName)) 97 | }, [pokemonName, run]) 98 | 99 | if (status === 'idle') { 100 | return 'Submit a pokemon' 101 | } else if (status === 'pending') { 102 | return 103 | } else if (status === 'rejected') { 104 | throw error 105 | } else if (status === 'resolved') { 106 | return 107 | } 108 | 109 | throw new Error('This should be impossible') 110 | } 111 | 112 | function App() { 113 | const [pokemonName, setPokemonName] = React.useState('') 114 | 115 | function handleSubmit(newPokemonName) { 116 | setPokemonName(newPokemonName) 117 | } 118 | 119 | function handleReset() { 120 | setPokemonName('') 121 | } 122 | 123 | return ( 124 |
125 | 126 |
127 |
128 | 129 | 130 | 131 |
132 |
133 | ) 134 | } 135 | 136 | function AppWithUnmountCheckbox() { 137 | const [mountApp, setMountApp] = React.useState(true) 138 | return ( 139 |
140 | 148 |
149 | {mountApp ? : null} 150 |
151 | ) 152 | } 153 | 154 | export default AppWithUnmountCheckbox 155 | -------------------------------------------------------------------------------- /src/backend.js: -------------------------------------------------------------------------------- 1 | import {graphql} from '@kentcdodds/react-workshop-app/server' 2 | 3 | const pokemonApi = graphql.link('https://graphql-pokemon2.vercel.app') 4 | 5 | export const handlers = [ 6 | pokemonApi.query('PokemonInfo', (req, res, ctx) => { 7 | const pokemon = allPokemon[req.variables.name.toLowerCase()] 8 | if (pokemon) { 9 | return res(ctx.status(200), ctx.data({pokemon})) 10 | } else { 11 | const pokemonNames = Object.keys(allPokemon) 12 | const randomName = 13 | pokemonNames[Math.floor(pokemonNames.length * Math.random())] 14 | return res( 15 | ctx.status(404), 16 | ctx.data({ 17 | errors: [ 18 | { 19 | message: `Unsupported pokemon: "${req.variables.name}". Try "${randomName}"`, 20 | }, 21 | ], 22 | }), 23 | ) 24 | } 25 | }), 26 | ] 27 | 28 | const allPokemon = { 29 | pikachu: { 30 | id: 'UG9rZW1vbjowMjU=', 31 | number: '025', 32 | name: 'Pikachu', 33 | image: '/img/pokemon/pikachu.jpg', 34 | attacks: { 35 | special: [ 36 | { 37 | name: 'Discharge', 38 | type: 'Electric', 39 | damage: 35, 40 | }, 41 | { 42 | name: 'Thunder', 43 | type: 'Electric', 44 | damage: 100, 45 | }, 46 | { 47 | name: 'Thunderbolt', 48 | type: 'Electric', 49 | damage: 55, 50 | }, 51 | ], 52 | }, 53 | }, 54 | mew: { 55 | id: 'UG9rZW1vbjoxNTE=', 56 | number: '151', 57 | image: '/img/pokemon/mew.jpg', 58 | name: 'Mew', 59 | attacks: { 60 | special: [ 61 | { 62 | name: 'Dragon Pulse', 63 | type: 'Dragon', 64 | damage: 65, 65 | }, 66 | { 67 | name: 'Earthquake', 68 | type: 'Ground', 69 | damage: 100, 70 | }, 71 | { 72 | name: 'Fire Blast', 73 | type: 'Fire', 74 | damage: 100, 75 | }, 76 | { 77 | name: 'Hurricane', 78 | type: 'Flying', 79 | damage: 80, 80 | }, 81 | { 82 | name: 'Hyper Beam', 83 | type: 'Normal', 84 | damage: 120, 85 | }, 86 | { 87 | name: 'Moonblast', 88 | type: 'Fairy', 89 | damage: 85, 90 | }, 91 | { 92 | name: 'Psychic', 93 | type: 'Psychic', 94 | damage: 55, 95 | }, 96 | { 97 | name: 'Solar Beam', 98 | type: 'Grass', 99 | damage: 120, 100 | }, 101 | { 102 | name: 'Thunder', 103 | type: 'Electric', 104 | damage: 100, 105 | }, 106 | ], 107 | }, 108 | }, 109 | mewtwo: { 110 | id: 'UG9rZW1vbjoxNTA=', 111 | number: '150', 112 | image: '/img/pokemon/mewtwo.jpg', 113 | name: 'Mewtwo', 114 | attacks: { 115 | special: [ 116 | { 117 | name: 'Hyper Beam', 118 | type: 'Normal', 119 | damage: 120, 120 | }, 121 | { 122 | name: 'Psychic', 123 | type: 'Psychic', 124 | damage: 55, 125 | }, 126 | { 127 | name: 'Shadow Ball', 128 | type: 'Ghost', 129 | damage: 45, 130 | }, 131 | ], 132 | }, 133 | }, 134 | ditto: { 135 | id: 'UG9rZW1vbjoxMzI=', 136 | number: '132', 137 | image: '/img/pokemon/ditto.jpg', 138 | name: 'Ditto', 139 | attacks: { 140 | special: [ 141 | { 142 | name: 'Struggle', 143 | type: 'Normal', 144 | damage: 15, 145 | }, 146 | ], 147 | }, 148 | }, 149 | charizard: { 150 | id: 'UG9rZW1vbjowMDY=', 151 | number: '006', 152 | name: 'Charizard', 153 | image: '/img/pokemon/charizard.jpg', 154 | attacks: { 155 | special: [ 156 | { 157 | name: 'Dragon Claw', 158 | type: 'Dragon', 159 | damage: 35, 160 | }, 161 | { 162 | name: 'Fire Blast', 163 | type: 'Fire', 164 | damage: 100, 165 | }, 166 | { 167 | name: 'Flamethrower', 168 | type: 'Fire', 169 | damage: 55, 170 | }, 171 | ], 172 | }, 173 | }, 174 | bulbasaur: { 175 | id: 'UG9rZW1vbjowMDE=', 176 | number: '001', 177 | name: 'Bulbasaur', 178 | image: '/img/pokemon/bulbasaur.jpg', 179 | attacks: { 180 | special: [ 181 | { 182 | name: 'Power Whip', 183 | type: 'Grass', 184 | damage: 70, 185 | }, 186 | { 187 | name: 'Seed Bomb', 188 | type: 'Grass', 189 | damage: 40, 190 | }, 191 | { 192 | name: 'Sludge Bomb', 193 | type: 'Poison', 194 | damage: 55, 195 | }, 196 | ], 197 | }, 198 | }, 199 | } 200 | -------------------------------------------------------------------------------- /src/exercise/03.md: -------------------------------------------------------------------------------- 1 | # useContext: simple Counter 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/03.md` 6 | 7 | ## Background 8 | 9 | Sharing state between components is a common problem. The best solution for this 10 | is to 📜 [lift your state](https://reactjs.org/docs/lifting-state-up.html). This 11 | requires 📜 [prop drilling](https://kentcdodds.com/blog/prop-drilling) which is 12 | not a problem, but there are some times where prop drilling can cause a real 13 | pain. 14 | 15 | To avoid this pain, we can insert some state into a section of our react tree, 16 | and then extract that state anywhere within that react tree without having to 17 | explicitly pass it everywhere. This feature is called `context`. In some ways 18 | it's like global variables, but it doesn't suffer from the same problems (and 19 | maintainability nightmares) of global variables thanks to how the API works to 20 | make the relationships explicit. 21 | 22 | Here's how you use context: 23 | 24 | ```javascript 25 | import * as React from 'react' 26 | 27 | const FooContext = React.createContext() 28 | 29 | function FooDisplay() { 30 | const foo = React.useContext(FooContext) 31 | return
Foo is: {foo}
32 | } 33 | 34 | ReactDOM.render( 35 | 36 | 37 | , 38 | document.getElementById('root'), 39 | ) 40 | // renders
Foo is: I am foo
41 | ``` 42 | 43 | `` could appear anywhere in the render tree, and it will have 44 | access to the `value` which is passed by the `FooContext.Provider` component. 45 | 46 | Note that as a first argument to `createContext`, you can provide a default 47 | value which React will use in the event someone calls `useContext` with your 48 | context, when no value has been provided: 49 | 50 | ```javascript 51 | ReactDOM.render(, document.getElementById('root')) 52 | ``` 53 | 54 | Most of the time, I don't recommend using a default value because it's probably 55 | a mistake to try and use context outside a provider, so in our exercise I'll 56 | show you how to avoid that from happening. 57 | 58 | 🦉 Keep in mind that while context makes sharing state easy, it's not the only 59 | solution to Prop Drilling pains and it's not necessarily the best solution 60 | either. React's composition model is powerful and can be used to avoid issues 61 | with prop drilling as well. Learn more about this from 62 | [Michael Jackson on Twitter](https://twitter.com/mjackson/status/1195495535483817984) 63 | 64 | ## Exercise 65 | 66 | Production deploys: 67 | 68 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/03.js) 69 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/03.js) 70 | 71 | We're putting everything in one file to keep things simple, but I've labeled 72 | things a bit so you know that typically your context provider will be placed in 73 | a different file and expose the provider component itself as well as the custom 74 | hook to access the context value. 75 | 76 | We're going to take the Count component that we had before and separate the 77 | button from the count display. We need to access both the `count` state as well 78 | as the `setCount` updater in these different components which live in different 79 | parts of the tree. Normally lifting state up would be the way to solve this 80 | trivial problem, but this is a contrived example so you can focus on learning 81 | how to use context. 82 | 83 | Your job is to fill in the `CountProvider` function component so that the app 84 | works and the tests pass. 85 | 86 | ## Extra Credit 87 | 88 | ### 1. 💯 create a consumer hook 89 | 90 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/03.extra-1.js) 91 | 92 | Imagine what would happen if someone tried to consume your context value without 93 | using your context provider. For example, as mentioned above when discussing the 94 | default value: 95 | 96 | ```javascript 97 | ReactDOM.render(, document.getElementById('root')) 98 | ``` 99 | 100 | If you don't provide a default context value, that would render 101 | `
Foo is:
`. This is because the context value would be `undefined`. 102 | In real-world scenarios, having an unexpected `undefined` value can result in 103 | errors that can be difficult to debug. 104 | 105 | In this extra credit, you need to create a custom hook that I can use like this: 106 | 107 | ```javascript 108 | const [count, setCount] = useCount() 109 | ``` 110 | 111 | And if you change the `App` to this: 112 | 113 | ```javascript 114 | function App() { 115 | return ( 116 |
117 | 118 | 119 |
120 | ) 121 | } 122 | ``` 123 | 124 | It should throw an error indicating that `useCount` must be used within a 125 | CountProvider. 126 | 127 | ### 2. 💯 caching in a context provider 128 | 129 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/03.extra-2.js) 130 | 131 | Let's try the last exercise over again with a bit more of a complex/practical 132 | example. That's right! We're back to the Pokemon info app! This time it has 133 | caching in place which is cool. So if you enter the same pokemon information, 134 | it's cached so it loads instantaneously. 135 | 136 | However, we have a requirement that we want to list all the cached pokemon in 137 | another part of the app, so we're going to use context to store the cache. This 138 | way both parts of the app which need access to the pokemon cache will have 139 | access. 140 | 141 | Because this is hard to describe in words (and because it's a completely 142 | separate example), there's a starting point for you in 143 | `./src/exercise/03.extra-2.js`. 144 | 145 | ## 🦉 Other notes 146 | 147 | `Context` also has the unique ability to be scoped to a specific section of the 148 | React component tree. A common mistake of context (and generally any 149 | "application" state) is to make it globally available anywhere in your 150 | application when it's actually only needed to be available in a part of the app 151 | (like a single page). Keeping a context value scoped to the area that needs it 152 | most has improved performance and maintainability characteristics. 153 | 154 | ## 🦉 Feedback 155 | 156 | Fill out 157 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=03%3A%20useContext%3A%20simple%20Counter&em=nfurlan%40alley.confurlan%40alley.co). 158 | -------------------------------------------------------------------------------- /src/pokemon.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {ErrorBoundary} from 'react-error-boundary' 3 | 4 | const formatDate = date => 5 | `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')} ${String( 6 | date.getSeconds(), 7 | ).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}` 8 | 9 | // the delay argument is for faking things out a bit 10 | function fetchPokemon(name, delay = 1500) { 11 | const pokemonQuery = ` 12 | query PokemonInfo($name: String) { 13 | pokemon(name: $name) { 14 | id 15 | number 16 | name 17 | image 18 | attacks { 19 | special { 20 | name 21 | type 22 | damage 23 | } 24 | } 25 | } 26 | } 27 | ` 28 | 29 | return window 30 | .fetch('https://graphql-pokemon2.vercel.app/', { 31 | // learn more about this API here: https://graphql-pokemon2.vercel.app/ 32 | method: 'POST', 33 | headers: { 34 | 'content-type': 'application/json;charset=UTF-8', 35 | delay: delay, 36 | }, 37 | body: JSON.stringify({ 38 | query: pokemonQuery, 39 | variables: {name: name.toLowerCase()}, 40 | }), 41 | }) 42 | .then(async response => { 43 | const {data} = await response.json() 44 | if (response.ok) { 45 | const pokemon = data?.pokemon 46 | if (pokemon) { 47 | pokemon.fetchedAt = formatDate(new Date()) 48 | return pokemon 49 | } else { 50 | return Promise.reject(new Error(`No pokemon with the name "${name}"`)) 51 | } 52 | } else { 53 | // handle the graphql errors 54 | const error = { 55 | message: data?.errors?.map(e => e.message).join('\n'), 56 | } 57 | return Promise.reject(error) 58 | } 59 | }) 60 | } 61 | 62 | function PokemonInfoFallback({name}) { 63 | const initialName = React.useRef(name).current 64 | const fallbackPokemonData = { 65 | name: initialName, 66 | number: 'XXX', 67 | image: '/img/pokemon/fallback-pokemon.jpg', 68 | attacks: { 69 | special: [ 70 | {name: 'Loading Attack 1', type: 'Type', damage: 'XX'}, 71 | {name: 'Loading Attack 2', type: 'Type', damage: 'XX'}, 72 | ], 73 | }, 74 | fetchedAt: 'loading...', 75 | } 76 | return 77 | } 78 | 79 | function PokemonDataView({pokemon}) { 80 | return ( 81 |
82 |
83 | {pokemon.name} 84 |
85 |
86 |

87 | {pokemon.name} 88 | {pokemon.number} 89 |

90 |
91 |
92 |
    93 | {pokemon.attacks.special.map(attack => ( 94 |
  • 95 | :{' '} 96 | 97 | {attack.damage} ({attack.type}) 98 | 99 |
  • 100 | ))} 101 |
102 |
103 | {pokemon.fetchedAt} 104 |
105 | ) 106 | } 107 | 108 | function PokemonForm({ 109 | pokemonName: externalPokemonName, 110 | initialPokemonName = externalPokemonName || '', 111 | onSubmit, 112 | }) { 113 | const [pokemonName, setPokemonName] = React.useState(initialPokemonName) 114 | 115 | // this is generally not a great idea. We're synchronizing state when it is 116 | // normally better to derive it https://kentcdodds.com/blog/dont-sync-state-derive-it 117 | // however, we're doing things this way to make it easier for the exercises 118 | // to not have to worry about the logic for this PokemonForm component. 119 | React.useEffect(() => { 120 | // note that because it's a string value, if the externalPokemonName 121 | // is the same as the one we're managing, this will not trigger a re-render 122 | if (typeof externalPokemonName === 'string') { 123 | setPokemonName(externalPokemonName) 124 | } 125 | }, [externalPokemonName]) 126 | 127 | function handleChange(e) { 128 | setPokemonName(e.target.value) 129 | } 130 | 131 | function handleSubmit(e) { 132 | e.preventDefault() 133 | onSubmit(pokemonName) 134 | } 135 | 136 | function handleSelect(newPokemonName) { 137 | setPokemonName(newPokemonName) 138 | onSubmit(newPokemonName) 139 | } 140 | 141 | return ( 142 |
143 | 144 | 145 | Try{' '} 146 | 153 | {', '} 154 | 161 | {', or '} 162 | 169 | 170 |
171 | 179 | 182 |
183 |
184 | ) 185 | } 186 | 187 | function ErrorFallback({error, resetErrorBoundary}) { 188 | return ( 189 |
190 | There was an error:{' '} 191 |
{error.message}
192 | 193 |
194 | ) 195 | } 196 | 197 | function PokemonErrorBoundary(props) { 198 | return 199 | } 200 | 201 | export { 202 | PokemonInfoFallback, 203 | PokemonForm, 204 | PokemonDataView, 205 | fetchPokemon, 206 | PokemonErrorBoundary, 207 | } 208 | -------------------------------------------------------------------------------- /src/exercise/01.md: -------------------------------------------------------------------------------- 1 | # useReducer: simple Counter 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/01.md` 6 | 7 | ## Background 8 | 9 | React's `useState` hook can get you a really long way with React state 10 | management. That said, sometimes you want to separate the state logic from the 11 | components that make the state changes. In addition, if you have multiple 12 | elements of state that typically change together, then having an object that 13 | contains those elements of state can be quite helpful. 14 | 15 | This is where `useReducer` comes in really handy. If you're familiar with redux, 16 | then you'll feel pretty comfortable here. If not, then you have less to unlearn 17 | 😉 18 | 19 | This exercise will take you pretty deep into `useReducer`. Typically, you'll use 20 | `useReducer` with an object of state, but we're going to start by managing a 21 | single number (a `count`). We're doing this to ease you into `useReducer` and 22 | help you learn the difference between the convention and the actual API. 23 | 24 | Here's an example of using `useReducer` to manage the value of a name in an 25 | input. 26 | 27 | ```javascript 28 | function nameReducer(previousName, newName) { 29 | return newName 30 | } 31 | 32 | const initialNameValue = 'Joe' 33 | 34 | function NameInput() { 35 | const [name, setName] = React.useReducer(nameReducer, initialNameValue) 36 | const handleChange = event => setName(event.target.value) 37 | return ( 38 | <> 39 | 42 |
You typed: {name}
43 | 44 | ) 45 | } 46 | ``` 47 | 48 | One important thing to note here is that the reducer (called `nameReducer` 49 | above) is called with two arguments: 50 | 51 | 1. the current state 52 | 2. whatever it is that the dispatch function (called `setName` above) is called 53 | with. This is often called an "action." 54 | 55 | ## Exercise 56 | 57 | Production deploys: 58 | 59 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/01.js) 60 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/01.js) 61 | 62 | We're going to start off as simple as possible with a `` component. 63 | `useReducer` is absolutely overkill for a counter component like ours, but for 64 | now, just focus on making things work with `useReducer`. 65 | 66 | 📜 Here are two really helpful blog posts comparing `useState` and `useReducer`: 67 | 68 | - [Should I useState or useReducer?](https://kentcdodds.com/blog/should-i-usestate-or-usereducer) 69 | - [How to implement useState with useReducer](https://kentcdodds.com/blog/how-to-implement-usestate-with-usereducer) 70 | 71 | ## Extra Credit 72 | 73 | ### 1. 💯 accept the step as the action 74 | 75 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-1.js) 76 | 77 | I want to change things a bit to have this API: 78 | 79 | ```javascript 80 | const [count, changeCount] = React.useReducer(countReducer, initialCount) 81 | const increment = () => changeCount(step) 82 | ``` 83 | 84 | How would you need to change your reducer to make this work? 85 | 86 | This one is just to show that you can pass anything as the action. 87 | 88 | ### 2. 💯 simulate setState with an object 89 | 90 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-2.js) 91 | 92 | Remember `this.setState` from class components? If not, lucky you 😉. Either 93 | way, let's see if you can figure out how to make the state updater (`dispatch` 94 | function) behave in a similar way by changing our `state` to an object 95 | (`{count: 0}`) and then calling the state updater with an object which merges 96 | with the current state. 97 | 98 | So here's how I want things to look now: 99 | 100 | ```javascript 101 | const [state, setState] = React.useReducer(countReducer, { 102 | count: initialCount, 103 | }) 104 | const {count} = state 105 | const increment = () => setState({count: count + step}) 106 | ``` 107 | 108 | How would you need to change the reducer to make this work? 109 | 110 | ### 3. 💯 simulate setState with an object OR function 111 | 112 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-3.js) 113 | 114 | `this.setState` from class components can also accept a function. So let's add 115 | support for that with our simulated `setState` function. See if you can figure 116 | out how to make your reducer support both the object as in the last extra credit 117 | as well as a function callback: 118 | 119 | ```javascript 120 | const [state, setState] = React.useReducer(countReducer, { 121 | count: initialCount, 122 | }) 123 | const {count} = state 124 | const increment = () => 125 | setState(currentState => ({count: currentState.count + step})) 126 | ``` 127 | 128 | ### 4. 💯 traditional dispatch object with a type and switch statement 129 | 130 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-4.js) 131 | 132 | Ok, now we can finally see what most people do conventionally (mostly thanks to 133 | redux). Update your reducer so I can do this: 134 | 135 | ```javascript 136 | const [state, dispatch] = React.useReducer(countReducer, { 137 | count: initialCount, 138 | }) 139 | const {count} = state 140 | const increment = () => dispatch({type: 'INCREMENT', step}) 141 | ``` 142 | 143 | ## 🦉 Other notes 144 | 145 | ### lazy initialization 146 | 147 | This one's not an extra credit, but _sometimes_ lazy initialization can be 148 | useful, so here's how we'd do that with our original hook App: 149 | 150 | ```javascript 151 | function init(initialStateFromProps) { 152 | return { 153 | pokemon: null, 154 | loading: false, 155 | error: null, 156 | } 157 | } 158 | 159 | // ... 160 | 161 | const [state, dispatch] = React.useReducer(reducer, props.initialState, init) 162 | ``` 163 | 164 | So, if you pass a third function argument to `useReducer`, it passes the second 165 | argument to that function and uses the return value for the initial state. 166 | 167 | This could be useful if our `init` function read into localStorage or something 168 | else that we wouldn't want happening every re-render. 169 | 170 | ### The full `useReducer` API 171 | 172 | If you're into TypeScript, here's some type definitions for `useReducer`: 173 | 174 | > Thanks to [Trey's blog post](https://levelup.gitconnected.com/db1858d1fb9c) 175 | 176 | > Please don't spend too much time reading through this by the way! 177 | 178 | ```typescript 179 | type Dispatch = (value: A) => void 180 | type Reducer = (prevState: S, action: A) => S 181 | type ReducerState> = R extends Reducer 182 | ? S 183 | : never 184 | type ReducerAction> = R extends Reducer< 185 | any, 186 | infer A 187 | > 188 | ? A 189 | : never 190 | 191 | function useReducer, I>( 192 | reducer: R, 193 | initializerArg: I & ReducerState, 194 | initializer: (arg: I & ReducerState) => ReducerState, 195 | ): [ReducerState, Dispatch>] 196 | 197 | function useReducer, I>( 198 | reducer: R, 199 | initializerArg: I, 200 | initializer: (arg: I) => ReducerState, 201 | ): [ReducerState, Dispatch>] 202 | 203 | function useReducer>( 204 | reducer: R, 205 | initialState: ReducerState, 206 | initializer?: undefined, 207 | ): [ReducerState, Dispatch>] 208 | ``` 209 | 210 | `useReducer` is pretty versatile. The key takeaway here is that while 211 | conventions are useful, understanding the API and its capabilities is more 212 | important. 213 | 214 | ## 🦉 Feedback 215 | 216 | Fill out 217 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=01%3A%20useReducer%3A%20simple%20Counter&em=nfurlan%40alley.confurlan%40alley.co). 218 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/exercise/02.md: -------------------------------------------------------------------------------- 1 | # useCallback: custom hooks 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/02.md` 6 | 7 | ## Background 8 | 9 | You know the dependency list of `useEffect`? Here's a quick refresher: 10 | 11 | ```javascript 12 | React.useEffect(() => { 13 | window.localStorage.setItem('count', count) 14 | }, [count]) // <-- that's the dependency list 15 | ``` 16 | 17 | Remember that the dependency list is how React knows whether to call your 18 | callback (and if you don't provide one then React will call your callback every 19 | render). It does this to ensure that the side effect you're performing in the 20 | callback doesn't get out of sync with the state of the application. 21 | 22 | But what happens if I use a function in my callback? 23 | 24 | ```javascript 25 | const updateLocalStorage = () => window.localStorage.setItem('count', count) 26 | React.useEffect(() => { 27 | updateLocalStorage() 28 | }, []) // <-- what goes in that dependency list? 29 | ``` 30 | 31 | We could just put the `count` in the dependency list and that would actually 32 | work, but what would happen if we changed `updateLocalStorage`? 33 | 34 | ```javascript 35 | const updateLocalStorage = () => window.localStorage.setItem(key, count) 36 | ``` 37 | 38 | Would we remember to update the dependency list to include the `key`? Hopefully 39 | we would. But this can be a pain to keep track of dependencies. Especially if 40 | the function that we're using in our `useEffect` callback is coming to us from 41 | props (in the case of a custom component) or arguments (in the case of a custom 42 | hook). 43 | 44 | Instead, it would be much easier if we could just put the function itself in the 45 | dependency list: 46 | 47 | ```javascript 48 | const updateLocalStorage = () => window.localStorage.setItem('count', count) 49 | React.useEffect(() => { 50 | updateLocalStorage() 51 | }, [updateLocalStorage]) // <-- function as a dependency 52 | ``` 53 | 54 | The problem with that though is because `updateLocalStorage` is defined inside 55 | the component function body, it's re-initialized every render, which means it's 56 | brand new every render, which means it changes every render, which means, you 57 | guessed it, our callback will be called every render! 58 | 59 | **This is the problem `useCallback` solves**. And here's how you solve it 60 | 61 | ```javascript 62 | const updateLocalStorage = React.useCallback( 63 | () => window.localStorage.setItem('count', count), 64 | [count], // <-- yup! That's a dependency list! 65 | ) 66 | React.useEffect(() => { 67 | updateLocalStorage() 68 | }, [updateLocalStorage]) 69 | ``` 70 | 71 | What that does is we pass React a function and React gives that same function 72 | back to us, but with a catch. On subsequent renders, if the elements in the 73 | dependency list are unchanged, instead of giving the same function back that we 74 | give to it, React will give us the same function it gave us last time. 75 | 76 | So while we still create a new function every render (to pass to `useCallback`), 77 | React only gives us the new one if the dependency list changes. 78 | 79 | 🦉 A common question with this is: "Why don't we just wrap every function in 80 | `useCallback`?" You can read about this in my blog post 81 | [When to useMemo and useCallback](https://kentcdodds.com/blog/usememo-and-usecallback). 82 | 83 | 🦉 And if "value stability" and "memoization" has you scratching your head, then 84 | this article may be helpful to you as well: 85 | [Memoization and React](https://epicreact.dev/memoization-and-react) 86 | 87 | 🦉 And if the concept of a "closure" is new or confusing to you, then 88 | [give this a read](https://whatthefork.is/closure). 89 | 90 | ## Exercise 91 | 92 | Production deploys: 93 | 94 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/02.js) 95 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/02.js) 96 | 97 | **People tend to find this exercise more difficult, so don't skip the reference 98 | material above!** 99 | 100 | For the exercise, we have a reducer that's responsible for managing the state of 101 | the promise for fetching the pokemon. Managing async state is something every 102 | app does all the time so it would be nice if we could abstract that away into a 103 | custom hook and make use of it elsewhere. 104 | 105 | Your job is to extract the logic from the `PokemonInfo` component into a custom 106 | `useAsync` hook. In the process you'll find you need to do some fancy things 107 | with dependencies. 108 | 109 | NOTE: In this part of the exercise, we don't need `useCallback`. We'll add it in 110 | the extra credits. 111 | 112 | ## Extra Credit 113 | 114 | ### 1. 💯 use useCallback to empower the user to customize memoization 115 | 116 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/02.extra-1.js) 117 | 118 | Unfortunately, the ESLint plugin is unable to determine whether the 119 | `dependencies` argument is a valid argument for `useEffect` which is a shame, 120 | and normally I'd say just ignore it and move on. But, there's another solution 121 | to this problem which I think is probably better. 122 | 123 | Instead of accepting `dependencies` to `useAsync`, why don't we just treat the 124 | `asyncCallback` as a dependency? Any time `asyncCallback` changes, we know that 125 | we should call it again. The problem is that because our `asyncCallback` depends 126 | on the `pokemonName` which comes from props, it has to be defined within the 127 | body of the component, which means that it will be defined on every render which 128 | means it will be new every render. This is where `React.useCallback` comes in! 129 | 130 | Here's a quick intro to the `React.useCallback` API: 131 | 132 | ```javascript 133 | function ConsoleGreeting(props) { 134 | const greet = React.useCallback( 135 | greeting => console.log(`${greeting} ${props.name}`), 136 | [props.name], 137 | ) 138 | 139 | React.useEffect(() => { 140 | const helloGreeting = 'Hello' 141 | greet(helloGreeting) 142 | }, [greet]) 143 | return
check the console
144 | } 145 | ``` 146 | 147 | The first argument to `useCallback` is the callback you want called, the second 148 | argument is an array of dependencies which is similar to `useEffect`. When one 149 | of the dependencies changes between renders, the callback you passed in the 150 | first argument will be the one returned from `useCallback`. If they do not 151 | change, then you'll get the callback which was returned the previous time (so 152 | the callback remains the same between renders). 153 | 154 | So we only want our `asyncCallback` to change when the `pokemonName` changes. 155 | See if you can make things work like this: 156 | 157 | ```javascript 158 | // 🐨 you'll need to define asyncCallback as a value returned from React.useCallback 159 | const state = useAsync(asyncCallback, { 160 | status: pokemonName ? 'pending' : 'idle', 161 | }) 162 | ``` 163 | 164 | ### 2. 💯 return a memoized `run` function from useAsync 165 | 166 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/02.extra-2.js) 167 | 168 | Requiring users to provide a memoized value is fine. You can document it as part 169 | of the API and expect people to just read the docs right? lol, that's hilarious 170 | 😂 It'd be WAY better if we could redesign the API a bit so we (as the hook 171 | developers) are the ones who have to memoize the function, and the users of our 172 | hook don't have to worry about it. 173 | 174 | So see if you can redesign this a little bit by providing a (memoized) `run` 175 | function that people can call in their own `useEffect` like this: 176 | 177 | ```javascript 178 | const {data: pokemon, status, error, run} = useAsync({ 179 | status: pokemonName ? 'pending' : 'idle', 180 | }) 181 | 182 | React.useEffect(() => { 183 | if (!pokemonName) { 184 | return 185 | } 186 | run(fetchPokemon(pokemonName)) 187 | }, [pokemonName, run]) 188 | ``` 189 | 190 | ### 3. 💯 make safeDispatch with useCallback, useRef, and useEffect 191 | 192 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/02.extra-3.js) 193 | 194 | **NOTICE: Things have changed slightly.** The app you're running the exercises in 195 | was changed since the videos were recorded and you can no longer see this issue 196 | by changing the exercise. All the exercises are now rendered in an iframe on the 197 | exercise pages, so when you go to a different exercise, you're effectively 198 | "closing" the page, so all JS execution for that exercise stops. 199 | 200 | So I've added a little checkbox which you can use to mount and unmount the 201 | component with ease. This has the benefit of also working on the isolated page 202 | as well. On the exercise page, you'll want to make sure that your console output 203 | is showing the output from the iframe by 204 | [selecting the right context](https://developers.google.com/web/tools/chrome-devtools/console/reference#context). 205 | 206 | I've also added a test for this one to help make sure you've got it right. 207 | 208 | Phew, ok, back to your extra credit! 209 | 210 | This one's a bit tricky, and I'm going to be intentionally vague here to give 211 | you a bit of a challenge, but consider the scenario where we fetch a pokemon, 212 | and before the request finishes, we change our mind and navigate to a different 213 | page (or uncheck the mount checkbox). In that case, the component would get 214 | removed from the page ("unmounted") and when the request finally does complete, 215 | it will call `dispatch`, but because the component has been removed from the 216 | page, we'll get this warning from React: 217 | 218 |
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
219 | 220 | The best solution to this problem would be to 221 | [cancel the request](https://developers.google.com/web/updates/2017/09/abortable-fetch), 222 | but even then, we'd have to handle the error and prevent the `dispatch` from 223 | being called for the rejected promise. 224 | 225 | So see whether you can work out a solution for preventing `dispatch` from being 226 | called if the component is unmounted. Depending on how you implement this, you 227 | might need `useRef`, `useCallback`, and `useEffect`. 228 | 229 | ## 🦉 Feedback 230 | 231 | Fill out 232 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=02%3A%20useCallback%3A%20custom%20hooks&em=nfurlan%40alley.confurlan%40alley.co). 233 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "advanced-react-hooks", 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": "frankcalise", 25 | "name": "Frank Calise", 26 | "avatar_url": "https://avatars0.githubusercontent.com/u/374022?v=4", 27 | "profile": "http://frankcalise.com", 28 | "contributions": [ 29 | "code" 30 | ] 31 | }, 32 | { 33 | "login": "Zara603", 34 | "name": "Zara603", 35 | "avatar_url": "https://avatars1.githubusercontent.com/u/4918423?v=4", 36 | "profile": "https://github.com/Zara603", 37 | "contributions": [ 38 | "code" 39 | ] 40 | }, 41 | { 42 | "login": "michaelfriedman", 43 | "name": "Michael Friedman", 44 | "avatar_url": "https://avatars3.githubusercontent.com/u/17555926?v=4", 45 | "profile": "https://github.com/michaelfriedman", 46 | "contributions": [ 47 | "doc" 48 | ] 49 | }, 50 | { 51 | "login": "btnwtn", 52 | "name": "Brandon Newton", 53 | "avatar_url": "https://avatars1.githubusercontent.com/u/20847518?v=4", 54 | "profile": "https://bitwise.cool", 55 | "contributions": [ 56 | "doc", 57 | "code" 58 | ] 59 | }, 60 | { 61 | "login": "JonathanBruce", 62 | "name": "Jonathan Bruce", 63 | "avatar_url": "https://avatars3.githubusercontent.com/u/1743411?v=4", 64 | "profile": "https://github.com/JonathanBruce", 65 | "contributions": [ 66 | "code" 67 | ] 68 | }, 69 | { 70 | "login": "lgandecki", 71 | "name": "Łukasz Gandecki", 72 | "avatar_url": "https://avatars1.githubusercontent.com/u/4002543?v=4", 73 | "profile": "http://team.thebrain.pro", 74 | "contributions": [ 75 | "doc" 76 | ] 77 | }, 78 | { 79 | "login": "jdorfman", 80 | "name": "Justin Dorfman", 81 | "avatar_url": "https://avatars1.githubusercontent.com/u/398230?v=4", 82 | "profile": "https://stackshare.io/jdorfman/decisions", 83 | "contributions": [ 84 | "fundingFinding" 85 | ] 86 | }, 87 | { 88 | "login": "motdde", 89 | "name": "Oluwaseun Oyebade", 90 | "avatar_url": "https://avatars1.githubusercontent.com/u/12215060?v=4", 91 | "profile": "http://motdde.com", 92 | "contributions": [ 93 | "doc" 94 | ] 95 | }, 96 | { 97 | "login": "kevscript", 98 | "name": "Kevin Ostafinski", 99 | "avatar_url": "https://avatars0.githubusercontent.com/u/28754130?v=4", 100 | "profile": "http://kevinostafinski.com", 101 | "contributions": [ 102 | "doc" 103 | ] 104 | }, 105 | { 106 | "login": "Snaptags", 107 | "name": "Markus Lasermann", 108 | "avatar_url": "https://avatars1.githubusercontent.com/u/1249745?v=4", 109 | "profile": "https://github.com/Snaptags", 110 | "contributions": [ 111 | "code", 112 | "test" 113 | ] 114 | }, 115 | { 116 | "login": "zacjones93", 117 | "name": "Zac Jones", 118 | "avatar_url": "https://avatars2.githubusercontent.com/u/6188161?v=4", 119 | "profile": "https://zacjones.io", 120 | "contributions": [ 121 | "doc" 122 | ] 123 | }, 124 | { 125 | "login": "rbusquet", 126 | "name": "Ricardo Busquet", 127 | "avatar_url": "https://avatars1.githubusercontent.com/u/7198302?v=4", 128 | "profile": "https://ricardobusquet.com", 129 | "contributions": [ 130 | "code" 131 | ] 132 | }, 133 | { 134 | "login": "kylereblora", 135 | "name": "Kyle Matthew Reblora", 136 | "avatar_url": "https://avatars2.githubusercontent.com/u/33372538?v=4", 137 | "profile": "https://kylereblora.github.io/", 138 | "contributions": [ 139 | "doc" 140 | ] 141 | }, 142 | { 143 | "login": "marcosvega91", 144 | "name": "Marco Moretti", 145 | "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4", 146 | "profile": "https://github.com/marcosvega91", 147 | "contributions": [ 148 | "code" 149 | ] 150 | }, 151 | { 152 | "login": "nywleswoey", 153 | "name": "Selwyn Yeow", 154 | "avatar_url": "https://avatars3.githubusercontent.com/u/28249994?v=4", 155 | "profile": "https://github.com/nywleswoey", 156 | "contributions": [ 157 | "doc" 158 | ] 159 | }, 160 | { 161 | "login": "gugol2", 162 | "name": "Watchmaker", 163 | "avatar_url": "https://avatars0.githubusercontent.com/u/4933016?v=4", 164 | "profile": "https://github.com/gugol2", 165 | "contributions": [ 166 | "code", 167 | "doc" 168 | ] 169 | }, 170 | { 171 | "login": "fonstack", 172 | "name": "Carlos Fontes", 173 | "avatar_url": "https://avatars3.githubusercontent.com/u/35873992?v=4", 174 | "profile": "https://fonstack.dev/", 175 | "contributions": [ 176 | "bug" 177 | ] 178 | }, 179 | { 180 | "login": "PritamSangani", 181 | "name": "Pritam Sangani", 182 | "avatar_url": "https://avatars3.githubusercontent.com/u/22857896?v=4", 183 | "profile": "https://www.linkedin.com/in/pritamsangani/", 184 | "contributions": [ 185 | "code" 186 | ] 187 | }, 188 | { 189 | "login": "wbeuil", 190 | "name": "William BEUIL", 191 | "avatar_url": "https://avatars1.githubusercontent.com/u/8110579?v=4", 192 | "profile": "http://wbeuil.com", 193 | "contributions": [ 194 | "doc" 195 | ] 196 | }, 197 | { 198 | "login": "emzoumpo", 199 | "name": "Emmanouil Zoumpoulakis", 200 | "avatar_url": "https://avatars2.githubusercontent.com/u/2103443?v=4", 201 | "profile": "https://github.com/emzoumpo", 202 | "contributions": [ 203 | "doc" 204 | ] 205 | }, 206 | { 207 | "login": "Aprillion", 208 | "name": "Peter Hozák", 209 | "avatar_url": "https://avatars0.githubusercontent.com/u/1087670?v=4", 210 | "profile": "http://peter.hozak.info/", 211 | "contributions": [ 212 | "code" 213 | ] 214 | }, 215 | { 216 | "login": "joemaffei", 217 | "name": "Joe Maffei", 218 | "avatar_url": "https://avatars1.githubusercontent.com/u/9068746?v=4", 219 | "profile": "https://github.com/joemaffei", 220 | "contributions": [ 221 | "doc" 222 | ] 223 | }, 224 | { 225 | "login": "jmagrippis", 226 | "name": "Johnny Magrippis", 227 | "avatar_url": "https://avatars0.githubusercontent.com/u/3502800?v=4", 228 | "profile": "https://magrippis.com", 229 | "contributions": [ 230 | "code" 231 | ] 232 | }, 233 | { 234 | "login": "rphuber", 235 | "name": "Ryan Huber", 236 | "avatar_url": "https://avatars0.githubusercontent.com/u/8245890?v=4", 237 | "profile": "http://blog.rphuber.com", 238 | "contributions": [ 239 | "doc", 240 | "code" 241 | ] 242 | }, 243 | { 244 | "login": "dominicchapman", 245 | "name": "Dominic Chapman", 246 | "avatar_url": "https://avatars2.githubusercontent.com/u/7607007?v=4", 247 | "profile": "https://dominicchapman.com", 248 | "contributions": [ 249 | "doc" 250 | ] 251 | }, 252 | { 253 | "login": "imalbert", 254 | "name": "imalbert", 255 | "avatar_url": "https://avatars1.githubusercontent.com/u/12537973?v=4", 256 | "profile": "https://github.com/imalbert", 257 | "contributions": [ 258 | "doc" 259 | ] 260 | }, 261 | { 262 | "login": "Huuums", 263 | "name": "Dennis Collon", 264 | "avatar_url": "https://avatars1.githubusercontent.com/u/9745322?v=4", 265 | "profile": "https://github.com/Huuums", 266 | "contributions": [ 267 | "doc" 268 | ] 269 | }, 270 | { 271 | "login": "jrozbicki", 272 | "name": "Jakub Różbicki", 273 | "avatar_url": "https://avatars3.githubusercontent.com/u/35103924?v=4", 274 | "profile": "https://github.com/jrozbicki", 275 | "contributions": [ 276 | "doc" 277 | ] 278 | }, 279 | { 280 | "login": "vasilii-kovalev", 281 | "name": "Vasilii Kovalev", 282 | "avatar_url": "https://avatars0.githubusercontent.com/u/10310491?v=4", 283 | "profile": "https://vk.com/vasilii_kovalev", 284 | "contributions": [ 285 | "bug" 286 | ] 287 | }, 288 | { 289 | "login": "alexfertel", 290 | "name": "Alexander Gonzalez", 291 | "avatar_url": "https://avatars3.githubusercontent.com/u/22298999?v=4", 292 | "profile": "http://alexfertel.netlify.app", 293 | "contributions": [ 294 | "code" 295 | ] 296 | }, 297 | { 298 | "login": "DaleSeo", 299 | "name": "Dale Seo", 300 | "avatar_url": "https://avatars1.githubusercontent.com/u/5466341?v=4", 301 | "profile": "https://www.daleseo.com", 302 | "contributions": [ 303 | "doc", 304 | "test" 305 | ] 306 | }, 307 | { 308 | "login": "MichaelDeBoey", 309 | "name": "Michaël De Boey", 310 | "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4", 311 | "profile": "https://michaeldeboey.be", 312 | "contributions": [ 313 | "code" 314 | ] 315 | }, 316 | { 317 | "login": "thegoodsheppard", 318 | "name": "Greg Sheppard", 319 | "avatar_url": "https://avatars1.githubusercontent.com/u/13774377?v=4", 320 | "profile": "https://github.com/thegoodsheppard", 321 | "contributions": [ 322 | "doc" 323 | ] 324 | }, 325 | { 326 | "login": "bobbywarner", 327 | "name": "Bobby Warner", 328 | "avatar_url": "https://avatars0.githubusercontent.com/u/554961?v=4", 329 | "profile": "http://bobbywarner.com", 330 | "contributions": [ 331 | "code" 332 | ] 333 | }, 334 | { 335 | "login": "jwm0", 336 | "name": "Jakub Majorek", 337 | "avatar_url": "https://avatars0.githubusercontent.com/u/28310983?v=4", 338 | "profile": "https://github.com/jwm0", 339 | "contributions": [ 340 | "code" 341 | ] 342 | }, 343 | { 344 | "login": "suddenlyGiovanni", 345 | "name": "Giovanni Ravalico", 346 | "avatar_url": "https://avatars2.githubusercontent.com/u/15946771?v=4", 347 | "profile": "https://suddenlyGiovanni.dev", 348 | "contributions": [ 349 | "ideas" 350 | ] 351 | }, 352 | { 353 | "login": "jsberlanga", 354 | "name": "Julio Soto", 355 | "avatar_url": "https://avatars.githubusercontent.com/u/32543746?v=4", 356 | "profile": "https://juliosoto.dev", 357 | "contributions": [ 358 | "code" 359 | ] 360 | }, 361 | { 362 | "login": "jmtes", 363 | "name": "Juno Tesoro", 364 | "avatar_url": "https://avatars.githubusercontent.com/u/38450133?v=4", 365 | "profile": "http://jmtes.github.io", 366 | "contributions": [ 367 | "doc" 368 | ] 369 | }, 370 | { 371 | "login": "aosante", 372 | "name": "Andrés Osante", 373 | "avatar_url": "https://avatars.githubusercontent.com/u/37124700?v=4", 374 | "profile": "http://www.andresosante.com", 375 | "contributions": [ 376 | "code" 377 | ] 378 | }, 379 | { 380 | "login": "IanVS", 381 | "name": "Ian VanSchooten", 382 | "avatar_url": "https://avatars.githubusercontent.com/u/4616705?v=4", 383 | "profile": "https://github.com/IanVS", 384 | "contributions": [ 385 | "test" 386 | ] 387 | } 388 | ], 389 | "contributorsPerLine": 7, 390 | "repoHost": "https://github.com", 391 | "skipCi": true 392 | } 393 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

🔥 Advanced React Hooks 🚀 EpicReact.Dev

3 | 4 | Learn the more advanced React hooks and different patterns to enable great 5 | developer APIs for custom hooks. 6 | 7 |

8 | We’ll look at some of the more advanced hooks and ways they can be used to 9 | optimize your components and custom hooks. We’ll also look at several 10 | patterns you can follow to make custom hooks that provide great APIs for 11 | developers to be productive building applications. 12 |

13 | 14 | 15 | Learn React from Start to Finish 19 | 20 |
21 | 22 |
23 | 24 | 25 | [![Build Status][build-badge]][build] 26 | [![All Contributors][all-contributors-badge]](#contributors) 27 | [![GPL 3.0 License][license-badge]][license] 28 | [![Code of Conduct][coc-badge]][coc] 29 | 30 | 31 | ## Prerequisites 32 | 33 | - You should be experienced with `useState`, `useEffect`, and `useRef`. 34 | 35 | ## Additional Resources 36 | 37 | - Videos 38 | [Getting Closure on React Hooks by Shawn Wang](https://www.youtube.com/watch?v=KJP1E-Y-xyo) 39 | (26 minutes) 40 | 41 | ## System Requirements 42 | 43 | - [git][git] v2.13 or greater 44 | - [NodeJS][node] `12 || 14 || 15 || 16` 45 | - [npm][npm] v6 or greater 46 | 47 | All of these must be available in your `PATH`. To verify things are set up 48 | properly, you can run this: 49 | 50 | ```shell 51 | git --version 52 | node --version 53 | npm --version 54 | ``` 55 | 56 | If you have trouble with any of these, learn more about the PATH environment 57 | variable and how to fix it here for [windows][win-path] or 58 | [mac/linux][mac-path]. 59 | 60 | ## Setup 61 | 62 | * Create a [fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo) of 63 | the Alley for of the React Fundamentals repo into your personal GitHub account. 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/[username]/advanced-react-hooks.git 69 | cd advanced-react-hooks 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-hooks). 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-hooks.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 | * Exercises are located in `src/exercise` 131 | * Exercise answer keys (final code) are located in `src/final` 132 | * When you first start the app you’ll be taken to a menu of all the exercises. 133 | * Click on either the title of the exercise or the “exercise” button to proceed to a specific exercise’s content. 134 | * Each exercise has some text to read in addition to a source code file (generally either an HTML or JS file). Exercise readmes will be visible on the left hand side of the app. 135 | * Changes made to exercise files (html, js) will be hot-reloaded in the exercise app and rendered on the right side of your screen. 136 | * Once you’ve completed an exercise, you can run `npm test` at the root of the repo to determine if you’ve done the exercise correctly. see previous section in this doc about tests. 137 | * In addition, you can double check your code against the final version in `src/final` and/or by using the “final” button on the exercise menu screen. 138 | * There’s an area in each exercise’s markdown file for you to take personal notes. 139 | * Create a separate PR for each exercise you complete (or, if you want to break things down further, for each extra credit as well). 140 | * Be sure to note which repo you're merging into as your PR base, as it may be either this repo or Kent C. Dodds' repo by default (you want to merge into your _own_ fork). 141 | 142 | - `src/exercise/00.md`: Background, Exercise Instructions, Extra Credit 143 | - `src/exercise/00.js`: Exercise with Emoji helpers 144 | - `src/__tests__/00.js`: Tests 145 | - `src/final/00.js`: Final version 146 | - `src/final/00.extra-0.js`: Final version of extra credit 147 | 148 | The purpose of the exercise is **not** for you to work through all the material. 149 | It's intended to get your brain thinking about the right questions to ask me as 150 | _I_ walk through the material. 151 | 152 | ### Helpful Emoji 🐨 💰 💯 📝 🦉 📜 💣 💪 🏁 👨‍💼 🚨 153 | 154 | Each exercise has comments in it to help you get through the exercise. These fun 155 | emoji characters are here to help you. 156 | 157 | - **Kody the Koala** 🐨 will tell you when there's something specific you should 158 | do version 159 | - **Marty the Money Bag** 💰 will give you specific tips (and sometimes code) 160 | along the way 161 | - **Hannah the Hundred** 💯 will give you extra challenges you can do if you 162 | finish the exercises early. 163 | - **Nancy the Notepad** 📝 will encourage you to take notes on what you're 164 | learning 165 | - **Olivia the Owl** 🦉 will give you useful tidbits/best practice notes and a 166 | link for elaboration and feedback. 167 | - **Dominic the Document** 📜 will give you links to useful documentation 168 | - **Berry the Bomb** 💣 will be hanging around anywhere you need to blow stuff 169 | up (delete code) 170 | - **Matthew the Muscle** 💪 will indicate that you're working with an exercise 171 | - **Chuck the Checkered Flag** 🏁 will indicate that you're working with a final 172 | - **Peter the Product Manager** 👨‍💼 helps us know what our users want 173 | - **Alfred the Alert** 🚨 will occasionally show up in the test failures with 174 | potential explanations for why the tests are failing. 175 | 176 | ## Contributors 177 | 178 | Thanks goes to these wonderful people 179 | ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 |

Kent C. Dodds

💻 📖 🚇 ⚠️

Frank Calise

💻

Zara603

💻

Michael Friedman

📖

Brandon Newton

📖 💻

Jonathan Bruce

💻

Łukasz Gandecki

📖

Justin Dorfman

🔍

Oluwaseun Oyebade

📖

Kevin Ostafinski

📖

Markus Lasermann

💻 ⚠️

Zac Jones

📖

Ricardo Busquet

💻

Kyle Matthew Reblora

📖

Marco Moretti

💻

Selwyn Yeow

📖

Watchmaker

💻 📖

Carlos Fontes

🐛

Pritam Sangani

💻

William BEUIL

📖

Emmanouil Zoumpoulakis

📖

Peter Hozák

💻

Joe Maffei

📖

Johnny Magrippis

💻

Ryan Huber

📖 💻

Dominic Chapman

📖

imalbert

📖

Dennis Collon

📖

Jakub Różbicki

📖

Vasilii Kovalev

🐛

Alexander Gonzalez

💻

Dale Seo

📖 ⚠️

Michaël De Boey

💻

Greg Sheppard

📖

Bobby Warner

💻

Jakub Majorek

💻

Giovanni Ravalico

🤔

Julio Soto

💻

Juno Tesoro

📖

Andrés Osante

💻

Ian VanSchooten

⚠️
239 | 240 | 241 | 242 | 243 | 244 | 245 | This project follows the 246 | [all-contributors](https://github.com/kentcdodds/all-contributors) 247 | specification. Contributions of any kind welcome! 248 | 249 | ## Workshop Feedback 250 | 251 | Each exercise has an Elaboration and Feedback link. Please fill that out after 252 | the exercise and instruction. 253 | 254 | At the end of the workshop, please go to this URL to give overall feedback. 255 | Thank you! https://kcd.im/arh-ws-feedback 256 | 257 | 258 | [npm]: https://www.npmjs.com/ 259 | [node]: https://nodejs.org 260 | [git]: https://git-scm.com/ 261 | [build-badge]: https://img.shields.io/github/workflow/status/kentcdodds/advanced-react-hooks/validate/main?logo=github&style=flat-square 262 | [build]: https://github.com/kentcdodds/advanced-react-hooks/actions?query=workflow%3Avalidate 263 | [license-badge]: https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square 264 | [license]: https://github.com/kentcdodds/advanced-react-hooks/blob/main/LICENSE 265 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 266 | [coc]: https://github.com/kentcdodds/advanced-react-hooks/blob/main/CODE_OF_CONDUCT.md 267 | [emojis]: https://github.com/kentcdodds/all-contributors#emoji-key 268 | [all-contributors]: https://github.com/kentcdodds/all-contributors 269 | [all-contributors-badge]: https://img.shields.io/github/all-contributors/kentcdodds/advanced-react-hooks?color=orange&style=flat-square 270 | [win-path]: https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/ 271 | [mac-path]: http://stackoverflow.com/a/24322978/971592 272 | [issue]: https://github.com/kentcdodds/advanced-react-hooks/issues/new 273 | 274 | --------------------------------------------------------------------------------