├── .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 {count}
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 {count}
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 {count}
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 {count}
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 | You need to enable JavaScript to run this app.
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 {count}
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 {count}
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 Increment count
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 Increment count
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 Increment count
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 |
107 | )
108 | }
109 |
110 | function AppWithUnmountCheckbox() {
111 | const [mountApp, setMountApp] = React.useState(true)
112 | return (
113 |
114 |
115 | setMountApp(e.target.checked)}
119 | />{' '}
120 | Mount Component
121 |
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 |
103 | )
104 | }
105 |
106 | function AppWithUnmountCheckbox() {
107 | const [mountApp, setMountApp] = React.useState(true)
108 | return (
109 |
110 |
111 | setMountApp(e.target.checked)}
115 | />{' '}
116 | Mount Component
117 |
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 |
107 | )
108 | }
109 |
110 | function AppWithUnmountCheckbox() {
111 | const [mountApp, setMountApp] = React.useState(true)
112 | return (
113 |
114 |
115 | setMountApp(e.target.checked)}
119 | />{' '}
120 | Mount Component
121 |
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 | add message
55 | remove message
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 | add message
56 | remove message
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 | Focus on the input
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 | add message
56 | remove message
57 |
58 |
59 |
60 | scroll to top
61 |
62 |
63 |
64 | scroll to bottom
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 | add message
55 | remove message
56 |
57 |
58 |
59 | scroll to top
60 |
61 |
62 |
63 | scroll to bottom
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 | onSelect(pokemonName)}
92 | >
93 | {pokemonName}
94 |
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 |
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 | onSelect(pokemonName)}
83 | >
84 | {pokemonName}
85 |
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 |
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 |
116 | )
117 | }
118 |
119 | function AppWithUnmountCheckbox() {
120 | const [mountApp, setMountApp] = React.useState(true)
121 | return (
122 |
123 |
124 | setMountApp(e.target.checked)}
128 | />{' '}
129 | Mount Component
130 |
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 |
133 | )
134 | }
135 |
136 | function AppWithUnmountCheckbox() {
137 | const [mountApp, setMountApp] = React.useState(true)
138 | return (
139 |
140 |
141 | setMountApp(e.target.checked)}
145 | />{' '}
146 | Mount Component
147 |
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 |
84 |
85 |
86 |
87 | {pokemon.name}
88 | {pokemon.number}
89 |
90 |
91 |
92 |
93 | {pokemon.attacks.special.map(attack => (
94 |
95 | {attack.name} :{' '}
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 |
184 | )
185 | }
186 |
187 | function ErrorFallback({error, resetErrorBoundary}) {
188 | return (
189 |
190 | There was an error:{' '}
191 |
{error.message}
192 |
Try again
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 |
40 | Name:
41 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------