├── .gitattributes ├── public ├── _redirects ├── favicon.ico ├── img │ └── pokemon │ │ ├── mew.jpg │ │ ├── ditto.jpg │ │ ├── mewtwo.jpg │ │ ├── pikachu.jpg │ │ ├── bulbasaur.jpg │ │ ├── charizard.jpg │ │ └── fallback-pokemon.jpg ├── manifest.json ├── index.html └── mockServiceWorker.js ├── setup.js ├── .eslintignore ├── .prettierignore ├── src ├── setupTests.js ├── examples │ ├── hook-flow.png │ ├── local-state-key-change.js │ └── hook-flow.js ├── index.js ├── __tests__ │ ├── 01.js │ ├── 03.js │ ├── 03.extra-1.js │ ├── 05.js │ ├── 06.js │ ├── 02.js │ ├── 04.js │ ├── 04.extra-1.js │ └── 04.extra-3.js ├── final │ ├── 01.js │ ├── 01.extra-1.js │ ├── 02.js │ ├── 02.extra-1.js │ ├── 02.extra-2.js │ ├── 05.js │ ├── 02.extra-3.js │ ├── 03.js │ ├── 03.extra-1.js │ ├── 06.js │ ├── 06.extra-1.js │ ├── 06.extra-2.js │ ├── 06.extra-3.js │ ├── 02.extra-4.js │ ├── 06.extra-6.js │ ├── 06.extra-7.js │ ├── 06.extra-8.js │ ├── 06.extra-4.js │ ├── 06.extra-5.js │ ├── 04.js │ ├── 04.extra-2.js │ ├── 04.extra-1.js │ └── 04.extra-3.js ├── exercise │ ├── 01.js │ ├── 05.js │ ├── 02.js │ ├── 03.js │ ├── 05-classes.js │ ├── 03.md │ ├── 06.js │ ├── 05.md │ ├── 04.js │ ├── 01.md │ ├── 04-classes.js │ ├── 04.md │ ├── 02.md │ └── 06.md ├── utils.js ├── backend.js ├── styles.css └── 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/react-hooks/main/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | build 5 | .idea/ 6 | .vscode/ 7 | .eslintcache 8 | -------------------------------------------------------------------------------- /public/img/pokemon/mew.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/react-hooks/main/public/img/pokemon/mew.jpg -------------------------------------------------------------------------------- /scripts/fix-links: -------------------------------------------------------------------------------- 1 | npx https://gist.github.com/kentcdodds/436a77ff8977269e5fee39d9d89956de 2 | npm run format 3 | -------------------------------------------------------------------------------- /src/examples/hook-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/react-hooks/main/src/examples/hook-flow.png -------------------------------------------------------------------------------- /public/img/pokemon/ditto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/react-hooks/main/public/img/pokemon/ditto.jpg -------------------------------------------------------------------------------- /public/img/pokemon/mewtwo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/react-hooks/main/public/img/pokemon/mewtwo.jpg -------------------------------------------------------------------------------- /public/img/pokemon/pikachu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/react-hooks/main/public/img/pokemon/pikachu.jpg -------------------------------------------------------------------------------- /public/img/pokemon/bulbasaur.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/react-hooks/main/public/img/pokemon/bulbasaur.jpg -------------------------------------------------------------------------------- /public/img/pokemon/charizard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/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 | -------------------------------------------------------------------------------- /public/img/pokemon/fallback-pokemon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkifurls/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 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "node", 3 | "container": { 4 | "startScript": "start", 5 | "port": 3000, 6 | "node": "14" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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": "React Hooks", 3 | "name": "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 | -------------------------------------------------------------------------------- /src/__tests__/01.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/01' 5 | // import App from '../exercise/01' 6 | 7 | test('typing a name shows a greeting', () => { 8 | render() 9 | userEvent.type(screen.getByRole('textbox', {name: /name/i}), 'bob') 10 | expect(screen.getByText(/hello.*bob/i)).toBeInTheDocument() 11 | }) 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "jsxBracketSameLine": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 80, 10 | "proseWrap": "always", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleQuote": true, 15 | "tabWidth": 2, 16 | "trailingComma": "all", 17 | "useTabs": false 18 | } 19 | -------------------------------------------------------------------------------- /src/__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('App works', () => { 8 | render() 9 | userEvent.type(screen.getByRole('textbox', {name: /name/i}), 'mulan') 10 | userEvent.type(screen.getByRole('textbox', {name: /animal/i}), 'dragon') 11 | expect( 12 | screen.getByText('Hey mulan, your favorite animal is: dragon!'), 13 | ).toBeInTheDocument() 14 | }) 15 | -------------------------------------------------------------------------------- /src/__tests__/03.extra-1.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.extra-1' 5 | // import App from '../exercise/03' 6 | 7 | test('App works', () => { 8 | render() 9 | userEvent.type(screen.getByRole('textbox', {name: /name/i}), 'mulan') 10 | userEvent.type(screen.getByRole('textbox', {name: /animal/i}), 'dragon') 11 | expect( 12 | screen.getByText('Your favorite animal is: dragon!'), 13 | ).toBeInTheDocument() 14 | }) 15 | -------------------------------------------------------------------------------- /src/__tests__/05.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render} from '@testing-library/react' 3 | import App from '../final/05' 4 | // import App from '../exercise/05' 5 | 6 | test('calls VanillaTilt.init with the root node', () => { 7 | const {container, unmount} = render() 8 | const tiltRoot = container.querySelector('.tilt-root') 9 | expect(tiltRoot).toHaveProperty('vanillaTilt') 10 | 11 | const destroy = jest.spyOn(tiltRoot.vanillaTilt, 'destroy') 12 | expect(destroy).toHaveBeenCalledTimes(0) 13 | 14 | unmount() 15 | 16 | expect(destroy).toHaveBeenCalledTimes(1) 17 | }) 18 | -------------------------------------------------------------------------------- /src/final/01.js: -------------------------------------------------------------------------------- 1 | // useState: greeting 2 | // http://localhost:3000/isolated/final/01.js 3 | 4 | import * as React from 'react' 5 | 6 | function Greeting() { 7 | const [name, setName] = React.useState('') 8 | function handleChange(event) { 9 | setName(event.target.value) 10 | } 11 | return ( 12 |
13 |
14 | 15 | 16 |
17 | {name ? Hello {name} : 'Please type your name'} 18 |
19 | ) 20 | } 21 | 22 | function App() { 23 | return 24 | } 25 | 26 | export default App 27 | -------------------------------------------------------------------------------- /src/exercise/01.js: -------------------------------------------------------------------------------- 1 | // useState: greeting 2 | // http://localhost:3000/isolated/exercise/01.js 3 | 4 | import * as React from 'react' 5 | 6 | function Greeting(props) { 7 | const [ name, setName ] = React.useState(props.initialName); 8 | 9 | function handleChange(event) { 10 | setName(event.target.value); 11 | } 12 | 13 | return ( 14 |
15 |
16 | 17 | 18 |
19 | {name ? Hello {name} : 'Please type your name'} 20 |
21 | ) 22 | } 23 | 24 | function App() { 25 | return 26 | } 27 | 28 | export default App 29 | -------------------------------------------------------------------------------- /.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-1.js: -------------------------------------------------------------------------------- 1 | // useState: greeting 2 | // 💯 accept an initialName 3 | // http://localhost:3000/isolated/final/01.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | function Greeting({initialName = ''}) { 8 | const [name, setName] = React.useState(initialName) 9 | function handleChange(event) { 10 | setName(event.target.value) 11 | } 12 | return ( 13 |
14 |
15 | 16 | 17 |
18 | {name ? Hello {name} : 'Please type your name'} 19 |
20 | ) 21 | } 22 | 23 | function App() { 24 | return 25 | } 26 | 27 | export default App 28 | -------------------------------------------------------------------------------- /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 | React Hooks 🎣 13 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /src/final/02.js: -------------------------------------------------------------------------------- 1 | // useEffect: persistent state 2 | // http://localhost:3000/isolated/final/02.js 3 | 4 | import * as React from 'react' 5 | 6 | function Greeting({initialName = ''}) { 7 | const [name, setName] = React.useState( 8 | window.localStorage.getItem('name') || initialName, 9 | ) 10 | 11 | React.useEffect(() => { 12 | window.localStorage.setItem('name', name) 13 | }) 14 | 15 | function handleChange(event) { 16 | setName(event.target.value) 17 | } 18 | 19 | return ( 20 |
21 |
22 | 23 | 24 |
25 | {name ? Hello {name} : 'Please type your name'} 26 |
27 | ) 28 | } 29 | 30 | function App() { 31 | return 32 | } 33 | 34 | export default App 35 | -------------------------------------------------------------------------------- /scripts/diff.js: -------------------------------------------------------------------------------- 1 | const {spawnSync} = require('child_process') 2 | const inquirer = require('inquirer') 3 | const glob = require('glob') 4 | 5 | async function go() { 6 | const files = glob 7 | .sync('src/+(exercise|final)/*.+(js|ts|tsx)', { 8 | ignore: ['*.d.ts'], 9 | }) 10 | .map(f => f.replace(/^src\//, '')) 11 | const {first} = await inquirer.prompt([ 12 | { 13 | name: 'first', 14 | message: `What's the first file`, 15 | type: 'list', 16 | choices: files, 17 | }, 18 | ]) 19 | const {second} = await inquirer.prompt([ 20 | { 21 | name: 'second', 22 | message: `What's the second file`, 23 | type: 'list', 24 | choices: files.filter(f => f !== first), 25 | }, 26 | ]) 27 | 28 | spawnSync(`git diff --no-index ./src/${first} ./src/${second}`, { 29 | shell: true, 30 | stdio: 'inherit', 31 | }) 32 | } 33 | 34 | go() 35 | -------------------------------------------------------------------------------- /src/final/02.extra-1.js: -------------------------------------------------------------------------------- 1 | // useEffect: persistent state 2 | // 💯 lazy state initialization 3 | // http://localhost:3000/isolated/final/02.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | function Greeting({initialName = ''}) { 8 | const [name, setName] = React.useState( 9 | () => window.localStorage.getItem('name') || initialName, 10 | ) 11 | 12 | React.useEffect(() => { 13 | window.localStorage.setItem('name', name) 14 | }) 15 | 16 | function handleChange(event) { 17 | setName(event.target.value) 18 | } 19 | 20 | return ( 21 |
22 |
23 | 24 | 25 |
26 | {name ? Hello {name} : 'Please type your name'} 27 |
28 | ) 29 | } 30 | 31 | function App() { 32 | return 33 | } 34 | 35 | export default App 36 | -------------------------------------------------------------------------------- /src/final/02.extra-2.js: -------------------------------------------------------------------------------- 1 | // useEffect: persistent state 2 | // 💯 effect dependencies 3 | // http://localhost:3000/isolated/final/02.extra-2.js 4 | 5 | import * as React from 'react' 6 | 7 | function Greeting({initialName = ''}) { 8 | const [name, setName] = React.useState( 9 | () => window.localStorage.getItem('name') || initialName, 10 | ) 11 | 12 | React.useEffect(() => { 13 | window.localStorage.setItem('name', name) 14 | }, [name]) 15 | 16 | function handleChange(event) { 17 | setName(event.target.value) 18 | } 19 | 20 | return ( 21 |
22 |
23 | 24 | 25 |
26 | {name ? Hello {name} : 'Please type your name'} 27 |
28 | ) 29 | } 30 | 31 | function App() { 32 | return 33 | } 34 | 35 | export default App 36 | -------------------------------------------------------------------------------- /src/final/05.js: -------------------------------------------------------------------------------- 1 | // useRef and useEffect: DOM interaction 2 | // http://localhost:3000/isolated/final/05.js 3 | 4 | import * as React from 'react' 5 | import VanillaTilt from 'vanilla-tilt' 6 | 7 | function Tilt({children}) { 8 | const tiltRef = React.useRef() 9 | 10 | React.useEffect(() => { 11 | const {current: tiltNode} = tiltRef 12 | const vanillaTiltOptions = { 13 | max: 25, 14 | speed: 400, 15 | glare: true, 16 | 'max-glare': 0.5, 17 | } 18 | VanillaTilt.init(tiltNode, vanillaTiltOptions) 19 | return () => tiltNode.vanillaTilt.destroy() 20 | }, []) 21 | 22 | return ( 23 |
24 |
{children}
25 |
26 | ) 27 | } 28 | 29 | function App() { 30 | return ( 31 | 32 |
vanilla-tilt.js
33 |
34 | ) 35 | } 36 | 37 | export default App 38 | -------------------------------------------------------------------------------- /src/exercise/05.js: -------------------------------------------------------------------------------- 1 | // useRef and useEffect: DOM interaction 2 | // http://localhost:3000/isolated/exercise/05.js 3 | 4 | import * as React from 'react' 5 | // eslint-disable-next-line no-unused-vars 6 | import VanillaTilt from 'vanilla-tilt' 7 | 8 | function Tilt({children}) { 9 | const tiltRef = React.useRef(); 10 | 11 | React.useEffect(() => { 12 | const tiltNode = tiltRef.current; 13 | VanillaTilt.init(tiltNode, { 14 | max: 25, 15 | speed: 400, 16 | glare: true, 17 | 'max-glare': 0.5, 18 | }); 19 | 20 | return () => tiltNode?.vanillaTilt.destroy(); 21 | }, []); 22 | 23 | // 🐨 add the `ref` prop to the `tilt-root` div here: 24 | return ( 25 |
26 |
{children}
27 |
28 | ) 29 | } 30 | 31 | function App() { 32 | return ( 33 | 34 |
vanilla-tilt.js
35 |
36 | ) 37 | } 38 | 39 | export default App 40 | -------------------------------------------------------------------------------- /src/exercise/02.js: -------------------------------------------------------------------------------- 1 | // useEffect: persistent state 2 | // http://localhost:3000/isolated/exercise/02.js 3 | 4 | import * as React from 'react' 5 | 6 | // Custom Hook 7 | function useLocalStorageState(key, value) { 8 | const [state, setState] = React.useState(() => window.localStorage.getItem(key) || value); 9 | 10 | React.useEffect(() => { 11 | window.localStorage.setItem(key, state) 12 | }, [key, state]); 13 | 14 | return [state, setState]; 15 | } 16 | 17 | // Component 18 | function Greeting({initialName = ''}) { 19 | const [name, setName] = useLocalStorageState('name', initialName); 20 | 21 | function handleChange(event) { 22 | setName(event.target.value) 23 | } 24 | return ( 25 |
26 |
27 | 28 | 29 |
30 | {name ? Hello {name} : 'Please type your name'} 31 |
32 | ) 33 | } 34 | 35 | function App() { 36 | return 37 | } 38 | 39 | export default App 40 | -------------------------------------------------------------------------------- /src/final/02.extra-3.js: -------------------------------------------------------------------------------- 1 | // useEffect: persistent state 2 | // 💯 custom hook 3 | // http://localhost:3000/isolated/final/02.extra-3.js 4 | 5 | import * as React from 'react' 6 | 7 | function useLocalStorageState(key, defaultValue = '') { 8 | const [state, setState] = React.useState( 9 | () => window.localStorage.getItem(key) || defaultValue, 10 | ) 11 | 12 | React.useEffect(() => { 13 | window.localStorage.setItem(key, state) 14 | }, [key, state]) 15 | 16 | return [state, setState] 17 | } 18 | 19 | function Greeting({initialName = ''}) { 20 | const [name, setName] = useLocalStorageState('name', initialName) 21 | 22 | function handleChange(event) { 23 | setName(event.target.value) 24 | } 25 | 26 | return ( 27 |
28 |
29 | 30 | 31 |
32 | {name ? Hello {name} : 'Please type your name'} 33 |
34 | ) 35 | } 36 | 37 | function App() { 38 | return 39 | } 40 | 41 | export default App 42 | -------------------------------------------------------------------------------- /src/exercise/03.js: -------------------------------------------------------------------------------- 1 | // Lifting state 2 | // http://localhost:3000/isolated/exercise/03.js 3 | 4 | import * as React from 'react' 5 | 6 | function Name() { 7 | const [name, setName] = React.useState(''); 8 | return ( 9 |
10 | 11 | setName(event.target.value)} /> 12 |
13 | ) 14 | } 15 | 16 | function FavoriteAnimal({animal, onAnimalChange}) { 17 | return ( 18 |
19 | 20 | 25 |
26 | ) 27 | } 28 | 29 | function Display({animal}) { 30 | return
{`Your favorite animal is: ${animal}!`}
31 | } 32 | 33 | function App() { 34 | const [animal, setAnimal] = React.useState(''); 35 | return ( 36 |
37 | 38 | setAnimal(event.target.value)} /> 39 | 40 | 41 | ) 42 | } 43 | 44 | export default App 45 | -------------------------------------------------------------------------------- /src/examples/local-state-key-change.js: -------------------------------------------------------------------------------- 1 | // useEffect: persistent state 2 | // flexible localStorage hook - changing the key in localStorage 3 | // http://localhost:3000/isolated/examples/local-state-key-change.js 4 | 5 | import * as React from 'react' 6 | import {useLocalStorageState} from '../utils' 7 | 8 | function Greeting({initialName = ''}) { 9 | const [key, setKey] = React.useState('name') 10 | const [name, setName] = useLocalStorageState(key, initialName) 11 | 12 | function handleClick() { 13 | if (key === 'name') { 14 | setKey('firstName') 15 | } else if (key === 'firstName') { 16 | setKey('Name') 17 | } else { 18 | setKey('name') 19 | } 20 | } 21 | 22 | function handleChange(event) { 23 | setName(event.target.value) 24 | } 25 | 26 | return ( 27 |
28 | 31 |
32 | 33 | 34 |
35 | {name ? Hello {name} : 'Please type your name'} 36 |
37 | ) 38 | } 39 | 40 | export default Greeting 41 | -------------------------------------------------------------------------------- /src/final/03.js: -------------------------------------------------------------------------------- 1 | // Lifting state 2 | // http://localhost:3000/isolated/final/03.js 3 | 4 | import * as React from 'react' 5 | 6 | function Name({name, onNameChange}) { 7 | return ( 8 |
9 | 10 | 11 |
12 | ) 13 | } 14 | 15 | function FavoriteAnimal({animal, onAnimalChange}) { 16 | return ( 17 |
18 | 19 | 20 |
21 | ) 22 | } 23 | 24 | function Display({name, animal}) { 25 | return
{`Hey ${name}, your favorite animal is: ${animal}!`}
26 | } 27 | 28 | function App() { 29 | const [animal, setAnimal] = React.useState('') 30 | const [name, setName] = React.useState('') 31 | return ( 32 |
33 | setName(event.target.value)} /> 34 | setAnimal(event.target.value)} 37 | /> 38 | 39 | 40 | ) 41 | } 42 | 43 | export default App 44 | -------------------------------------------------------------------------------- /src/final/03.extra-1.js: -------------------------------------------------------------------------------- 1 | // Lifting state 2 | // 💯 colocating state 3 | // http://localhost:3000/isolated/final/03.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | function Name() { 8 | const [name, setName] = React.useState('') 9 | return ( 10 |
11 | 12 | setName(event.target.value)} 16 | /> 17 |
18 | ) 19 | } 20 | 21 | function FavoriteAnimal({animal, onAnimalChange}) { 22 | return ( 23 |
24 | 25 | 26 |
27 | ) 28 | } 29 | 30 | function Display({animal}) { 31 | return
{`Your favorite animal is: ${animal}!`}
32 | } 33 | 34 | function App() { 35 | const [animal, setAnimal] = React.useState('') 36 | return ( 37 |
38 | 39 | setAnimal(event.target.value)} 42 | /> 43 | 44 | 45 | ) 46 | } 47 | 48 | export default App 49 | -------------------------------------------------------------------------------- /src/exercise/05-classes.js: -------------------------------------------------------------------------------- 1 | // useRef and useEffect: DOM interaction 2 | // 💯 (alternate) migrate from classes 3 | // http://localhost:3000/isolated/exercise/05-classes.js 4 | 5 | import * as React from 'react' 6 | import VanillaTilt from 'vanilla-tilt' 7 | 8 | // If you'd rather practice refactoring a class component to a function 9 | // component with hooks, then go ahead and do this exercise. 10 | 11 | class Tilt extends React.Component { 12 | tiltRef = React.createRef() 13 | componentDidMount() { 14 | const tiltNode = this.tiltRef.current 15 | const vanillaTiltOptions = { 16 | max: 25, 17 | speed: 400, 18 | glare: true, 19 | 'max-glare': 0.5, 20 | } 21 | VanillaTilt.init(tiltNode, vanillaTiltOptions) 22 | } 23 | componentWillUnmount() { 24 | this.tiltRef.current.vanillaTilt.destroy() 25 | } 26 | render() { 27 | return ( 28 |
29 |
{this.props.children}
30 |
31 | ) 32 | } 33 | } 34 | function App() { 35 | return ( 36 | 37 |
vanilla-tilt.js
38 |
39 | ) 40 | } 41 | 42 | export default App 43 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | /** 4 | * 5 | * @param {String} key The key to set in localStorage for this value 6 | * @param {Object} defaultValue The value to use if it is not already in localStorage 7 | * @param {{serialize: Function, deserialize: Function}} options The serialize and deserialize functions to use (defaults to JSON.stringify and JSON.parse respectively) 8 | */ 9 | 10 | function useLocalStorageState( 11 | key, 12 | defaultValue = '', 13 | {serialize = JSON.stringify, deserialize = JSON.parse} = {}, 14 | ) { 15 | const [state, setState] = React.useState(() => { 16 | const valueInLocalStorage = window.localStorage.getItem(key) 17 | if (valueInLocalStorage) { 18 | return deserialize(valueInLocalStorage) 19 | } 20 | return typeof defaultValue === 'function' ? defaultValue() : defaultValue 21 | }) 22 | 23 | const prevKeyRef = React.useRef(key) 24 | 25 | React.useEffect(() => { 26 | const prevKey = prevKeyRef.current 27 | if (prevKey !== key) { 28 | window.localStorage.removeItem(prevKey) 29 | } 30 | prevKeyRef.current = key 31 | window.localStorage.setItem(key, serialize(state)) 32 | }, [key, state, serialize]) 33 | 34 | return [state, setState] 35 | } 36 | 37 | export {useLocalStorageState} 38 | -------------------------------------------------------------------------------- /src/final/06.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // http://localhost:3000/isolated/final/06.js 3 | 4 | import * as React from 'react' 5 | import { 6 | fetchPokemon, 7 | PokemonInfoFallback, 8 | PokemonForm, 9 | PokemonDataView, 10 | } from '../pokemon' 11 | 12 | function PokemonInfo({pokemonName}) { 13 | const [pokemon, setPokemon] = React.useState(null) 14 | 15 | React.useEffect(() => { 16 | if (!pokemonName) { 17 | return 18 | } 19 | setPokemon(null) 20 | fetchPokemon(pokemonName).then(pokemon => setPokemon(pokemon)) 21 | }, [pokemonName]) 22 | 23 | if (!pokemonName) { 24 | return 'Submit a pokemon' 25 | } else if (!pokemon) { 26 | return 27 | } else { 28 | return 29 | } 30 | } 31 | 32 | function App() { 33 | const [pokemonName, setPokemonName] = React.useState('') 34 | 35 | function handleSubmit(newPokemonName) { 36 | setPokemonName(newPokemonName) 37 | } 38 | 39 | return ( 40 |
41 | 42 |
43 |
44 | 45 |
46 |
47 | ) 48 | } 49 | 50 | export default App 51 | -------------------------------------------------------------------------------- /src/__tests__/06.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/06' 6 | // import App from '../exercise/06' 7 | 8 | beforeEach(() => jest.spyOn(window, 'fetch')) 9 | afterEach(() => window.fetch.mockRestore()) 10 | 11 | test('displays the pokemon', async () => { 12 | render() 13 | const input = screen.getByLabelText(/pokemon/i) 14 | const submit = screen.getByText(/^submit$/i) 15 | 16 | // verify that an initial request is made when mounted 17 | userEvent.type(input, 'pikachu') 18 | userEvent.click(submit) 19 | 20 | await screen.findByRole('heading', {name: /pikachu/i}) 21 | 22 | // verify that a request is made when props change 23 | userEvent.clear(input) 24 | userEvent.type(input, 'ditto') 25 | userEvent.click(submit) 26 | 27 | await screen.findByRole('heading', {name: /ditto/i}) 28 | 29 | // verify that when props remain the same a request is not made 30 | window.fetch.mockClear() 31 | userEvent.click(submit) 32 | 33 | await screen.findByRole('heading', {name: /ditto/i}) 34 | 35 | alfredTip( 36 | () => expect(window.fetch).not.toHaveBeenCalled(), 37 | 'Make certain that you are providing a dependencies list in useEffect.', 38 | ) 39 | }) 40 | -------------------------------------------------------------------------------- /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/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/react-hooks/issues 39 | -------------------------------------------------------------------------------- /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/final/06.extra-1.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // 💯 handle errors 3 | // http://localhost:3000/isolated/final/06.extra-1.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonInfoFallback, 9 | PokemonForm, 10 | PokemonDataView, 11 | } from '../pokemon' 12 | 13 | function PokemonInfo({pokemonName}) { 14 | const [pokemon, setPokemon] = React.useState(null) 15 | const [error, setError] = React.useState(null) 16 | 17 | React.useEffect(() => { 18 | if (!pokemonName) { 19 | return 20 | } 21 | setPokemon(null) 22 | setError(null) 23 | fetchPokemon(pokemonName).then( 24 | pokemon => setPokemon(pokemon), 25 | error => setError(error), 26 | ) 27 | }, [pokemonName]) 28 | 29 | if (error) { 30 | return ( 31 |
32 | There was an error:{' '} 33 |
{error.message}
34 |
35 | ) 36 | } else if (!pokemonName) { 37 | return 'Submit a pokemon' 38 | } else if (!pokemon) { 39 | return 40 | } else { 41 | return 42 | } 43 | } 44 | 45 | function App() { 46 | const [pokemonName, setPokemonName] = React.useState('') 47 | 48 | function handleSubmit(newPokemonName) { 49 | setPokemonName(newPokemonName) 50 | } 51 | 52 | return ( 53 |
54 | 55 |
56 |
57 | 58 |
59 |
60 | ) 61 | } 62 | 63 | export default App 64 | -------------------------------------------------------------------------------- /src/__tests__/02.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/02' 5 | // import App from '../exercise/02' 6 | 7 | afterEach(() => { 8 | window.localStorage.removeItem('name') 9 | }) 10 | 11 | test('App works', () => { 12 | const {rerender} = render() 13 | userEvent.type(screen.getByRole('textbox', {name: /name/i}), 'bob') 14 | const lsName = window.localStorage.getItem('name') 15 | 16 | // extra credit 4 serializes the value in localStorage so there's a bit of a 17 | // variation here. 18 | const isSerialized = lsName === '"bob"' 19 | if (isSerialized) { 20 | expect(screen.getByText(/hello.*bob/i)).toBeInTheDocument() 21 | } else if (lsName === 'bob') { 22 | expect(screen.getByText(/hello.*bob/i)).toBeInTheDocument() 23 | } else { 24 | throw new Error( 25 | `🚨 localStorage is not getting updated with the text that's typed. Be sure to call window.localStorage.setItem('name', name) in a useEffect callback that runs whenever the name changes.`, 26 | ) 27 | } 28 | 29 | // make sure it's initialized properly 30 | window.localStorage.setItem('name', isSerialized ? '"jill"' : 'jill') 31 | rerender() 32 | const greetingText = screen.getByText(/hello/i).textContent 33 | if (!greetingText.includes('jill')) { 34 | throw new Error( 35 | `🚨 the app is not initialized with the name that's in localStorage. Make sure useState is called with the value in localStorage.`, 36 | ) 37 | } 38 | if (greetingText.includes('"')) { 39 | throw new Error( 40 | `🚨 the value in localStorage is not getting deserialized properly. Make sure the value is deserialized when read from localStorage.`, 41 | ) 42 | } 43 | expect(screen.getByRole('textbox', {name: /name/i})).toHaveValue('jill') 44 | }) 45 | -------------------------------------------------------------------------------- /src/final/06.extra-2.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // 💯 use a status 3 | // http://localhost:3000/isolated/final/06.extra-2.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonInfoFallback, 9 | PokemonForm, 10 | PokemonDataView, 11 | } from '../pokemon' 12 | 13 | function PokemonInfo({pokemonName}) { 14 | const [status, setStatus] = React.useState('idle') 15 | const [pokemon, setPokemon] = React.useState(null) 16 | const [error, setError] = React.useState(null) 17 | 18 | React.useEffect(() => { 19 | if (!pokemonName) { 20 | return 21 | } 22 | setStatus('pending') 23 | fetchPokemon(pokemonName).then( 24 | pokemon => { 25 | setPokemon(pokemon) 26 | setStatus('resolved') 27 | }, 28 | error => { 29 | setError(error) 30 | setStatus('rejected') 31 | }, 32 | ) 33 | }, [pokemonName]) 34 | 35 | if (status === 'idle') { 36 | return 'Submit a pokemon' 37 | } else if (status === 'pending') { 38 | return 39 | } else if (status === 'rejected') { 40 | return ( 41 |
42 | There was an error:{' '} 43 |
{error.message}
44 |
45 | ) 46 | } else if (status === 'resolved') { 47 | return 48 | } 49 | 50 | throw new Error('This should be impossible') 51 | } 52 | 53 | function App() { 54 | const [pokemonName, setPokemonName] = React.useState('') 55 | 56 | function handleSubmit(newPokemonName) { 57 | setPokemonName(newPokemonName) 58 | } 59 | 60 | return ( 61 |
62 | 63 |
64 |
65 | 66 |
67 |
68 | ) 69 | } 70 | 71 | export default App 72 | -------------------------------------------------------------------------------- /src/final/06.extra-3.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // 💯 store the state in an object 3 | // http://localhost:3000/isolated/final/06.extra-3.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonInfoFallback, 9 | PokemonForm, 10 | PokemonDataView, 11 | } from '../pokemon' 12 | 13 | function PokemonInfo({pokemonName}) { 14 | const [state, setState] = React.useState({ 15 | status: 'idle', 16 | pokemon: null, 17 | error: null, 18 | }) 19 | const {status, pokemon, error} = state 20 | 21 | React.useEffect(() => { 22 | if (!pokemonName) { 23 | return 24 | } 25 | setState({status: 'pending'}) 26 | fetchPokemon(pokemonName).then( 27 | pokemon => { 28 | setState({status: 'resolved', pokemon}) 29 | }, 30 | error => { 31 | setState({status: 'rejected', error}) 32 | }, 33 | ) 34 | }, [pokemonName]) 35 | 36 | if (status === 'idle') { 37 | return 'Submit a pokemon' 38 | } else if (status === 'pending') { 39 | return 40 | } else if (status === 'rejected') { 41 | return ( 42 |
43 | There was an error:{' '} 44 |
{error.message}
45 |
46 | ) 47 | } else if (status === 'resolved') { 48 | return 49 | } 50 | 51 | throw new Error('This should be impossible') 52 | } 53 | 54 | function App() { 55 | const [pokemonName, setPokemonName] = React.useState('') 56 | 57 | function handleSubmit(newPokemonName) { 58 | setPokemonName(newPokemonName) 59 | } 60 | 61 | return ( 62 |
63 | 64 |
65 |
66 | 67 |
68 |
69 | ) 70 | } 71 | 72 | export default App 73 | -------------------------------------------------------------------------------- /src/__tests__/04.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/04' 5 | // import App from '../exercise/04' 6 | 7 | test('can play a game of tic tac toe', () => { 8 | render() 9 | // prettier-ignore 10 | const [ 11 | s1, s2, s3, 12 | s4, s5, s6, 13 | s7, s8, s9 // eslint-disable-line no-unused-vars 14 | ] = Array.from(screen.queryAllByRole('button')) 15 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 16 | 17 | userEvent.click(s1) 18 | expect(s1).toHaveTextContent('X') 19 | 20 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 21 | userEvent.click(s5) 22 | expect(s5).toHaveTextContent('O') 23 | 24 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 25 | userEvent.click(s9) 26 | expect(s9).toHaveTextContent('X') 27 | 28 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 29 | userEvent.click(s7) 30 | expect(s7).toHaveTextContent('O') 31 | 32 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 33 | userEvent.click(s3) 34 | expect(s3).toHaveTextContent('X') 35 | 36 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 37 | userEvent.click(s2) 38 | expect(s2).toHaveTextContent('O') 39 | 40 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 41 | userEvent.click(s6) 42 | expect(s6).toHaveTextContent('X') 43 | 44 | // game is over so no more moves may be played 45 | expect(screen.getByText('Winner: X')).toBeInTheDocument() 46 | userEvent.click(s4) 47 | expect(s4).toHaveTextContent('') 48 | }) 49 | 50 | test('does not change square value when it is clicked multiple times', () => { 51 | render() 52 | const [square1] = Array.from(screen.queryAllByRole('button')) 53 | 54 | userEvent.click(square1) 55 | userEvent.click(square1) 56 | expect(square1).toHaveTextContent('X') 57 | }) 58 | -------------------------------------------------------------------------------- /.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/final/02.extra-4.js: -------------------------------------------------------------------------------- 1 | // useEffect: persistent state 2 | // 💯 flexible localStorage hook 3 | // http://localhost:3000/isolated/final/02.extra-4.js 4 | 5 | import * as React from 'react' 6 | 7 | function useLocalStorageState( 8 | key, 9 | defaultValue = '', 10 | // the = {} fixes the error we would get from destructuring when no argument was passed 11 | // Check https://jacobparis.com/blog/destructure-arguments for a detailed explanation 12 | {serialize = JSON.stringify, deserialize = JSON.parse} = {}, 13 | ) { 14 | const [state, setState] = React.useState(() => { 15 | const valueInLocalStorage = window.localStorage.getItem(key) 16 | if (valueInLocalStorage) { 17 | // the try/catch is here in case the localStorage value was set before 18 | // we had the serialization in place (like we do in previous extra credits) 19 | try { 20 | return deserialize(valueInLocalStorage) 21 | } catch (error) { 22 | window.localStorage.removeItem(key) 23 | } 24 | } 25 | return typeof defaultValue === 'function' ? defaultValue() : defaultValue 26 | }) 27 | 28 | const prevKeyRef = React.useRef(key) 29 | 30 | // Check the example at src/examples/local-state-key-change.js to visualize a key change 31 | React.useEffect(() => { 32 | const prevKey = prevKeyRef.current 33 | if (prevKey !== key) { 34 | window.localStorage.removeItem(prevKey) 35 | } 36 | prevKeyRef.current = key 37 | window.localStorage.setItem(key, serialize(state)) 38 | }, [key, state, serialize]) 39 | 40 | return [state, setState] 41 | } 42 | 43 | function Greeting({initialName = ''}) { 44 | const [name, setName] = useLocalStorageState('name', initialName) 45 | 46 | function handleChange(event) { 47 | setName(event.target.value) 48 | } 49 | 50 | return ( 51 |
52 |
53 | 54 | 55 |
56 | {name ? Hello {name} : 'Please type your name'} 57 |
58 | ) 59 | } 60 | 61 | function App() { 62 | return 63 | } 64 | 65 | export default App 66 | -------------------------------------------------------------------------------- /src/__tests__/04.extra-1.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/04.extra-1' 6 | // import App from '../exercise/04' 7 | 8 | test('can play a game of tic tac toe', () => { 9 | const {container} = render() 10 | // prettier-ignore 11 | const [ 12 | s1, s2, s3, 13 | s4, s5, s6, 14 | s7, s8, s9 // eslint-disable-line no-unused-vars 15 | ] = Array.from(container.querySelectorAll('button')) 16 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 17 | 18 | userEvent.click(s1) 19 | expect(s1).toHaveTextContent('X') 20 | 21 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 22 | userEvent.click(s5) 23 | expect(s5).toHaveTextContent('O') 24 | 25 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 26 | userEvent.click(s9) 27 | expect(s9).toHaveTextContent('X') 28 | 29 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 30 | userEvent.click(s7) 31 | expect(s7).toHaveTextContent('O') 32 | 33 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 34 | userEvent.click(s3) 35 | expect(s3).toHaveTextContent('X') 36 | 37 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 38 | userEvent.click(s2) 39 | expect(s2).toHaveTextContent('O') 40 | 41 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 42 | userEvent.click(s6) 43 | expect(s6).toHaveTextContent('X') 44 | 45 | // game is over so no more moves may be played 46 | expect(screen.getByText('Winner: X')).toBeInTheDocument() 47 | userEvent.click(s4) 48 | expect(s4).toHaveTextContent('') 49 | 50 | alfredTip( 51 | () => 52 | expect(JSON.parse(window.localStorage.getItem('squares'))).toEqual( 53 | // prettier-ignore 54 | [ 55 | 'X', 'O', 'X', 56 | null, 'O', 'X', 57 | 'O', null, 'X', 58 | ], 59 | ), 60 | 'Make sure that the "squares" localStorage item is updated with the JSON.stringified squares', 61 | ) 62 | }) 63 | -------------------------------------------------------------------------------- /src/final/06.extra-6.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // 💯 use react-error-boundary 3 | // http://localhost:3000/isolated/final/06.extra-6.js 4 | 5 | import * as React from 'react' 6 | import {ErrorBoundary} from 'react-error-boundary' 7 | import { 8 | fetchPokemon, 9 | PokemonInfoFallback, 10 | PokemonForm, 11 | PokemonDataView, 12 | } from '../pokemon' 13 | 14 | function PokemonInfo({pokemonName}) { 15 | const [state, setState] = React.useState({ 16 | status: 'idle', 17 | pokemon: null, 18 | error: null, 19 | }) 20 | const {status, pokemon, error} = state 21 | 22 | React.useEffect(() => { 23 | if (!pokemonName) { 24 | return 25 | } 26 | setState({status: 'pending'}) 27 | fetchPokemon(pokemonName).then( 28 | pokemon => { 29 | setState({status: 'resolved', pokemon}) 30 | }, 31 | error => { 32 | setState({status: 'rejected', error}) 33 | }, 34 | ) 35 | }, [pokemonName]) 36 | 37 | if (status === 'idle') { 38 | return 'Submit a pokemon' 39 | } else if (status === 'pending') { 40 | return 41 | } else if (status === 'rejected') { 42 | // this will be handled by an error boundary 43 | throw error 44 | } else if (status === 'resolved') { 45 | return 46 | } 47 | 48 | throw new Error('This should be impossible') 49 | } 50 | 51 | function ErrorFallback({error}) { 52 | return ( 53 |
54 | There was an error:{' '} 55 |
{error.message}
56 |
57 | ) 58 | } 59 | 60 | function App() { 61 | const [pokemonName, setPokemonName] = React.useState('') 62 | 63 | function handleSubmit(newPokemonName) { 64 | setPokemonName(newPokemonName) 65 | } 66 | 67 | return ( 68 |
69 | 70 |
71 |
72 | 73 | 74 | 75 |
76 |
77 | ) 78 | } 79 | 80 | export default App 81 | -------------------------------------------------------------------------------- /src/exercise/03.md: -------------------------------------------------------------------------------- 1 | # Lifting state 2 | 3 | ## Recordings 4 | [Exercise](https://drive.google.com/file/d/1IgINnQD3vxXRQTZpE3IKmYOiEqgR2H6V/view?usp=sharing) 5 | [Extra Credit 1](https://drive.google.com/file/d/1vP7CTwuvomkt3rFSfJOZYZBjHSguV1S2/view?usp=sharing) 6 | ## 📝 Your Notes 7 | 8 | Elaborate on your learnings here in `src/exercise/03.md` 9 | 10 | ## Background 11 | 12 | A common question from React beginners is how to share state between two sibling 13 | components. The answer is to 14 | ["lift the state"](https://reactjs.org/docs/lifting-state-up.html) which 15 | basically amounts to finding the lowest common parent shared between the two 16 | components and placing the state management there, and then passing the state 17 | and a mechanism for updating that state down into the components that need it. 18 | 19 | ## Exercise 20 | 21 | Production deploys: 22 | 23 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/03.js) 24 | - [Final](https://react-hooks.netlify.app/isolated/final/03.js) 25 | 26 | 👨‍💼 Peter told us we've got a new feature request for the `Display` component. He 27 | wants us to display the `animal` the user selects. But that state is managed in 28 | a "sibling" component, so we have to move that management to the least common 29 | parent (`App`) and then pass it down. 30 | 31 | ## Extra Credit 32 | 33 | ### 1. 💯 colocating state 34 | 35 | [Production deploy](https://react-hooks.netlify.app/isolated/final/03.extra-1.js) 36 | 37 | As a community we’re pretty good at lifting state. It becomes natural over time. 38 | One thing that we typically have trouble remembering to do is to push state back 39 | down (or 40 | [colocate state](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster)). 41 | 42 | 👨‍💼 Peter told us that now users only want the animal displayed instead of the 43 | name: 44 | 45 | ```javascript 46 | function Display({animal}) { 47 | return
{`Your favorite animal is: ${animal}!`}
48 | } 49 | ``` 50 | 51 | You'll notice that just updating the `Display` component to this works fine, but 52 | for the extra credit, go through the process of moving state to the components 53 | that need it. You know what you just did for the `Animal` component? You need to 54 | do the opposite thing for the `Name` component. 55 | 56 | ## 🦉 Feedback 57 | 58 | Fill out 59 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=03%3A%20Lifting%20state&em=). 60 | -------------------------------------------------------------------------------- /src/final/06.extra-7.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // 💯 reset the error boundary 3 | // http://localhost:3000/isolated/final/06.extra-7.js 4 | 5 | import * as React from 'react' 6 | import {ErrorBoundary} from 'react-error-boundary' 7 | import { 8 | fetchPokemon, 9 | PokemonInfoFallback, 10 | PokemonForm, 11 | PokemonDataView, 12 | } from '../pokemon' 13 | 14 | function PokemonInfo({pokemonName}) { 15 | const [state, setState] = React.useState({ 16 | status: pokemonName ? 'pending' : 'idle', 17 | pokemon: null, 18 | error: null, 19 | }) 20 | const {status, pokemon, error} = state 21 | 22 | React.useEffect(() => { 23 | if (!pokemonName) { 24 | return 25 | } 26 | setState({status: 'pending'}) 27 | fetchPokemon(pokemonName).then( 28 | pokemon => { 29 | setState({status: 'resolved', pokemon}) 30 | }, 31 | error => { 32 | setState({status: 'rejected', error}) 33 | }, 34 | ) 35 | }, [pokemonName]) 36 | 37 | if (status === 'idle') { 38 | return 'Submit a pokemon' 39 | } else if (status === 'pending') { 40 | return 41 | } else if (status === 'rejected') { 42 | // this will be handled by an error boundary 43 | throw error 44 | } else if (status === 'resolved') { 45 | return 46 | } 47 | 48 | throw new Error('This should be impossible') 49 | } 50 | 51 | function ErrorFallback({error, resetErrorBoundary}) { 52 | return ( 53 |
54 | There was an error:{' '} 55 |
{error.message}
56 | 57 |
58 | ) 59 | } 60 | 61 | function App() { 62 | const [pokemonName, setPokemonName] = React.useState('') 63 | 64 | function handleSubmit(newPokemonName) { 65 | setPokemonName(newPokemonName) 66 | } 67 | 68 | function handleReset() { 69 | setPokemonName('') 70 | } 71 | 72 | return ( 73 |
74 | 75 |
76 |
77 | 78 | 79 | 80 |
81 |
82 | ) 83 | } 84 | 85 | export default App 86 | -------------------------------------------------------------------------------- /src/final/06.extra-8.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // 💯 use resetKeys 3 | // http://localhost:3000/isolated/final/06.extra-8.js 4 | 5 | import * as React from 'react' 6 | import {ErrorBoundary} from 'react-error-boundary' 7 | import { 8 | fetchPokemon, 9 | PokemonInfoFallback, 10 | PokemonForm, 11 | PokemonDataView, 12 | } from '../pokemon' 13 | 14 | function PokemonInfo({pokemonName}) { 15 | const [state, setState] = React.useState({ 16 | status: pokemonName ? 'pending' : 'idle', 17 | pokemon: null, 18 | error: null, 19 | }) 20 | const {status, pokemon, error} = state 21 | 22 | React.useEffect(() => { 23 | if (!pokemonName) { 24 | return 25 | } 26 | setState({status: 'pending'}) 27 | fetchPokemon(pokemonName).then( 28 | pokemon => { 29 | setState({status: 'resolved', pokemon}) 30 | }, 31 | error => { 32 | setState({status: 'rejected', error}) 33 | }, 34 | ) 35 | }, [pokemonName]) 36 | 37 | if (status === 'idle') { 38 | return 'Submit a pokemon' 39 | } else if (status === 'pending') { 40 | return 41 | } else if (status === 'rejected') { 42 | // this will be handled by an error boundary 43 | throw error 44 | } else if (status === 'resolved') { 45 | return 46 | } 47 | 48 | throw new Error('This should be impossible') 49 | } 50 | 51 | function ErrorFallback({error, resetErrorBoundary}) { 52 | return ( 53 |
54 | There was an error:{' '} 55 |
{error.message}
56 | 57 |
58 | ) 59 | } 60 | 61 | function App() { 62 | const [pokemonName, setPokemonName] = React.useState('') 63 | 64 | function handleSubmit(newPokemonName) { 65 | setPokemonName(newPokemonName) 66 | } 67 | 68 | function handleReset() { 69 | setPokemonName('') 70 | } 71 | 72 | return ( 73 |
74 | 75 |
76 |
77 | 82 | 83 | 84 |
85 |
86 | ) 87 | } 88 | 89 | export default App 90 | -------------------------------------------------------------------------------- /src/final/06.extra-4.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // 💯 create an ErrorBoundary component 3 | // http://localhost:3000/isolated/final/06.extra-4.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonInfoFallback, 9 | PokemonForm, 10 | PokemonDataView, 11 | } from '../pokemon' 12 | 13 | class ErrorBoundary extends React.Component { 14 | state = {error: null} 15 | static getDerivedStateFromError(error) { 16 | return {error} 17 | } 18 | render() { 19 | const {error} = this.state 20 | if (error) { 21 | return 22 | } 23 | 24 | return this.props.children 25 | } 26 | } 27 | 28 | function PokemonInfo({pokemonName}) { 29 | const [state, setState] = React.useState({ 30 | status: 'idle', 31 | pokemon: null, 32 | error: null, 33 | }) 34 | const {status, pokemon, error} = state 35 | 36 | React.useEffect(() => { 37 | if (!pokemonName) { 38 | return 39 | } 40 | setState({status: 'pending'}) 41 | fetchPokemon(pokemonName).then( 42 | pokemon => { 43 | setState({status: 'resolved', pokemon}) 44 | }, 45 | error => { 46 | setState({status: 'rejected', error}) 47 | }, 48 | ) 49 | }, [pokemonName]) 50 | 51 | if (status === 'idle') { 52 | return 'Submit a pokemon' 53 | } else if (status === 'pending') { 54 | return 55 | } else if (status === 'rejected') { 56 | // this will be handled by an error boundary 57 | throw error 58 | } else if (status === 'resolved') { 59 | return 60 | } 61 | 62 | throw new Error('This should be impossible') 63 | } 64 | 65 | function ErrorFallback({error}) { 66 | return ( 67 |
68 | There was an error:{' '} 69 |
{error.message}
70 |
71 | ) 72 | } 73 | 74 | function App() { 75 | const [pokemonName, setPokemonName] = React.useState('') 76 | 77 | function handleSubmit(newPokemonName) { 78 | setPokemonName(newPokemonName) 79 | } 80 | 81 | return ( 82 |
83 | 84 |
85 |
86 | 87 | 88 | 89 |
90 |
91 | ) 92 | } 93 | 94 | export default App 95 | -------------------------------------------------------------------------------- /src/final/06.extra-5.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // 💯 re-mount the error boundary 3 | // http://localhost:3000/isolated/final/06.extra-5.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonInfoFallback, 9 | PokemonForm, 10 | PokemonDataView, 11 | } from '../pokemon' 12 | 13 | class ErrorBoundary extends React.Component { 14 | state = {error: null} 15 | static getDerivedStateFromError(error) { 16 | return {error} 17 | } 18 | render() { 19 | const {error} = this.state 20 | if (error) { 21 | return 22 | } 23 | 24 | return this.props.children 25 | } 26 | } 27 | 28 | function PokemonInfo({pokemonName}) { 29 | const [state, setState] = React.useState({ 30 | status: 'idle', 31 | pokemon: null, 32 | error: null, 33 | }) 34 | const {status, pokemon, error} = state 35 | 36 | React.useEffect(() => { 37 | if (!pokemonName) { 38 | return 39 | } 40 | setState({status: 'pending'}) 41 | fetchPokemon(pokemonName).then( 42 | pokemon => { 43 | setState({status: 'resolved', pokemon}) 44 | }, 45 | error => { 46 | setState({status: 'rejected', error}) 47 | }, 48 | ) 49 | }, [pokemonName]) 50 | 51 | if (status === 'idle') { 52 | return 'Submit a pokemon' 53 | } else if (status === 'pending') { 54 | return 55 | } else if (status === 'rejected') { 56 | // this will be handled by an error boundary 57 | throw error 58 | } else if (status === 'resolved') { 59 | return 60 | } 61 | 62 | throw new Error('This should be impossible') 63 | } 64 | 65 | function ErrorFallback({error}) { 66 | return ( 67 |
68 | There was an error:{' '} 69 |
{error.message}
70 |
71 | ) 72 | } 73 | 74 | function App() { 75 | const [pokemonName, setPokemonName] = React.useState('') 76 | 77 | function handleSubmit(newPokemonName) { 78 | setPokemonName(newPokemonName) 79 | } 80 | 81 | return ( 82 |
83 | 84 |
85 |
86 | 87 | 88 | 89 |
90 |
91 | ) 92 | } 93 | 94 | export default App 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hooks", 3 | "title": "React Hooks 🎣", 4 | "description": "The best resources for you to learn React Hooks", 5 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 6 | "version": "1.0.0", 7 | "private": true, 8 | "keywords": [], 9 | "homepage": "https://react-hooks.netlify.app/", 10 | "license": "GPL-3.0-only", 11 | "main": "src/index.js", 12 | "engines": { 13 | "node": "12 || 14 || 15 || 16", 14 | "npm": ">=6" 15 | }, 16 | "dependencies": { 17 | "@kentcdodds/react-workshop-app": "^4.3.0", 18 | "@testing-library/react": "^11.2.6", 19 | "@testing-library/user-event": "^13.1.9", 20 | "codegen.macro": "^4.1.0", 21 | "react": "^17.0.2", 22 | "react-dom": "^17.0.2", 23 | "react-error-boundary": "^3.1.3", 24 | "vanilla-tilt": "^1.7.0" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^17.0.5", 28 | "@types/react-dom": "^17.0.4", 29 | "husky": "^4.3.8", 30 | "npm-run-all": "^4.1.5", 31 | "prettier": "^2.3.0", 32 | "react-scripts": "^4.0.3", 33 | "typescript": "^4.2.4" 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/react-hooks.git" 81 | }, 82 | "bugs": { 83 | "url": "https://github.com/kentcdodds/react-hooks/issues" 84 | }, 85 | "msw": { 86 | "workerDirectory": "public" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/exercise/06.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // http://localhost:3000/isolated/exercise/06.js 3 | 4 | import * as React from 'react' 5 | import { 6 | fetchPokemon, // the function we call to get the pokemon info 7 | PokemonDataView, // the stuff we use to display the pokemon info 8 | PokemonInfoFallback, // the thing we show while we're loading the pokemon info 9 | PokemonForm 10 | } from '../pokemon' 11 | 12 | const states = { 13 | idle: 'idle', 14 | pending: 'pending', 15 | resolved: 'resolved', 16 | rejected: 'rejected', 17 | }; 18 | 19 | function PokemonInfo({pokemonName}) { 20 | const [state, setState] = React.useState({ 21 | status: states.idle, 22 | pokemon: null, 23 | error: null, 24 | }); 25 | 26 | React.useEffect(() => { 27 | if (!pokemonName || '' === pokemonName) { 28 | return; 29 | } 30 | setState({ 31 | status: states.pending, 32 | pokemon: null, 33 | error: null, 34 | }); 35 | fetchPokemon(pokemonName) 36 | .then( 37 | pokemonData => { 38 | setState({ 39 | status: states.resolved, 40 | pokemon: pokemonData, 41 | error: null, 42 | }); 43 | }, 44 | ) 45 | .catch( 46 | error => { 47 | setState({ 48 | status: states.rejected, 49 | pokemon: null, 50 | error: error, 51 | }); 52 | }, 53 | ) 54 | }, [pokemonName]); 55 | 56 | switch (state.status) { 57 | case states.idle: { 58 | return 'Submit a pokemon'; 59 | } 60 | case states.pending: { 61 | return 62 | } 63 | case states.resolved: { 64 | return ; 65 | } 66 | case states.rejected: { 67 | return ( 68 |
69 | There was an error:
{state.error.message}
70 |
71 | ); 72 | } 73 | default: { 74 | return 'Submit a pokemon'; 75 | } 76 | } 77 | } 78 | 79 | function App() { 80 | const [pokemonName, setPokemonName] = React.useState('') 81 | 82 | function handleSubmit(newPokemonName) { 83 | setPokemonName(newPokemonName) 84 | } 85 | 86 | return ( 87 |
88 | 89 |
90 |
91 | 92 |
93 |
94 | ) 95 | } 96 | 97 | export default App 98 | -------------------------------------------------------------------------------- /src/final/04.js: -------------------------------------------------------------------------------- 1 | // useState: tic tac toe 2 | // http://localhost:3000/isolated/final/04.js 3 | 4 | import * as React from 'react' 5 | 6 | function Board() { 7 | const [squares, setSquares] = React.useState(Array(9).fill(null)) 8 | 9 | const nextValue = calculateNextValue(squares) 10 | const winner = calculateWinner(squares) 11 | const status = calculateStatus(winner, squares, nextValue) 12 | 13 | function selectSquare(square) { 14 | if (winner || squares[square]) { 15 | return 16 | } 17 | const squaresCopy = [...squares] 18 | squaresCopy[square] = nextValue 19 | setSquares(squaresCopy) 20 | } 21 | 22 | function restart() { 23 | setSquares(Array(9).fill(null)) 24 | } 25 | 26 | function renderSquare(i) { 27 | return ( 28 | 31 | ) 32 | } 33 | 34 | return ( 35 |
36 |
{status}
37 |
38 | {renderSquare(0)} 39 | {renderSquare(1)} 40 | {renderSquare(2)} 41 |
42 |
43 | {renderSquare(3)} 44 | {renderSquare(4)} 45 | {renderSquare(5)} 46 |
47 |
48 | {renderSquare(6)} 49 | {renderSquare(7)} 50 | {renderSquare(8)} 51 |
52 | 55 |
56 | ) 57 | } 58 | 59 | function Game() { 60 | return ( 61 |
62 |
63 | 64 |
65 |
66 | ) 67 | } 68 | 69 | function calculateStatus(winner, squares, nextValue) { 70 | return winner 71 | ? `Winner: ${winner}` 72 | : squares.every(Boolean) 73 | ? `Scratch: Cat's game` 74 | : `Next player: ${nextValue}` 75 | } 76 | 77 | function calculateNextValue(squares) { 78 | return squares.filter(Boolean).length % 2 === 0 ? 'X' : 'O' 79 | } 80 | 81 | function calculateWinner(squares) { 82 | const lines = [ 83 | [0, 1, 2], 84 | [3, 4, 5], 85 | [6, 7, 8], 86 | [0, 3, 6], 87 | [1, 4, 7], 88 | [2, 5, 8], 89 | [0, 4, 8], 90 | [2, 4, 6], 91 | ] 92 | for (let i = 0; i < lines.length; i++) { 93 | const [a, b, c] = lines[i] 94 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 95 | return squares[a] 96 | } 97 | } 98 | return null 99 | } 100 | 101 | function App() { 102 | return 103 | } 104 | 105 | export default App 106 | -------------------------------------------------------------------------------- /src/exercise/05.md: -------------------------------------------------------------------------------- 1 | # useRef and useEffect: DOM interaction 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/05.md` 6 | 7 | ## Background 8 | 9 | Often when working with React you'll need to integrate with UI libraries. Some 10 | of these need to work directly with the DOM. Remember that when you do: 11 | `
hi
` that's actually syntactic sugar for a `React.createElement` so 12 | you don't actually have access to DOM nodes in your render method. In fact, DOM 13 | nodes aren't created at all until the `ReactDOM.render` method is called. Your 14 | component's render method is really just responsible for creating and returning 15 | React Elements and has nothing to do with the DOM in particular. 16 | 17 | So to get access to the DOM, you need to ask React to give you access to a 18 | particular DOM node when it renders your component. The way this happens is 19 | through a special prop called `ref`. 20 | 21 | Here's a simple example of using the `ref` prop: 22 | 23 | ```javascript 24 | function MyDiv() { 25 | const myDivRef = React.useRef() 26 | React.useEffect(() => { 27 | const myDiv = myDivRef.current 28 | // myDiv is the div DOM node! 29 | console.log(myDiv) 30 | }, []) 31 | return
hi
32 | } 33 | ``` 34 | 35 | After the component has been rendered, it's considered "mounted." That's when 36 | the React.useEffect callback is called and so by that point, the ref should have 37 | its `current` property set to the DOM node. So often you'll do direct DOM 38 | interactions/manipulations in the `useEffect` callback. 39 | 40 | ## Exercise 41 | 42 | Production deploys: 43 | 44 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/05.js) 45 | - [Final](https://react-hooks.netlify.app/isolated/final/05.js) 46 | 47 | In this exercise we're going to make a `` component that renders a div 48 | and uses the `vanilla-tilt` library to make it super fancy. 49 | 50 | The thing is, `vanilla-tilt` works directly with DOM nodes to setup event 51 | handlers and stuff, so we need access to the DOM node. But because we're not the 52 | one calling `document.createElement` (React does) we need React to give it to 53 | us. 54 | 55 | So in this exercise we're going to use a `ref` so React can give us the DOM node 56 | and then we can pass that on to `vanilla-tilt`. 57 | 58 | Additionally, we'll need to clean up after ourselves if this component is 59 | unmounted. Otherwise we'll have event handlers dangling around on DOM nodes that 60 | are no longer in the document. 61 | 62 | ### Alternate: 63 | 64 | If you'd prefer to practice refactoring a class that does this to a hook, then 65 | you can open `src/exercise/05-classes.js` and open that on 66 | [an isolated page](http://localhost:3000/isolated/exercise/05-classes.js) to 67 | practice that. 68 | 69 | ## 🦉 Feedback 70 | 71 | Fill out 72 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=05%3A%20useRef%20and%20useEffect%3A%20DOM%20interaction&em=). 73 | -------------------------------------------------------------------------------- /src/final/04.extra-2.js: -------------------------------------------------------------------------------- 1 | // useState: tic tac toe 2 | // 💯 useLocalStorageState 3 | // http://localhost:3000/isolated/final/04.extra-2.js 4 | 5 | import * as React from 'react' 6 | import {useLocalStorageState} from '../utils' 7 | 8 | function Board() { 9 | const [squares, setSquares] = useLocalStorageState( 10 | 'squares', 11 | Array(9).fill(null), 12 | ) 13 | 14 | const nextValue = calculateNextValue(squares) 15 | const winner = calculateWinner(squares) 16 | const status = calculateStatus(winner, squares, nextValue) 17 | 18 | function selectSquare(square) { 19 | if (winner || squares[square]) { 20 | return 21 | } 22 | const squaresCopy = [...squares] 23 | squaresCopy[square] = nextValue 24 | setSquares(squaresCopy) 25 | } 26 | 27 | function restart() { 28 | setSquares(Array(9).fill(null)) 29 | } 30 | 31 | function renderSquare(i) { 32 | return ( 33 | 36 | ) 37 | } 38 | 39 | return ( 40 |
41 |
{status}
42 |
43 | {renderSquare(0)} 44 | {renderSquare(1)} 45 | {renderSquare(2)} 46 |
47 |
48 | {renderSquare(3)} 49 | {renderSquare(4)} 50 | {renderSquare(5)} 51 |
52 |
53 | {renderSquare(6)} 54 | {renderSquare(7)} 55 | {renderSquare(8)} 56 |
57 | 60 |
61 | ) 62 | } 63 | 64 | function Game() { 65 | return ( 66 |
67 |
68 | 69 |
70 |
71 | ) 72 | } 73 | 74 | function calculateStatus(winner, squares, nextValue) { 75 | return winner 76 | ? `Winner: ${winner}` 77 | : squares.every(Boolean) 78 | ? `Scratch: Cat's game` 79 | : `Next player: ${nextValue}` 80 | } 81 | 82 | function calculateNextValue(squares) { 83 | const xSquaresCount = squares.filter(r => r === 'X').length 84 | const oSquaresCount = squares.filter(r => r === 'O').length 85 | return oSquaresCount === xSquaresCount ? 'X' : 'O' 86 | } 87 | 88 | function calculateWinner(squares) { 89 | const lines = [ 90 | [0, 1, 2], 91 | [3, 4, 5], 92 | [6, 7, 8], 93 | [0, 3, 6], 94 | [1, 4, 7], 95 | [2, 5, 8], 96 | [0, 4, 8], 97 | [2, 4, 6], 98 | ] 99 | for (let i = 0; i < lines.length; i++) { 100 | const [a, b, c] = lines[i] 101 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 102 | return squares[a] 103 | } 104 | } 105 | return null 106 | } 107 | 108 | function App() { 109 | return 110 | } 111 | 112 | export default App 113 | -------------------------------------------------------------------------------- /src/final/04.extra-1.js: -------------------------------------------------------------------------------- 1 | // useState: tic tac toe 2 | // 💯 preserve state in localStorage 3 | // http://localhost:3000/isolated/final/04.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | function Board() { 8 | const [squares, setSquares] = React.useState( 9 | () => 10 | JSON.parse(window.localStorage.getItem('squares')) || Array(9).fill(null), 11 | ) 12 | 13 | React.useEffect(() => { 14 | window.localStorage.setItem('squares', JSON.stringify(squares)) 15 | }, [squares]) 16 | 17 | const nextValue = calculateNextValue(squares) 18 | const winner = calculateWinner(squares) 19 | const status = calculateStatus(winner, squares, nextValue) 20 | 21 | function selectSquare(square) { 22 | if (winner || squares[square]) { 23 | return 24 | } 25 | const squaresCopy = [...squares] 26 | squaresCopy[square] = nextValue 27 | setSquares(squaresCopy) 28 | } 29 | 30 | function restart() { 31 | setSquares(Array(9).fill(null)) 32 | } 33 | 34 | function renderSquare(i) { 35 | return ( 36 | 39 | ) 40 | } 41 | 42 | return ( 43 |
44 |
{status}
45 |
46 | {renderSquare(0)} 47 | {renderSquare(1)} 48 | {renderSquare(2)} 49 |
50 |
51 | {renderSquare(3)} 52 | {renderSquare(4)} 53 | {renderSquare(5)} 54 |
55 |
56 | {renderSquare(6)} 57 | {renderSquare(7)} 58 | {renderSquare(8)} 59 |
60 | 63 |
64 | ) 65 | } 66 | 67 | function Game() { 68 | return ( 69 |
70 |
71 | 72 |
73 |
74 | ) 75 | } 76 | 77 | function calculateStatus(winner, squares, nextValue) { 78 | return winner 79 | ? `Winner: ${winner}` 80 | : squares.every(Boolean) 81 | ? `Scratch: Cat's game` 82 | : `Next player: ${nextValue}` 83 | } 84 | 85 | function calculateNextValue(squares) { 86 | const xSquaresCount = squares.filter(r => r === 'X').length 87 | const oSquaresCount = squares.filter(r => r === 'O').length 88 | return oSquaresCount === xSquaresCount ? 'X' : 'O' 89 | } 90 | 91 | function calculateWinner(squares) { 92 | const lines = [ 93 | [0, 1, 2], 94 | [3, 4, 5], 95 | [6, 7, 8], 96 | [0, 3, 6], 97 | [1, 4, 7], 98 | [2, 5, 8], 99 | [0, 4, 8], 100 | [2, 4, 6], 101 | ] 102 | for (let i = 0; i < lines.length; i++) { 103 | const [a, b, c] = lines[i] 104 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 105 | return squares[a] 106 | } 107 | } 108 | return null 109 | } 110 | 111 | function App() { 112 | return 113 | } 114 | 115 | export default App 116 | -------------------------------------------------------------------------------- /src/exercise/04.js: -------------------------------------------------------------------------------- 1 | // useState: tic tac toe 2 | // http://localhost:3000/isolated/exercise/04.js 3 | 4 | import * as React from 'react' 5 | import { useLocalStorageState } from '../utils'; 6 | 7 | function Board() { 8 | const [squares, setSquares] = useLocalStorageState('squares', Array(9).fill(null)); 9 | const nextValue = calculateNextValue(squares); 10 | const winner = calculateWinner(squares); 11 | const status = calculateStatus(winner, squares, nextValue); 12 | 13 | React.useEffect(() => { 14 | window.localStorage.setItem('squares', JSON.stringify(squares)) 15 | }, [squares]) 16 | 17 | function selectSquare(square) { 18 | if (winner || squares[square]) { 19 | return; 20 | } 21 | 22 | const squaresCopy = [...squares]; 23 | squaresCopy[square] = nextValue; 24 | setSquares(squaresCopy); 25 | } 26 | 27 | function restart() { 28 | setSquares(Array(9).fill(null)); 29 | } 30 | 31 | function renderSquare(i) { 32 | return ( 33 | 36 | ) 37 | } 38 | 39 | return ( 40 |
41 | {/* 🐨 put the status in the div below */} 42 |
{status}
43 |
44 | {renderSquare(0)} 45 | {renderSquare(1)} 46 | {renderSquare(2)} 47 |
48 |
49 | {renderSquare(3)} 50 | {renderSquare(4)} 51 | {renderSquare(5)} 52 |
53 |
54 | {renderSquare(6)} 55 | {renderSquare(7)} 56 | {renderSquare(8)} 57 |
58 | 61 |
62 | ) 63 | } 64 | 65 | function Game() { 66 | return ( 67 |
68 |
69 | 70 |
71 |
72 | ) 73 | } 74 | 75 | // eslint-disable-next-line no-unused-vars 76 | function calculateStatus(winner, squares, nextValue) { 77 | return winner 78 | ? `Winner: ${winner}` 79 | : squares.every(Boolean) 80 | ? `Scratch: Cat's game` 81 | : `Next player: ${nextValue}` 82 | } 83 | 84 | // eslint-disable-next-line no-unused-vars 85 | function calculateNextValue(squares) { 86 | return squares.filter(Boolean).length % 2 === 0 ? 'X' : 'O' 87 | } 88 | 89 | // eslint-disable-next-line no-unused-vars 90 | function calculateWinner(squares) { 91 | const lines = [ 92 | [0, 1, 2], 93 | [3, 4, 5], 94 | [6, 7, 8], 95 | [0, 3, 6], 96 | [1, 4, 7], 97 | [2, 5, 8], 98 | [0, 4, 8], 99 | [2, 4, 6], 100 | ] 101 | for (let i = 0; i < lines.length; i++) { 102 | const [a, b, c] = lines[i] 103 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 104 | return squares[a] 105 | } 106 | } 107 | return null 108 | } 109 | 110 | function App() { 111 | return 112 | } 113 | 114 | export default App 115 | -------------------------------------------------------------------------------- /src/__tests__/04.extra-3.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/04.extra-3' 6 | // import App from '../exercise/04' 7 | 8 | test('can play a game of tic tac toe', () => { 9 | render() 10 | 11 | // prettier-ignore 12 | const [ 13 | s1, s2, s3, // eslint-disable-line no-unused-vars 14 | s4, s5, s6, // eslint-disable-line no-unused-vars 15 | s7, s8, s9 // eslint-disable-line no-unused-vars 16 | ] = Array.from(screen.queryAllByRole('button')) 17 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 18 | const gameStart = screen.getByText(/go to game start/i) 19 | expect(gameStart).toHaveAttribute('disabled') 20 | expect(gameStart).toHaveTextContent('current') 21 | 22 | userEvent.click(s1) 23 | expect(s1).toHaveTextContent('X') 24 | 25 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 26 | const firstMove = screen.getByText(/go to move #1/i) 27 | expect(gameStart).not.toHaveAttribute('disabled') 28 | expect(gameStart).not.toHaveTextContent('current') 29 | expect(firstMove).toHaveAttribute('disabled') 30 | expect(firstMove).toHaveTextContent('current') 31 | 32 | userEvent.click(s5) 33 | expect(s5).toHaveTextContent('O') 34 | const secondMove = screen.getByText(/go to move #2/i) 35 | expect(gameStart).not.toHaveAttribute('disabled') 36 | expect(gameStart).not.toHaveTextContent('current') 37 | expect(firstMove).not.toHaveAttribute('disabled') 38 | expect(firstMove).not.toHaveTextContent('current') 39 | expect(secondMove).toHaveAttribute('disabled') 40 | expect(secondMove).toHaveTextContent('current') 41 | 42 | userEvent.click(firstMove) 43 | expect(gameStart).not.toHaveAttribute('disabled') 44 | expect(gameStart).not.toHaveTextContent('current') 45 | expect(firstMove).toHaveAttribute('disabled') 46 | expect(firstMove).toHaveTextContent('current') 47 | expect(secondMove).not.toHaveAttribute('disabled') 48 | expect(secondMove).not.toHaveTextContent('current') 49 | expect(s5).not.toHaveTextContent('O') 50 | 51 | alfredTip( 52 | () => 53 | expect( 54 | JSON.parse(window.localStorage.getItem('tic-tac-toe:history')), 55 | ).toEqual( 56 | // prettier-ignore 57 | [ 58 | [null, null, null, 59 | null, null, null, 60 | null, null, null], 61 | ['X', null, null, 62 | null, null, null, 63 | null, null, null], 64 | ['X', null, null, 65 | null, 'O', null, 66 | null, null, null] 67 | ], 68 | ), 69 | 'Make sure that the localStorage item is updated with the JSON.stringified squares array', 70 | ) 71 | 72 | userEvent.click(gameStart) 73 | expect(s1).toHaveTextContent('') 74 | expect(s5).toHaveTextContent('') 75 | expect(screen.queryAllByRole('listitem').length).toBe(3) 76 | 77 | userEvent.click(screen.getByText('restart')) 78 | expect(s1).toHaveTextContent('') 79 | expect(s5).toHaveTextContent('') 80 | expect(screen.queryAllByRole('listitem').length).toBe(1) 81 | 82 | alfredTip( 83 | () => 84 | expect( 85 | JSON.parse(window.localStorage.getItem('tic-tac-toe:history')), 86 | ).toEqual( 87 | // prettier-ignore 88 | [ 89 | [null, null, null, 90 | null, null, null, 91 | null, null, null] 92 | ], 93 | ), 94 | 'Make sure that the localStorage item is updated with the JSON.stringified squares array', 95 | ) 96 | }) 97 | -------------------------------------------------------------------------------- /src/examples/hook-flow.js: -------------------------------------------------------------------------------- 1 | // Hook flow 2 | // https://github.com/donavon/hook-flow 3 | // http://localhost:3000/isolated/examples/hook-flow.js 4 | 5 | // PLEASE NOTE: there was a subtle change in the order of cleanup functions 6 | // getting called in React 17: 7 | // https://github.com/kentcdodds/react-hooks/issues/90 8 | 9 | import * as React from 'react' 10 | 11 | function Child() { 12 | console.log('%c Child: render start', 'color: MediumSpringGreen') 13 | 14 | const [count, setCount] = React.useState(() => { 15 | console.log('%c Child: useState(() => 0)', 'color: tomato') 16 | return 0 17 | }) 18 | 19 | React.useEffect(() => { 20 | console.log('%c Child: useEffect(() => {})', 'color: LightCoral') 21 | return () => { 22 | console.log( 23 | '%c Child: useEffect(() => {}) cleanup 🧹', 24 | 'color: LightCoral', 25 | ) 26 | } 27 | }) 28 | 29 | React.useEffect(() => { 30 | console.log( 31 | '%c Child: useEffect(() => {}, [])', 32 | 'color: MediumTurquoise', 33 | ) 34 | return () => { 35 | console.log( 36 | '%c Child: useEffect(() => {}, []) cleanup 🧹', 37 | 'color: MediumTurquoise', 38 | ) 39 | } 40 | }, []) 41 | 42 | React.useEffect(() => { 43 | console.log('%c Child: useEffect(() => {}, [count])', 'color: HotPink') 44 | return () => { 45 | console.log( 46 | '%c Child: useEffect(() => {}, [count]) cleanup 🧹', 47 | 'color: HotPink', 48 | ) 49 | } 50 | }, [count]) 51 | 52 | const element = ( 53 | 56 | ) 57 | 58 | console.log('%c Child: render end', 'color: MediumSpringGreen') 59 | 60 | return element 61 | } 62 | 63 | function App() { 64 | console.log('%cApp: render start', 'color: MediumSpringGreen') 65 | 66 | const [showChild, setShowChild] = React.useState(() => { 67 | console.log('%cApp: useState(() => false)', 'color: tomato') 68 | return false 69 | }) 70 | 71 | React.useEffect(() => { 72 | console.log('%cApp: useEffect(() => {})', 'color: LightCoral') 73 | return () => { 74 | console.log('%cApp: useEffect(() => {}) cleanup 🧹', 'color: LightCoral') 75 | } 76 | }) 77 | 78 | React.useEffect(() => { 79 | console.log('%cApp: useEffect(() => {}, [])', 'color: MediumTurquoise') 80 | return () => { 81 | console.log( 82 | '%cApp: useEffect(() => {}, []) cleanup 🧹', 83 | 'color: MediumTurquoise', 84 | ) 85 | } 86 | }, []) 87 | 88 | React.useEffect(() => { 89 | console.log('%cApp: useEffect(() => {}, [showChild])', 'color: HotPink') 90 | return () => { 91 | console.log( 92 | '%cApp: useEffect(() => {}, [showChild]) cleanup 🧹', 93 | 'color: HotPink', 94 | ) 95 | } 96 | }, [showChild]) 97 | 98 | const element = ( 99 | <> 100 | 108 |
117 | {showChild ? : null} 118 |
119 | 120 | ) 121 | 122 | console.log('%cApp: render end', 'color: MediumSpringGreen') 123 | 124 | return element 125 | } 126 | 127 | export default App 128 | -------------------------------------------------------------------------------- /src/exercise/01.md: -------------------------------------------------------------------------------- 1 | # useState: greeting 2 | 3 | ## Recordings 4 | [Exercise](https://drive.google.com/file/d/1tspXVGfFOYTH63B4ZirYnGhq_UUy7ocs/view?usp=sharing) 5 | [Extra Credit 1](https://drive.google.com/file/d/1jDc87PseaNkCHSZIAEdbo2sO8yMxkcqe/view?usp=sharing) 6 | ## 📝 Your Notes 7 | 8 | Elaborate on your learnings here in `src/exercise/01.md` 9 | 10 | ## Background 11 | 12 | Normally an interactive application will need to hold state somewhere. In React, 13 | you use special functions called "hooks" to do this. Common built-in hooks 14 | include: 15 | 16 | - `React.useState` 17 | - `React.useEffect` 18 | - `React.useContext` 19 | - `React.useRef` 20 | - `React.useReducer` 21 | 22 | Each of these is a special function that you can call inside your custom React 23 | component function to store data (like state) or perform actions (or 24 | side-effects). There are a few more built-in hooks that have special use cases, 25 | but the ones above are what you'll be using most of the time. 26 | 27 | Each of the hooks has a unique API. Some return a value (like `React.useRef` and 28 | `React.useContext`), others return a pair of values (like `React.useState` and 29 | `React.useReducer`), and others return nothing at all (like `React.useEffect`). 30 | 31 | Here's an example of a component that uses the `useState` hook and an onClick 32 | event handler to update that state: 33 | 34 | ```jsx 35 | function Counter() { 36 | const [count, setCount] = React.useState(0) 37 | const increment = () => setCount(count + 1) 38 | return 39 | } 40 | ``` 41 | 42 | `React.useState` is a function that accepts a single argument. That argument is 43 | the initial state for the instance of the component. In our case, the state will 44 | start as `0`. 45 | 46 | `React.useState` returns a pair of values. It does this by returning an array 47 | with two elements (and we use destructuring syntax to assign each of those 48 | values to distinct variables). The first of the pair is the state value and the 49 | second is a function we can call to update the state. We can name these 50 | variables whatever we want. Common convention is to choose a name for the state 51 | variable, then prefix `set` in front of that for the updater function. 52 | 53 | State can be defined as: data that changes over time. So how does this work over 54 | time? When the button is clicked, our `increment` function will be called at 55 | which time we update the `count` by calling `setCount`. 56 | 57 | When we call `setCount`, that tells React to re-render our component. When it 58 | does this, the entire `Counter` function is re-run, so when `React.useState` is 59 | called this time, the value we get back is the value that we called `setCount` 60 | with. And it continues like that until `Counter` is unmounted (removed from the 61 | application), or the user closes the application. 62 | 63 | ## Exercise 64 | 65 | Production deploys: 66 | 67 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/01.js) 68 | - [Final](https://react-hooks.netlify.app/isolated/final/01.js) 69 | 70 | In this exercise we have a form where you can type in your name and it will give 71 | you a greeting as you type. Fill out the `Greeting` component so that it manages 72 | the state of the name and shows the greeting as the name is changed. 73 | 74 | ## Extra Credit 75 | 76 | ### 1. 💯 accept an initialName 77 | 78 | [Production deploy](https://react-hooks.netlify.app/isolated/final/01.extra-1.js) 79 | 80 | Make the `Greeting` accept a prop called `initialName` and initialize the `name` 81 | state to that value. 82 | 83 | ## 🦉 Feedback 84 | 85 | Fill out 86 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=01%3A%20useState%3A%20greeting&em=). 87 | -------------------------------------------------------------------------------- /src/exercise/04-classes.js: -------------------------------------------------------------------------------- 1 | // useState: tic tac toe 2 | // 💯 (alternate) migrate from classes 3 | // http://localhost:3000/isolated/exercise/04-classes.js 4 | 5 | import * as React from 'react' 6 | 7 | // If you'd rather practice refactoring a class component to a function 8 | // component with hooks, then go ahead and do this exercise. 9 | 10 | // 🦉 You've learned all the hooks you need to know to refactor this Board 11 | // component to hooks. So, let's make it happen! 12 | 13 | class Board extends React.Component { 14 | state = { 15 | squares: 16 | JSON.parse(window.localStorage.getItem('squares')) || Array(9).fill(null), 17 | } 18 | 19 | selectSquare(square) { 20 | const {squares} = this.state 21 | const nextValue = calculateNextValue(squares) 22 | if (calculateWinner(squares) || squares[square]) { 23 | return 24 | } 25 | const squaresCopy = [...squares] 26 | squaresCopy[square] = nextValue 27 | this.setState({squares: squaresCopy}) 28 | } 29 | renderSquare = i => ( 30 | 33 | ) 34 | 35 | restart = () => { 36 | this.setState({squares: Array(9).fill(null)}) 37 | this.updateLocalStorage() 38 | } 39 | 40 | componentDidMount() { 41 | this.updateLocalStorage() 42 | } 43 | 44 | componentDidUpdate(prevProps, prevState) { 45 | if (prevState.squares !== this.state.squares) { 46 | this.updateLocalStorage() 47 | } 48 | } 49 | 50 | updateLocalStorage() { 51 | window.localStorage.setItem('squares', JSON.stringify(this.state.squares)) 52 | } 53 | 54 | render() { 55 | const {squares} = this.state 56 | const nextValue = calculateNextValue(squares) 57 | const winner = calculateWinner(squares) 58 | let status = calculateStatus(winner, squares, nextValue) 59 | 60 | return ( 61 |
62 |
{status}
63 |
64 | {this.renderSquare(0)} 65 | {this.renderSquare(1)} 66 | {this.renderSquare(2)} 67 |
68 |
69 | {this.renderSquare(3)} 70 | {this.renderSquare(4)} 71 | {this.renderSquare(5)} 72 |
73 |
74 | {this.renderSquare(6)} 75 | {this.renderSquare(7)} 76 | {this.renderSquare(8)} 77 |
78 | 81 |
82 | ) 83 | } 84 | } 85 | 86 | function Game() { 87 | return ( 88 |
89 |
90 | 91 |
92 |
93 | ) 94 | } 95 | 96 | function calculateStatus(winner, squares, nextValue) { 97 | return winner 98 | ? `Winner: ${winner}` 99 | : squares.every(Boolean) 100 | ? `Scratch: Cat's game` 101 | : `Next player: ${nextValue}` 102 | } 103 | 104 | function calculateNextValue(squares) { 105 | return squares.filter(Boolean).length % 2 === 0 ? 'X' : 'O' 106 | } 107 | 108 | function calculateWinner(squares) { 109 | const lines = [ 110 | [0, 1, 2], 111 | [3, 4, 5], 112 | [6, 7, 8], 113 | [0, 3, 6], 114 | [1, 4, 7], 115 | [2, 5, 8], 116 | [0, 4, 8], 117 | [2, 4, 6], 118 | ] 119 | for (let i = 0; i < lines.length; i++) { 120 | const [a, b, c] = lines[i] 121 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 122 | return squares[a] 123 | } 124 | } 125 | return null 126 | } 127 | 128 | function App() { 129 | return 130 | } 131 | 132 | export default App 133 | -------------------------------------------------------------------------------- /src/final/04.extra-3.js: -------------------------------------------------------------------------------- 1 | // useState: tic tac toe 2 | // 💯 add game history feature 3 | // http://localhost:3000/isolated/final/04.extra-3.js 4 | 5 | import * as React from 'react' 6 | import {useLocalStorageState} from '../utils' 7 | 8 | function Board({squares, onClick}) { 9 | function renderSquare(i) { 10 | return ( 11 | 14 | ) 15 | } 16 | 17 | return ( 18 |
19 |
20 | {renderSquare(0)} 21 | {renderSquare(1)} 22 | {renderSquare(2)} 23 |
24 |
25 | {renderSquare(3)} 26 | {renderSquare(4)} 27 | {renderSquare(5)} 28 |
29 |
30 | {renderSquare(6)} 31 | {renderSquare(7)} 32 | {renderSquare(8)} 33 |
34 |
35 | ) 36 | } 37 | 38 | function Game() { 39 | const [history, setHistory] = useLocalStorageState('tic-tac-toe:history', [ 40 | Array(9).fill(null), 41 | ]) 42 | const [currentStep, setCurrentStep] = useLocalStorageState( 43 | 'tic-tac-toe:step', 44 | 0, 45 | ) 46 | 47 | const currentSquares = history[currentStep] 48 | const winner = calculateWinner(currentSquares) 49 | const nextValue = calculateNextValue(currentSquares) 50 | const status = calculateStatus(winner, currentSquares, nextValue) 51 | 52 | function selectSquare(square) { 53 | if (winner || currentSquares[square]) { 54 | return 55 | } 56 | 57 | const newHistory = history.slice(0, currentStep + 1) 58 | const squares = [...currentSquares] 59 | 60 | squares[square] = nextValue 61 | setHistory([...newHistory, squares]) 62 | setCurrentStep(newHistory.length) 63 | } 64 | 65 | function restart() { 66 | setHistory([Array(9).fill(null)]) 67 | setCurrentStep(0) 68 | } 69 | 70 | const moves = history.map((stepSquares, step) => { 71 | const desc = step ? `Go to move #${step}` : 'Go to game start' 72 | const isCurrentStep = step === currentStep 73 | return ( 74 |
  • 75 | 78 |
  • 79 | ) 80 | }) 81 | 82 | return ( 83 |
    84 |
    85 | 86 | 89 |
    90 |
    91 |
    {status}
    92 |
      {moves}
    93 |
    94 |
    95 | ) 96 | } 97 | 98 | function calculateStatus(winner, squares, nextValue) { 99 | return winner 100 | ? `Winner: ${winner}` 101 | : squares.every(Boolean) 102 | ? `Scratch: Cat's game` 103 | : `Next player: ${nextValue}` 104 | } 105 | 106 | function calculateNextValue(squares) { 107 | const xSquaresCount = squares.filter(r => r === 'X').length 108 | const oSquaresCount = squares.filter(r => r === 'O').length 109 | return oSquaresCount === xSquaresCount ? 'X' : 'O' 110 | } 111 | 112 | function calculateWinner(squares) { 113 | const lines = [ 114 | [0, 1, 2], 115 | [3, 4, 5], 116 | [6, 7, 8], 117 | [0, 3, 6], 118 | [1, 4, 7], 119 | [2, 5, 8], 120 | [0, 4, 8], 121 | [2, 4, 6], 122 | ] 123 | for (let i = 0; i < lines.length; i++) { 124 | const [a, b, c] = lines[i] 125 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 126 | return squares[a] 127 | } 128 | } 129 | return null 130 | } 131 | 132 | function App() { 133 | return 134 | } 135 | 136 | export default App 137 | -------------------------------------------------------------------------------- /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/04.md: -------------------------------------------------------------------------------- 1 | # useState: tic tac toe 2 | 3 | ## Recordings 4 | [Exercise](https://drive.google.com/file/d/1vNQJkCK5lCRahjSUb_3hfeSCXnDpwlg1/view?usp=sharing) 5 | [Extra Credit 1](https://drive.google.com/file/d/1uP8XD08f9TqgSin98Cw48OHn9TD2ud7K/view?usp=sharing) 6 | [Extra Credit 2](https://drive.google.com/file/d/1xh9roZChSKjF5KxSSNVT_YqISSBXOSP4/view?usp=sharing) 7 | [Extra Credit 3](https://drive.google.com/file/d/1Y0RvU-h54yAoXJHKiUbZzOloF81gYXRi/view?usp=sharing) 8 | 9 | ## 📝 Your Notes 10 | 11 | Elaborate on your learnings here in `src/exercise/04.md` 12 | 13 | ## Background 14 | 15 | A `name` is one thing, but a real UI is a bit different. Often you need more 16 | than one element of state in your component, so you'll call `React.useState` 17 | more than once. Please note that each call to `React.useState` in a given 18 | component will give you a unique state and updater function. 19 | 20 | ## Exercise 21 | 22 | Production deploys: 23 | 24 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/04.js) 25 | - [Final](https://react-hooks.netlify.app/isolated/final/04.js) 26 | 27 | We're going to build tic-tac-toe (with localStorage support)! If you've gone 28 | through React's official tutorial, this was lifted from that (except that 29 | example still uses classes). 30 | 31 | You're going to need some managed state and some derived state: 32 | 33 | - **Managed State:** State that you need to explicitly manage 34 | - **Derived State:** State that you can calculate based on other state 35 | 36 | `squares` is the managed state and it's the state of the board in a 37 | single-dimensional array: 38 | 39 | ``` 40 | [ 41 | 'X', 'O', 'X', 42 | 'X', 'O', 'O', 43 | 'X', 'X', 'O' 44 | ] 45 | ``` 46 | 47 | This will start out as an empty array because it's the start of the game. 48 | 49 | `nextValue` will be either the string `X` or `O` and is derived state which you 50 | can determine based on the value of `squares`. We can determine whose turn it is 51 | based on how many "X" and "O" squares there are. We've written this out for you 52 | in a `calculateNextValue` function at the bottom of the file. 53 | 54 | `winner` will be either the string `X` or `O` and is derived state which can 55 | also be determined based on the value of `squares` and we've provided a 56 | `calculateWinner` function you can use to get that value. 57 | 58 | 📜 Read more about derived state in 59 | [Don't Sync State. Derive It!](https://kentcdodds.com/blog/dont-sync-state-derive-it) 60 | 61 | ### Alternate: 62 | 63 | If you'd prefer to practice refactoring a class that does this to a hook, then 64 | you can open `src/exercise/04-classes.js` and open that on 65 | [an isolated page](http://localhost:3000/isolated/exercise/04-classes.js) to 66 | practice that. 67 | 68 | ## Extra Credit 69 | 70 | ### 1. 💯 preserve state in localStorage 71 | 72 | [Production deploy](https://react-hooks.netlify.app/isolated/final/04.extra-1.js) 73 | 74 | 👨‍💼 Our customers want to be able to pause a game, close the tab, and then resume 75 | the game later. Can you store the game's state in `localStorage`? 76 | 77 | ### 2. 💯 useLocalStorageState 78 | 79 | [Production deploy](https://react-hooks.netlify.app/isolated/final/04.extra-2.js) 80 | 81 | It's cool that we can get localStorage support with a simple `useEffect`, but 82 | it'd be even cooler to use the `useLocalStorageState` hook that's already 83 | written for us in `src/utils.js`! 84 | 85 | Refactor your code to use that custom hook instead. (This should be a pretty 86 | quick extra credit). 87 | 88 | ### 3. 💯 add game history feature 89 | 90 | [Production deploy](https://react-hooks.netlify.app/isolated/final/04.extra-3.js) 91 | 92 | Open `http://localhost:3000/isolated/final/04.extra-3.js` and see that the extra 93 | version supports keeping a history of the game and allows you to go backward and 94 | forward in time. See if you can implement that! 95 | 96 | NOTE: This extra credit is one of the harder extra credits. Don't worry if you 97 | struggle on it! 98 | 99 | 💰 Tip, in the final example, we store the history of squares in an array of 100 | arrays. `[[/* step 0 squares */], [/* step 1 squares */], ...etc]`, so we have 101 | two states: `history` and `currentStep`. 102 | 103 | 💰 Tip, in the final example, we move the state management from the `Board` 104 | component to the `Game` component and that helps a bit). Here's what the JSX 105 | returned from the `Game` component is in the final version: 106 | 107 | ```javascript 108 | return ( 109 |
    110 |
    111 | 112 | 115 |
    116 |
    117 |
    {status}
    118 |
      {moves}
    119 |
    120 |
    121 | ) 122 | ``` 123 | 124 | ## 🦉 Feedback 125 | 126 | Fill out 127 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=04%3A%20useState%3A%20tic%20tac%20toe&em=). 128 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | Taken from the vanilla-tilt.js demo site: 3 | https://micku7zu.github.io/vanilla-tilt.js/index.html 4 | */ 5 | .tilt-root { 6 | height: 150px; 7 | background-color: red; 8 | width: 200px; 9 | background-image: -webkit-linear-gradient(315deg, #ff00ba 0%, #fae713 100%); 10 | background-image: linear-gradient(135deg, #ff00ba 0%, #fae713 100%); 11 | transform-style: preserve-3d; 12 | will-change: transform; 13 | transform: perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1); 14 | } 15 | .tilt-child { 16 | position: absolute; 17 | width: 50%; 18 | height: 50%; 19 | top: 50%; 20 | left: 50%; 21 | transform: translateZ(30px) translateX(-50%) translateY(-50%); 22 | box-shadow: 0 0 50px 0 rgba(51, 51, 51, 0.3); 23 | background-color: white; 24 | } 25 | .totally-centered { 26 | width: 100%; 27 | height: 100%; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .game { 34 | font: 14px 'Century Gothic', Futura, sans-serif; 35 | margin: 20px; 36 | min-height: 260px; 37 | } 38 | 39 | .game ol, 40 | .game ul { 41 | padding-left: 30px; 42 | } 43 | 44 | .board-row:after { 45 | clear: both; 46 | content: ''; 47 | display: table; 48 | } 49 | 50 | .status { 51 | margin-bottom: 10px; 52 | } 53 | 54 | .restart { 55 | margin-top: 10px; 56 | } 57 | 58 | .square { 59 | background: #fff; 60 | border: 1px solid #999; 61 | float: left; 62 | font-size: 24px; 63 | font-weight: bold; 64 | line-height: 34px; 65 | height: 34px; 66 | margin-right: -1px; 67 | margin-top: -1px; 68 | padding: 0; 69 | text-align: center; 70 | width: 34px; 71 | } 72 | 73 | .square:focus { 74 | outline: none; 75 | background: #ddd; 76 | } 77 | 78 | .game { 79 | display: flex; 80 | flex-direction: row; 81 | } 82 | 83 | .game-info { 84 | margin-left: 20px; 85 | min-width: 190px; 86 | } 87 | 88 | /* For exercise 6, we're handling errors with an error boundary */ 89 | body[class*='6'] :not(.render-container) > iframe, 90 | body[class*='6'] > iframe { 91 | display: none; 92 | } 93 | 94 | .pokemon-info-app a { 95 | color: #cc0000; 96 | } 97 | 98 | .pokemon-info-app a:focus, 99 | .pokemon-info-app a:hover, 100 | .pokemon-info-app a:active { 101 | color: #8a0000; 102 | } 103 | 104 | .pokemon-info-app input { 105 | line-height: 2; 106 | font-size: 16px; 107 | box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); 108 | border: none; 109 | border-radius: 2px; 110 | padding-left: 10px; 111 | padding-right: 10px; 112 | background-color: #eee; 113 | } 114 | 115 | .pokemon-info-app button { 116 | font-size: 1rem; 117 | font-family: inherit; 118 | border: 1px solid #ff0000; 119 | background-color: #cc0000; 120 | cursor: pointer; 121 | padding: 8px 10px; 122 | color: #eee; 123 | border-radius: 3px; 124 | } 125 | 126 | .pokemon-info-app button:disabled { 127 | border-color: #dc9494; 128 | background-color: #f16161; 129 | cursor: unset; 130 | } 131 | 132 | .pokemon-info-app button:hover:not(:disabled), 133 | .pokemon-info-app button:active:not(:disabled), 134 | .pokemon-info-app button:focus:not(:disabled) { 135 | border-color: #cc0000; 136 | background-color: #8a0000; 137 | } 138 | 139 | .pokemon-info-app .totally-centered { 140 | width: 100%; 141 | height: 100%; 142 | display: flex; 143 | justify-content: center; 144 | align-items: center; 145 | } 146 | 147 | .pokemon-info-app { 148 | max-width: 500px; 149 | margin: auto; 150 | } 151 | 152 | [class*='_isolated'] .pokemon-info-app { 153 | margin-top: 50px; 154 | } 155 | 156 | .pokemon-form { 157 | display: flex; 158 | flex-direction: column; 159 | align-items: center; 160 | } 161 | 162 | .pokemon-form input { 163 | margin-top: 10px; 164 | margin-right: 10px; 165 | } 166 | 167 | .pokemon-info { 168 | height: 400px; 169 | width: 300px; 170 | margin: auto; 171 | overflow: auto; 172 | background-color: #eee; 173 | border-radius: 4px; 174 | padding: 10px; 175 | position: relative; 176 | } 177 | 178 | .pokemon-info.pokemon-loading { 179 | opacity: 0.6; 180 | transition: opacity 0s; 181 | /* note: the transition delay is the same as the busyDelayMs config */ 182 | transition-delay: 0.4s; 183 | } 184 | 185 | .pokemon-info h2 { 186 | font-weight: bold; 187 | text-align: center; 188 | margin-top: 0.3em; 189 | } 190 | 191 | .pokemon-info img { 192 | max-width: 100%; 193 | max-height: 200px; 194 | } 195 | 196 | .pokemon-info .pokemon-info__img-wrapper { 197 | text-align: center; 198 | margin-top: 20px; 199 | } 200 | 201 | .pokemon-info .pokemon-info__fetch-time { 202 | position: absolute; 203 | top: 6px; 204 | right: 10px; 205 | } 206 | 207 | .pokemon-info-app button.invisible-button { 208 | border: none; 209 | padding: inherit; 210 | font-size: inherit; 211 | font-family: inherit; 212 | cursor: pointer; 213 | font-weight: inherit; 214 | background-color: transparent; 215 | color: #000; 216 | } 217 | .pokemon-info-app button.invisible-button:hover, 218 | .pokemon-info-app button.invisible-button:active, 219 | .pokemon-info-app button.invisible-button:focus { 220 | border: none; 221 | background-color: transparent; 222 | } 223 | -------------------------------------------------------------------------------- /src/exercise/02.md: -------------------------------------------------------------------------------- 1 | # useEffect: persistent state 2 | 3 | ## Recordings 4 | [Exercise](https://drive.google.com/file/d/1Yl_zemmKtErLquY79BPxtgwUFSkezZNC/view?usp=sharing) 5 | [Extra Credit 1](https://drive.google.com/file/d/1jVZHr6iLM7u_iZFhsocbwFFMGGZ6rMJW/view?usp=sharing) 6 | [Extra Credit 2](https://drive.google.com/file/d/1ojGl4x6BYjWW1rZBNIWqirkwsrxC_n8m/view?usp=sharing) 7 | [Extra Credit 3](https://drive.google.com/file/d/17LlxRbUVM0hAri1wSP3tm8hNcm3OJbkL/view?usp=sharing) 8 | [Extra Credit 4](https://drive.google.com/file/d/1ART7eY_kgm-AuAPiaw8MIYq9hRHQ3OHk/view?usp=sharing) 9 | ## 📝 Your Notes 10 | 11 | Elaborate on your learnings here in `src/exercise/02.md` 12 | 13 | ## Background 14 | 15 | `React.useEffect` is a built-in hook that allows you to run some custom code 16 | after React renders (and re-renders) your component to the DOM. It accepts a 17 | callback function which React will call after the DOM has been updated: 18 | 19 | ```javascript 20 | React.useEffect(() => { 21 | // your side-effect code here. 22 | // this is where you can make HTTP requests or interact with browser APIs. 23 | }) 24 | ``` 25 | 26 | Feel free to take a look at `src/examples/hook-flow.png` if you're interested in 27 | the timing of when your functions are run. This will make more sense after 28 | finishing the exercises/extra credit/instruction. 29 | 30 | ## Exercise 31 | 32 | Production deploys: 33 | 34 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/02.js) 35 | - [Final](https://react-hooks.netlify.app/isolated/final/02.js) 36 | 37 | In this exercise, we're going to enhance our `` component to get its 38 | initial state value from localStorage (if available) and keep localStorage 39 | updated as the `name` is updated. 40 | 41 | ## Extra Credit 42 | 43 | ### 1. 💯 lazy state initialization 44 | 45 | [Production deploy](https://react-hooks.netlify.app/isolated/final/02.extra-1.js) 46 | 47 | Right now, every time our component function is run, our function reads from 48 | localStorage. This is problematic because it could be a performance bottleneck 49 | (reading from localStorage can be slow). And what's more we only actually need 50 | to know the value from localStorage the first time this component is rendered! 51 | So the additional reads are wasted effort. 52 | 53 | To avoid this problem, React's useState hook allows you to pass a function 54 | instead of the actual value, and then it will only call that function to get the 55 | state value when the component is rendered the first time. So you can go from 56 | this: `React.useState(someExpensiveComputation())` To this: 57 | `React.useState(() => someExpensiveComputation())` 58 | 59 | And the `someExpensiveComputation` function will only be called when it's 60 | needed! 61 | 62 | Make the `React.useState` call use lazy initialization to avoid a performance 63 | bottleneck of reading into localStorage on every render. 64 | 65 | > Learn more about [lazy state initialization](https://kentcdodds.com/blog/use-state-lazy-initialization-and-function-updates) 66 | 67 | ### 2. 💯 effect dependencies 68 | 69 | [Production deploy](https://react-hooks.netlify.app/isolated/final/02.extra-2.js) 70 | 71 | The callback we're passing to `React.useEffect` is called after _every_ render 72 | of our component (including re-renders). This is exactly what we want because we 73 | want to make sure that the `name` is saved into localStorage whenever it 74 | changes, but there are various reasons a component can be re-rendered (for 75 | example, when a parent component in the application tree gets re-rendered). 76 | 77 | Really, we _only_ want localStorage to get updated when the `name` state 78 | actually changes. It doesn't need to re-run any other time. Luckily for us, 79 | `React.useEffect` allows you to pass a second argument called the "dependency 80 | array" which signals to React that your effect callback function should be 81 | called when (and only when) those dependencies change. So we can use this to 82 | avoid doing unnecessary work! 83 | 84 | Add a dependencies array for `React.useEffect` to avoid the callback being 85 | called too frequently. 86 | 87 | ### 3. 💯 custom hook 88 | 89 | [Production deploy](https://react-hooks.netlify.app/isolated/final/02.extra-3.js) 90 | 91 | The best part of hooks is that if you find a bit of logic inside your component 92 | function that you think would be useful elsewhere, you can put that in another 93 | function and call it from the components that need it (just like regular 94 | JavaScript). These functions you create are called "custom hooks". 95 | 96 | Create a custom hook called `useLocalStorageState` for reusability of all this 97 | logic. 98 | 99 | ### 4. 💯 flexible localStorage hook 100 | 101 | [Production deploy](https://react-hooks.netlify.app/isolated/final/02.extra-4.js) 102 | 103 | Take your custom `useLocalStorageState` hook and make it generic enough to 104 | support any data type (remember, you have to serialize objects to strings... use 105 | `JSON.stringify` and `JSON.parse`). Go wild with this! 106 | 107 | ## Notes 108 | 109 | If you'd like to learn more about when different hooks are called and the order 110 | in which they're called, then open up `src/examples/hook-flow.png` and 111 | `src/examples/hook-flow.js`. Play around with that a bit and hopefully that will 112 | help solidify this for you. Note that understanding this isn't absolutely 113 | necessary for you to understand hooks, but it _will_ help you in some situations 114 | so it's useful to understand. 115 | 116 | > PLEASE NOTE: there was a subtle change in the order of cleanup functions 117 | > getting called in React 17: 118 | > https://github.com/kentcdodds/react-hooks/issues/90 119 | 120 | ## 🦉 Feedback 121 | 122 | Fill out 123 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=02%3A%20useEffect%3A%20persistent%20state&em=). 124 | -------------------------------------------------------------------------------- /src/pokemon.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {ErrorBoundary} from 'react-error-boundary' 3 | 4 | const formatDate = date => 5 | `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')} ${String( 6 | date.getSeconds(), 7 | ).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}` 8 | 9 | // the delay argument is for faking things out a bit 10 | function fetchPokemon(name, delay = 1500) { 11 | const pokemonQuery = ` 12 | query PokemonInfo($name: String) { 13 | pokemon(name: $name) { 14 | id 15 | number 16 | name 17 | image 18 | attacks { 19 | special { 20 | name 21 | type 22 | damage 23 | } 24 | } 25 | } 26 | } 27 | ` 28 | 29 | return window 30 | .fetch('https://graphql-pokemon2.vercel.app/', { 31 | // learn more about this API here: https://graphql-pokemon2.vercel.app/ 32 | method: 'POST', 33 | headers: { 34 | 'content-type': 'application/json;charset=UTF-8', 35 | delay: delay, 36 | }, 37 | body: JSON.stringify({ 38 | query: pokemonQuery, 39 | variables: {name: name.toLowerCase()}, 40 | }), 41 | }) 42 | .then(async response => { 43 | const {data} = await response.json() 44 | if (response.ok) { 45 | const pokemon = data?.pokemon 46 | if (pokemon) { 47 | pokemon.fetchedAt = formatDate(new Date()) 48 | return pokemon 49 | } else { 50 | return Promise.reject(new Error(`No pokemon with the name "${name}"`)) 51 | } 52 | } else { 53 | // handle the graphql errors 54 | const error = { 55 | message: data?.errors?.map(e => e.message).join('\n'), 56 | } 57 | return Promise.reject(error) 58 | } 59 | }) 60 | } 61 | 62 | function PokemonInfoFallback({name}) { 63 | const initialName = React.useRef(name).current 64 | const fallbackPokemonData = { 65 | name: initialName, 66 | number: 'XXX', 67 | image: '/img/pokemon/fallback-pokemon.jpg', 68 | attacks: { 69 | special: [ 70 | {name: 'Loading Attack 1', type: 'Type', damage: 'XX'}, 71 | {name: 'Loading Attack 2', type: 'Type', damage: 'XX'}, 72 | ], 73 | }, 74 | fetchedAt: 'loading...', 75 | } 76 | return 77 | } 78 | 79 | function PokemonDataView({pokemon}) { 80 | return ( 81 |
    82 |
    83 | {pokemon.name} 84 |
    85 |
    86 |

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

    90 |
    91 |
    92 |
      93 | {pokemon.attacks.special.map(attack => ( 94 |
    • 95 | :{' '} 96 | 97 | {attack.damage} ({attack.type}) 98 | 99 |
    • 100 | ))} 101 |
    102 |
    103 | {pokemon.fetchedAt} 104 |
    105 | ) 106 | } 107 | 108 | function PokemonForm({ 109 | pokemonName: externalPokemonName, 110 | initialPokemonName = externalPokemonName || '', 111 | onSubmit, 112 | }) { 113 | const [pokemonName, setPokemonName] = React.useState(initialPokemonName) 114 | 115 | // this is generally not a great idea. We're synchronizing state when it is 116 | // normally better to derive it https://kentcdodds.com/blog/dont-sync-state-derive-it 117 | // however, we're doing things this way to make it easier for the exercises 118 | // to not have to worry about the logic for this PokemonForm component. 119 | React.useEffect(() => { 120 | // note that because it's a string value, if the externalPokemonName 121 | // is the same as the one we're managing, this will not trigger a re-render 122 | if (typeof externalPokemonName === 'string') { 123 | setPokemonName(externalPokemonName) 124 | } 125 | }, [externalPokemonName]) 126 | 127 | function handleChange(e) { 128 | setPokemonName(e.target.value) 129 | } 130 | 131 | function handleSubmit(e) { 132 | e.preventDefault() 133 | onSubmit(pokemonName) 134 | } 135 | 136 | function handleSelect(newPokemonName) { 137 | setPokemonName(newPokemonName) 138 | onSubmit(newPokemonName) 139 | } 140 | 141 | return ( 142 |
    143 | 144 | 145 | Try{' '} 146 | 153 | {', '} 154 | 161 | {', or '} 162 | 169 | 170 |
    171 | 179 | 182 |
    183 |
    184 | ) 185 | } 186 | 187 | function ErrorFallback({error, resetErrorBoundary}) { 188 | return ( 189 |
    190 | There was an error:{' '} 191 |
    {error.message}
    192 | 193 |
    194 | ) 195 | } 196 | 197 | function PokemonErrorBoundary(props) { 198 | return 199 | } 200 | 201 | export { 202 | PokemonInfoFallback, 203 | PokemonForm, 204 | PokemonDataView, 205 | fetchPokemon, 206 | PokemonErrorBoundary, 207 | } 208 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mock Service Worker. 3 | * @see https://github.com/mswjs/msw 4 | * - Please do NOT modify this file. 5 | * - Please do NOT serve this file on production. 6 | */ 7 | /* eslint-disable */ 8 | /* tslint:disable */ 9 | 10 | const INTEGRITY_CHECKSUM = '82ef9b96d8393b6da34527d1d6e19187' 11 | const bypassHeaderName = 'x-msw-bypass' 12 | const activeClientIds = new Set() 13 | 14 | self.addEventListener('install', function () { 15 | return self.skipWaiting() 16 | }) 17 | 18 | self.addEventListener('activate', async function (event) { 19 | return self.clients.claim() 20 | }) 21 | 22 | self.addEventListener('message', async function (event) { 23 | const clientId = event.source.id 24 | 25 | if (!clientId || !self.clients) { 26 | return 27 | } 28 | 29 | const client = await self.clients.get(clientId) 30 | 31 | if (!client) { 32 | return 33 | } 34 | 35 | const allClients = await self.clients.matchAll() 36 | 37 | switch (event.data) { 38 | case 'KEEPALIVE_REQUEST': { 39 | sendToClient(client, { 40 | type: 'KEEPALIVE_RESPONSE', 41 | }) 42 | break 43 | } 44 | 45 | case 'INTEGRITY_CHECK_REQUEST': { 46 | sendToClient(client, { 47 | type: 'INTEGRITY_CHECK_RESPONSE', 48 | payload: INTEGRITY_CHECKSUM, 49 | }) 50 | break 51 | } 52 | 53 | case 'MOCK_ACTIVATE': { 54 | activeClientIds.add(clientId) 55 | 56 | sendToClient(client, { 57 | type: 'MOCKING_ENABLED', 58 | payload: true, 59 | }) 60 | break 61 | } 62 | 63 | case 'MOCK_DEACTIVATE': { 64 | activeClientIds.delete(clientId) 65 | break 66 | } 67 | 68 | case 'CLIENT_CLOSED': { 69 | activeClientIds.delete(clientId) 70 | 71 | const remainingClients = allClients.filter((client) => { 72 | return client.id !== clientId 73 | }) 74 | 75 | // Unregister itself when there are no more clients 76 | if (remainingClients.length === 0) { 77 | self.registration.unregister() 78 | } 79 | 80 | break 81 | } 82 | } 83 | }) 84 | 85 | // Resolve the "master" client for the given event. 86 | // Client that issues a request doesn't necessarily equal the client 87 | // that registered the worker. It's with the latter the worker should 88 | // communicate with during the response resolving phase. 89 | async function resolveMasterClient(event) { 90 | const client = await self.clients.get(event.clientId) 91 | 92 | if (client.frameType === 'top-level') { 93 | return client 94 | } 95 | 96 | const allClients = await self.clients.matchAll() 97 | 98 | return allClients 99 | .filter((client) => { 100 | // Get only those clients that are currently visible. 101 | return client.visibilityState === 'visible' 102 | }) 103 | .find((client) => { 104 | // Find the client ID that's recorded in the 105 | // set of clients that have registered the worker. 106 | return activeClientIds.has(client.id) 107 | }) 108 | } 109 | 110 | async function handleRequest(event, requestId) { 111 | const client = await resolveMasterClient(event) 112 | const response = await getResponse(event, client, requestId) 113 | 114 | // Send back the response clone for the "response:*" life-cycle events. 115 | // Ensure MSW is active and ready to handle the message, otherwise 116 | // this message will pend indefinitely. 117 | if (client && activeClientIds.has(client.id)) { 118 | ;(async function () { 119 | const clonedResponse = response.clone() 120 | sendToClient(client, { 121 | type: 'RESPONSE', 122 | payload: { 123 | requestId, 124 | type: clonedResponse.type, 125 | ok: clonedResponse.ok, 126 | status: clonedResponse.status, 127 | statusText: clonedResponse.statusText, 128 | body: 129 | clonedResponse.body === null ? null : await clonedResponse.text(), 130 | headers: serializeHeaders(clonedResponse.headers), 131 | redirected: clonedResponse.redirected, 132 | }, 133 | }) 134 | })() 135 | } 136 | 137 | return response 138 | } 139 | 140 | async function getResponse(event, client, requestId) { 141 | const { request } = event 142 | const requestClone = request.clone() 143 | const getOriginalResponse = () => fetch(requestClone) 144 | 145 | // Bypass mocking when the request client is not active. 146 | if (!client) { 147 | return getOriginalResponse() 148 | } 149 | 150 | // Bypass initial page load requests (i.e. static assets). 151 | // The absence of the immediate/parent client in the map of the active clients 152 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 153 | // and is not ready to handle requests. 154 | if (!activeClientIds.has(client.id)) { 155 | return await getOriginalResponse() 156 | } 157 | 158 | // Bypass requests with the explicit bypass header 159 | if (requestClone.headers.get(bypassHeaderName) === 'true') { 160 | const cleanRequestHeaders = serializeHeaders(requestClone.headers) 161 | 162 | // Remove the bypass header to comply with the CORS preflight check. 163 | delete cleanRequestHeaders[bypassHeaderName] 164 | 165 | const originalRequest = new Request(requestClone, { 166 | headers: new Headers(cleanRequestHeaders), 167 | }) 168 | 169 | return fetch(originalRequest) 170 | } 171 | 172 | // Send the request to the client-side MSW. 173 | const reqHeaders = serializeHeaders(request.headers) 174 | const body = await request.text() 175 | 176 | const clientMessage = await sendToClient(client, { 177 | type: 'REQUEST', 178 | payload: { 179 | id: requestId, 180 | url: request.url, 181 | method: request.method, 182 | headers: reqHeaders, 183 | cache: request.cache, 184 | mode: request.mode, 185 | credentials: request.credentials, 186 | destination: request.destination, 187 | integrity: request.integrity, 188 | redirect: request.redirect, 189 | referrer: request.referrer, 190 | referrerPolicy: request.referrerPolicy, 191 | body, 192 | bodyUsed: request.bodyUsed, 193 | keepalive: request.keepalive, 194 | }, 195 | }) 196 | 197 | switch (clientMessage.type) { 198 | case 'MOCK_SUCCESS': { 199 | return delayPromise( 200 | () => respondWithMock(clientMessage), 201 | clientMessage.payload.delay, 202 | ) 203 | } 204 | 205 | case 'MOCK_NOT_FOUND': { 206 | return getOriginalResponse() 207 | } 208 | 209 | case 'NETWORK_ERROR': { 210 | const { name, message } = clientMessage.payload 211 | const networkError = new Error(message) 212 | networkError.name = name 213 | 214 | // Rejecting a request Promise emulates a network error. 215 | throw networkError 216 | } 217 | 218 | case 'INTERNAL_ERROR': { 219 | const parsedBody = JSON.parse(clientMessage.payload.body) 220 | 221 | console.error( 222 | `\ 223 | [MSW] Request handler function for "%s %s" has thrown the following exception: 224 | 225 | ${parsedBody.errorType}: ${parsedBody.message} 226 | (see more detailed error stack trace in the mocked response body) 227 | 228 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error. 229 | If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ 230 | `, 231 | request.method, 232 | request.url, 233 | ) 234 | 235 | return respondWithMock(clientMessage) 236 | } 237 | } 238 | 239 | return getOriginalResponse() 240 | } 241 | 242 | self.addEventListener('fetch', function (event) { 243 | const { request } = event 244 | 245 | // Bypass navigation requests. 246 | if (request.mode === 'navigate') { 247 | return 248 | } 249 | 250 | // Opening the DevTools triggers the "only-if-cached" request 251 | // that cannot be handled by the worker. Bypass such requests. 252 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 253 | return 254 | } 255 | 256 | // Bypass all requests when there are no active clients. 257 | // Prevents the self-unregistered worked from handling requests 258 | // after it's been deleted (still remains active until the next reload). 259 | if (activeClientIds.size === 0) { 260 | return 261 | } 262 | 263 | const requestId = uuidv4() 264 | 265 | return event.respondWith( 266 | handleRequest(event, requestId).catch((error) => { 267 | console.error( 268 | '[MSW] Failed to mock a "%s" request to "%s": %s', 269 | request.method, 270 | request.url, 271 | error, 272 | ) 273 | }), 274 | ) 275 | }) 276 | 277 | function serializeHeaders(headers) { 278 | const reqHeaders = {} 279 | headers.forEach((value, name) => { 280 | reqHeaders[name] = reqHeaders[name] 281 | ? [].concat(reqHeaders[name]).concat(value) 282 | : value 283 | }) 284 | return reqHeaders 285 | } 286 | 287 | function sendToClient(client, message) { 288 | return new Promise((resolve, reject) => { 289 | const channel = new MessageChannel() 290 | 291 | channel.port1.onmessage = (event) => { 292 | if (event.data && event.data.error) { 293 | return reject(event.data.error) 294 | } 295 | 296 | resolve(event.data) 297 | } 298 | 299 | client.postMessage(JSON.stringify(message), [channel.port2]) 300 | }) 301 | } 302 | 303 | function delayPromise(cb, duration) { 304 | return new Promise((resolve) => { 305 | setTimeout(() => resolve(cb()), duration) 306 | }) 307 | } 308 | 309 | function respondWithMock(clientMessage) { 310 | return new Response(clientMessage.payload.body, { 311 | ...clientMessage.payload, 312 | headers: clientMessage.payload.headers, 313 | }) 314 | } 315 | 316 | function uuidv4() { 317 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 318 | const r = (Math.random() * 16) | 0 319 | const v = c == 'x' ? r : (r & 0x3) | 0x8 320 | return v.toString(16) 321 | }) 322 | } 323 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "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": "tsnieman", 25 | "name": "Tyler Nieman", 26 | "avatar_url": "https://avatars3.githubusercontent.com/u/595711?v=4", 27 | "profile": "http://tsnieman.net/", 28 | "contributions": [ 29 | "code", 30 | "doc" 31 | ] 32 | }, 33 | { 34 | "login": "mplis", 35 | "name": "Mike Plis", 36 | "avatar_url": "https://avatars0.githubusercontent.com/u/1382377?v=4", 37 | "profile": "https://github.com/mplis", 38 | "contributions": [ 39 | "code", 40 | "test" 41 | ] 42 | }, 43 | { 44 | "login": "jdorfman", 45 | "name": "Justin Dorfman", 46 | "avatar_url": "https://avatars1.githubusercontent.com/u/398230?v=4", 47 | "profile": "https://stackshare.io/jdorfman/decisions", 48 | "contributions": [ 49 | "fundingFinding" 50 | ] 51 | }, 52 | { 53 | "login": "AlgusDark", 54 | "name": "Carlos Pérez Gutiérrez", 55 | "avatar_url": "https://avatars1.githubusercontent.com/u/818856?v=4", 56 | "profile": "http://algus.ninja", 57 | "contributions": [ 58 | "code" 59 | ] 60 | }, 61 | { 62 | "login": "CharlieStras", 63 | "name": "Charlie Stras", 64 | "avatar_url": "https://avatars2.githubusercontent.com/u/10193500?v=4", 65 | "profile": "http://charliestras.me", 66 | "contributions": [ 67 | "doc", 68 | "code" 69 | ] 70 | }, 71 | { 72 | "login": "lideo", 73 | "name": "Lide", 74 | "avatar_url": "https://avatars3.githubusercontent.com/u/1573567?v=4", 75 | "profile": "https://github.com/lideo", 76 | "contributions": [ 77 | "doc" 78 | ] 79 | }, 80 | { 81 | "login": "marcosvega91", 82 | "name": "Marco Moretti", 83 | "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4", 84 | "profile": "https://github.com/marcosvega91", 85 | "contributions": [ 86 | "code" 87 | ] 88 | }, 89 | { 90 | "login": "gugol2", 91 | "name": "Watchmaker", 92 | "avatar_url": "https://avatars0.githubusercontent.com/u/4933016?v=4", 93 | "profile": "https://github.com/gugol2", 94 | "contributions": [ 95 | "bug" 96 | ] 97 | }, 98 | { 99 | "login": "dschapman", 100 | "name": "Daniel Chapman", 101 | "avatar_url": "https://avatars3.githubusercontent.com/u/36767987?v=4", 102 | "profile": "https://dschapman.com", 103 | "contributions": [ 104 | "code" 105 | ] 106 | }, 107 | { 108 | "login": "flofehrenbacher", 109 | "name": "flofehrenbacher", 110 | "avatar_url": "https://avatars0.githubusercontent.com/u/18660708?v=4", 111 | "profile": "https://github.com/flofehrenbacher", 112 | "contributions": [ 113 | "doc" 114 | ] 115 | }, 116 | { 117 | "login": "PritamSangani", 118 | "name": "Pritam Sangani", 119 | "avatar_url": "https://avatars3.githubusercontent.com/u/22857896?v=4", 120 | "profile": "https://www.linkedin.com/in/pritamsangani/", 121 | "contributions": [ 122 | "code" 123 | ] 124 | }, 125 | { 126 | "login": "emzoumpo", 127 | "name": "Emmanouil Zoumpoulakis", 128 | "avatar_url": "https://avatars2.githubusercontent.com/u/2103443?v=4", 129 | "profile": "https://github.com/emzoumpo", 130 | "contributions": [ 131 | "doc" 132 | ] 133 | }, 134 | { 135 | "login": "Aprillion", 136 | "name": "Peter Hozák", 137 | "avatar_url": "https://avatars0.githubusercontent.com/u/1087670?v=4", 138 | "profile": "http://peter.hozak.info/", 139 | "contributions": [ 140 | "code", 141 | "doc" 142 | ] 143 | }, 144 | { 145 | "login": "timobleeker", 146 | "name": "Timo", 147 | "avatar_url": "https://avatars0.githubusercontent.com/u/2723586?v=4", 148 | "profile": "https://github.com/timobleeker", 149 | "contributions": [ 150 | "doc" 151 | ] 152 | }, 153 | { 154 | "login": "thacherhussain", 155 | "name": "Thacher Hussain", 156 | "avatar_url": "https://avatars1.githubusercontent.com/u/12368025?v=4", 157 | "profile": "http://thacher.co", 158 | "contributions": [ 159 | "doc" 160 | ] 161 | }, 162 | { 163 | "login": "jmagrippis", 164 | "name": "Johnny Magrippis", 165 | "avatar_url": "https://avatars0.githubusercontent.com/u/3502800?v=4", 166 | "profile": "https://magrippis.com", 167 | "contributions": [ 168 | "code" 169 | ] 170 | }, 171 | { 172 | "login": "apolakipso", 173 | "name": "Apola Kipso", 174 | "avatar_url": "https://avatars2.githubusercontent.com/u/494674?v=4", 175 | "profile": "https://twitter.com/apolakipso", 176 | "contributions": [ 177 | "code" 178 | ] 179 | }, 180 | { 181 | "login": "Snaptags", 182 | "name": "Markus Lasermann", 183 | "avatar_url": "https://avatars1.githubusercontent.com/u/1249745?v=4", 184 | "profile": "https://github.com/Snaptags", 185 | "contributions": [ 186 | "test" 187 | ] 188 | }, 189 | { 190 | "login": "degeens", 191 | "name": "Stijn Geens", 192 | "avatar_url": "https://avatars2.githubusercontent.com/u/33414262?v=4", 193 | "profile": "https://github.com/degeens", 194 | "contributions": [ 195 | "doc" 196 | ] 197 | }, 198 | { 199 | "login": "nativedone", 200 | "name": "Adeildo Amorim", 201 | "avatar_url": "https://avatars2.githubusercontent.com/u/20998754?v=4", 202 | "profile": "https://github.com/nativedone", 203 | "contributions": [ 204 | "doc" 205 | ] 206 | }, 207 | { 208 | "login": "thegoodsheppard", 209 | "name": "Greg Sheppard", 210 | "avatar_url": "https://avatars1.githubusercontent.com/u/13774377?v=4", 211 | "profile": "https://github.com/thegoodsheppard", 212 | "contributions": [ 213 | "doc" 214 | ] 215 | }, 216 | { 217 | "login": "RafaelDavisH", 218 | "name": "Rafael D. Hernandez", 219 | "avatar_url": "https://avatars0.githubusercontent.com/u/6822714?v=4", 220 | "profile": "https://rafaeldavis.dev", 221 | "contributions": [ 222 | "code" 223 | ] 224 | }, 225 | { 226 | "login": "DallasCarraher", 227 | "name": "Dallas Carraher", 228 | "avatar_url": "https://avatars2.githubusercontent.com/u/4131693?v=4", 229 | "profile": "http://dallascarraher.dev", 230 | "contributions": [ 231 | "doc" 232 | ] 233 | }, 234 | { 235 | "login": "roni-castro", 236 | "name": "Roni Castro", 237 | "avatar_url": "https://avatars3.githubusercontent.com/u/24610813?v=4", 238 | "profile": "https://github.com/roni-castro", 239 | "contributions": [ 240 | "test" 241 | ] 242 | }, 243 | { 244 | "login": "thebrengun", 245 | "name": "Brennan", 246 | "avatar_url": "https://avatars2.githubusercontent.com/u/15270595?v=4", 247 | "profile": "https://github.com/thebrengun", 248 | "contributions": [ 249 | "doc" 250 | ] 251 | }, 252 | { 253 | "login": "DaleSeo", 254 | "name": "Dale Seo", 255 | "avatar_url": "https://avatars1.githubusercontent.com/u/5466341?v=4", 256 | "profile": "https://www.daleseo.com", 257 | "contributions": [ 258 | "test" 259 | ] 260 | }, 261 | { 262 | "login": "MichaelDeBoey", 263 | "name": "Michaël De Boey", 264 | "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4", 265 | "profile": "https://michaeldeboey.be", 266 | "contributions": [ 267 | "code" 268 | ] 269 | }, 270 | { 271 | "login": "bobbywarner", 272 | "name": "Bobby Warner", 273 | "avatar_url": "https://avatars0.githubusercontent.com/u/554961?v=4", 274 | "profile": "http://bobbywarner.com", 275 | "contributions": [ 276 | "code" 277 | ] 278 | }, 279 | { 280 | "login": "douglance", 281 | "name": "Doug Lance", 282 | "avatar_url": "https://avatars2.githubusercontent.com/u/4741454?v=4", 283 | "profile": "https://github.com/douglance", 284 | "contributions": [ 285 | "doc" 286 | ] 287 | }, 288 | { 289 | "login": "nekhaevskiy", 290 | "name": "Yury Nekhaevskiy", 291 | "avatar_url": "https://avatars0.githubusercontent.com/u/15379100?v=4", 292 | "profile": "https://github.com/nekhaevskiy", 293 | "contributions": [ 294 | "doc" 295 | ] 296 | }, 297 | { 298 | "login": "mansn", 299 | "name": "Måns Nilsson", 300 | "avatar_url": "https://avatars0.githubusercontent.com/u/4518977?v=4", 301 | "profile": "https://github.com/mansn", 302 | "contributions": [ 303 | "doc" 304 | ] 305 | }, 306 | { 307 | "login": "cwinters8", 308 | "name": "Clark Winters", 309 | "avatar_url": "https://avatars2.githubusercontent.com/u/40615752?v=4", 310 | "profile": "https://clarkwinters.com", 311 | "contributions": [ 312 | "bug" 313 | ] 314 | }, 315 | { 316 | "login": "omarhoumz", 317 | "name": "Omar Houmz", 318 | "avatar_url": "https://avatars2.githubusercontent.com/u/40954879?v=4", 319 | "profile": "https://omarhoumz.com/", 320 | "contributions": [ 321 | "code" 322 | ] 323 | }, 324 | { 325 | "login": "suddenlyGiovanni", 326 | "name": "Giovanni Ravalico", 327 | "avatar_url": "https://avatars2.githubusercontent.com/u/15946771?v=4", 328 | "profile": "https://suddenlyGiovanni.dev", 329 | "contributions": [ 330 | "code", 331 | "ideas" 332 | ] 333 | }, 334 | { 335 | "login": "Segebre", 336 | "name": "Juan Enrique Segebre Zaghmout", 337 | "avatar_url": "https://avatars3.githubusercontent.com/u/10774915?v=4", 338 | "profile": "https://github.com/Segebre", 339 | "contributions": [ 340 | "code" 341 | ] 342 | }, 343 | { 344 | "login": "Alferguson", 345 | "name": "John Alexander Ferguson", 346 | "avatar_url": "https://avatars.githubusercontent.com/u/30883573?v=4", 347 | "profile": "https://www.linkedin.com/in/johnalexanderferguson/", 348 | "contributions": [ 349 | "test" 350 | ] 351 | }, 352 | { 353 | "login": "trentschnee", 354 | "name": "Trent Schneweis", 355 | "avatar_url": "https://avatars.githubusercontent.com/u/10525549?v=4", 356 | "profile": "https://trentschneweis.com", 357 | "contributions": [ 358 | "code" 359 | ] 360 | }, 361 | { 362 | "login": "dlo", 363 | "name": "Dan Loewenherz", 364 | "avatar_url": "https://avatars.githubusercontent.com/u/38447?v=4", 365 | "profile": "https://github.com/lionheart", 366 | "contributions": [ 367 | "code" 368 | ] 369 | }, 370 | { 371 | "login": "shivaprabhu", 372 | "name": "Shivaprabhu", 373 | "avatar_url": "https://avatars.githubusercontent.com/u/40115160?v=4", 374 | "profile": "https://prabhuwali.me/", 375 | "contributions": [ 376 | "doc" 377 | ] 378 | }, 379 | { 380 | "login": "JacobParis", 381 | "name": "Jacob Paris", 382 | "avatar_url": "https://avatars.githubusercontent.com/u/5633704?v=4", 383 | "profile": "https://www.jacobparis.com/", 384 | "contributions": [ 385 | "doc" 386 | ] 387 | } 388 | ], 389 | "contributorsPerLine": 7, 390 | "repoHost": "https://github.com", 391 | "skipCi": true 392 | } 393 | -------------------------------------------------------------------------------- /src/exercise/06.md: -------------------------------------------------------------------------------- 1 | # useEffect: HTTP requests 2 | 3 | ## Recordings 4 | [Exercise](https://drive.google.com/file/d/1R52hWPnE7fGVYh5_da9sLmt3BnUxYbNA/view?usp=sharing) 5 | [Extra Credit 1](https://drive.google.com/file/d/1AceRf7kwxa30DPUBI_VtnXQ1cMRw6XxB/view?usp=sharing) 6 | [Extra Credit 2](https://drive.google.com/file/d/1G3xMpEdK2Dngiia4QJONXAZn-QRurF-3/view?usp=sharing) 7 | [Extra Credit 3](https://drive.google.com/file/d/1VZCBNDzZK3WRsmCG0lIIQBdUeeSCI3qq/view?usp=sharing) 8 | [Extra Credit 4](https://drive.google.com/file/d/1qjei4Z54OhwzhGbdei_68IeS_tivmiOC/view?usp=sharing) 9 | [Extra Credit 5](https://drive.google.com/file/d/1_N6SQO27DCNdFXzsrDrIacNQHGlrXaNn/view?usp=sharing) 10 | [Extra Credit 6](https://drive.google.com/file/d/1tSg6iO16AjSNL34QLTJyi0YCDxpyj2E3/view?usp=sharing) 11 | [Extra Credit 7](https://drive.google.com/file/d/1p-5oTT6A0RV5ObFKY097-WnyPgse4dge/view?usp=sharing) 12 | 13 | ## 📝 Your Notes 14 | 15 | Elaborate on your learnings here in `src/exercise/06.md` 16 | 17 | ## Background 18 | 19 | HTTP requests are another common side-effect that we need to do in applications. 20 | This is no different from the side-effects we need to apply to a rendered DOM or 21 | when interacting with browser APIs like localStorage. In all these cases, we do 22 | that within a `useEffect` hook callback. This hook allows us to ensure that 23 | whenever certain changes take place, we apply the side-effects based on those 24 | changes. 25 | 26 | One important thing to note about the `useEffect` hook is that you cannot return 27 | anything other than the cleanup function. This has interesting implications with 28 | regard to async/await syntax: 29 | 30 | ```javascript 31 | // this does not work, don't do this: 32 | React.useEffect(async () => { 33 | const result = await doSomeAsyncThing() 34 | // do something with the result 35 | }) 36 | ``` 37 | 38 | The reason this doesn't work is because when you make a function async, it 39 | automatically returns a promise (whether you're not returning anything at all, 40 | or explicitly returning a function). This is due to the semantics of async/await 41 | syntax. So if you want to use async/await, the best way to do that is like so: 42 | 43 | ```javascript 44 | React.useEffect(() => { 45 | async function effect() { 46 | const result = await doSomeAsyncThing() 47 | // do something with the result 48 | } 49 | effect() 50 | }) 51 | ``` 52 | 53 | This ensures that you don't return anything but a cleanup function. 54 | 55 | 🦉 I find that it's typically just easier to extract all the async code into a 56 | utility function which I call and then use the promise-based `.then` method 57 | instead of using async/await syntax: 58 | 59 | ```javascript 60 | React.useEffect(() => { 61 | doSomeAsyncThing().then(result => { 62 | // do something with the result 63 | }) 64 | }) 65 | ``` 66 | 67 | But how you prefer to do this is totally up to you :) 68 | 69 | ## Exercise 70 | 71 | Production deploys: 72 | 73 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/06.js) 74 | - [Final](https://react-hooks.netlify.app/isolated/final/06.js) 75 | 76 | In this exercise, we'll be doing data fetching directly in a useEffect hook 77 | callback within our component. 78 | 79 | Here we have a form where users can enter the name of a pokemon and fetch data 80 | about that pokemon. Your job will be to create a component which makes that 81 | fetch request. When the user submits a pokemon name, our `PokemonInfo` component 82 | will get re-rendered with the `pokemonName` 83 | 84 | ## Extra Credit 85 | 86 | ### 1. 💯 handle errors 87 | 88 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-1.js) 89 | 90 | Unfortunately, sometimes things go wrong and we need to handle errors when they 91 | do so we can show the user useful information. Handle that error and render it 92 | out like so: 93 | 94 | ```jsx 95 |
    96 | There was an error:
    {error.message}
    97 |
    98 | ``` 99 | 100 | You can make an error happen by typing an incorrect pokemon name into the input. 101 | 102 | One common question I get about this extra credit is how to handle promise 103 | errors. There are two ways to do it in this extra credit: 104 | 105 | ```javascript 106 | // option 1: using .catch 107 | fetchPokemon(pokemonName) 108 | .then(pokemon => setPokemon(pokemon)) 109 | .catch(error => setError(error)) 110 | 111 | // option 2: using the second argument to .then 112 | fetchPokemon(pokemonName).then( 113 | pokemon => setPokemon(pokemon), 114 | error => setError(error), 115 | ) 116 | ``` 117 | 118 | These are functionally equivalent for our purposes, but they are semantically 119 | different in general. 120 | 121 | Using `.catch` means that you'll handle an error in the `fetchPokemon` promise, 122 | but you'll _also_ handle an error in the `setPokemon(pokemon)` call as well. 123 | This is due to the semantics of how promises work. 124 | 125 | Using the second argument to `.then` means that you will catch an error that 126 | happens in `fetchPokemon` only. In this case, I knew that calling `setPokemon` 127 | would not throw an error (React handles errors and we have an API to catch those 128 | which we'll use later), so I decided to go with the second argument option. 129 | 130 | However, in this situation, it doesn't really make much of a difference. If you 131 | want to go with the safe option, then opt for `.catch`. 132 | 133 | ### 2. 💯 use a status 134 | 135 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-2.js) 136 | 137 | Our logic for what to show the user when is kind of convoluted and requires that 138 | we be really careful about which state we set and when. 139 | 140 | We could make things much simpler by having some state to set the explicit 141 | status of our component. Our component can be in the following "states": 142 | 143 | - `idle`: no request made yet 144 | - `pending`: request started 145 | - `resolved`: request successful 146 | - `rejected`: request failed 147 | 148 | Try to use a status state by setting it to these string values rather than 149 | relying on existing state or booleans. 150 | 151 | Learn more about this concept here: 152 | https://kentcdodds.com/blog/stop-using-isloading-booleans 153 | 154 | 💰 Warning: Make sure you call `setPokemon` before calling `setStatus`. We'll 155 | address that more in the next extra credit. 156 | 157 | ### 3. 💯 store the state in an object 158 | 159 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-3.js) 160 | 161 | You'll notice that we're calling a bunch of state updaters in a row. This is 162 | normally not a problem, but each call to our state updater can result in a 163 | re-render of our component. React normally batches these calls so you only get a 164 | single re-render, but it's unable to do this in an asynchronous callback (like 165 | our promise success and error handlers). 166 | 167 | So you might notice that if you do this: 168 | 169 | ```javascript 170 | setStatus('resolved') 171 | setPokemon(pokemon) 172 | ``` 173 | 174 | You'll get an error indicating that you cannot read `image` of `null`. This is 175 | because the `setStatus` call results in a re-render that happens before the 176 | `setPokemon` happens. 177 | 178 | In the future, you'll learn about how `useReducer` can solve this problem really 179 | elegantly, but we can still accomplish this by storing our state as an object 180 | that has all the properties of state we're managing. 181 | 182 | See if you can figure out how to store all of your state in a single object with 183 | a single `React.useState` call so I can update my state like this: 184 | 185 | ```javascript 186 | setState({status: 'resolved', pokemon}) 187 | ``` 188 | 189 | ### 4. 💯 create an ErrorBoundary component 190 | 191 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-4.js) 192 | 193 | We've already solved the problem for errors in our request, we're only handling 194 | that one error. But there are a lot of different kinds of errors that can happen 195 | in our applications. 196 | 197 | No matter how hard you try, eventually your app code just isn’t going to behave 198 | the way you expect it to and you’ll need to handle those exceptions. If an error 199 | is thrown and unhandled, your application will be removed from the page, leaving 200 | the user with a blank screen... Kind of awkward... 201 | 202 | Luckily for us, there’s a simple way to handle errors in your application using 203 | a special kind of component called an 204 | [Error Boundary](https://reactjs.org/docs/error-boundaries.html). Unfortunately, 205 | there is currently no way to create an Error Boundary component with a function 206 | and you have to use a class component instead. 207 | 208 | In this extra credit, read up on ErrorBoundary components, and try to create one 209 | that handles this and any other error for the `PokemonInfo` component. 210 | 211 | 💰 to make your error boundary component handle errors from the `PokemonInfo` 212 | component, instead of rendering the error within the `PokemonInfo` component, 213 | you'll need to `throw error` right in the function so React can hand that to the 214 | error boundary. So `if (status === 'rejected') throw error`. 215 | 216 | ### 5. 💯 re-mount the error boundary 217 | 218 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-5.js) 219 | 220 | You might notice that with the changes we've added, we now cannot recover from 221 | an error. For example: 222 | 223 | 1. Type an incorrect pokemon 224 | 2. Notice the error 225 | 3. Type a correct pokemon 226 | 4. Notice it doesn't show that new pokemon's information 227 | 228 | The reason this is happening is because the `error` that's stored in the 229 | internal state of the `ErrorBoundary` component isn't getting reset, so it's not 230 | rendering the `children` we're passing to it. 231 | 232 | So what we need to do is reset the ErrorBoundary's `error` state to `null` so it 233 | will re-render. But how do we access the internal state of our `ErrorBoundary` 234 | to reset it? Well, there are a few ways we could do this by modifying the 235 | `ErrorBoundary`, but one thing you can do when you want to _reset_ the state of 236 | a component, is by providing it a `key` prop which can be used to unmount and 237 | re-mount a component. 238 | 239 | The `key` you can use? Try the `pokemonName`! 240 | 241 | ### 6. 💯 use react-error-boundary 242 | 243 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-6.js) 244 | 245 | As cool as our own `ErrorBoundary` is, I'd rather not have to maintain it in the 246 | long-term. Luckily for us, there's an npm package we can use instead and it's 247 | already installed into this project. It's called 248 | [`react-error-boundary`](https://github.com/bvaughn/react-error-boundary). 249 | 250 | Go ahead and give that a look and swap out our own `ErrorBoundary` for the one 251 | from `react-error-boundary`. 252 | 253 | ### 7. 💯 reset the error boundary 254 | 255 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-7.js) 256 | 257 | You may have noticed a problem with the way we're resetting the internal state 258 | of the `ErrorBoundary` using the `key`. Unfortunately, we're not only 259 | re-mounting the `ErrorBoundary`, we're also re-mounting the `PokemonInfo` which 260 | results in a flash of the initial "Submit a pokemon" state whenever we change 261 | our pokemon. 262 | 263 | So let's backtrack on that and instead we'll use `react-error-boundary`'s 264 | `resetErrorBoundary` function (which will be passed to our `ErrorFallback` 265 | component) to reset the state of the `ErrorBoundary` when the user clicks a "try 266 | again" button. 267 | 268 | > 💰 feel free to open up the finished version by clicking the link in the app 269 | > so you can get an idea of how this is supposed to work. 270 | 271 | Once you have this button wired up, we need to react to this reset of the 272 | `ErrorBoundary`'s state by resetting our own state so we don't wind up 273 | triggering the error again. To do this we can use the `onReset` prop of the 274 | `ErrorBoundary`. In that function we can simply `setPokemonName` to an empty 275 | string. 276 | 277 | ### 8. 💯 use resetKeys 278 | 279 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-8.js) 280 | 281 | Unfortunately now the user can't simply select a new pokemon and continue with 282 | their day. They have to first click "Try again" and then select their new 283 | pokemon. I think it would be cooler if they can just submit a new `pokemonName` 284 | and the `ErrorBoundary` would reset itself automatically. 285 | 286 | Luckily for us `react-error-boundary` supports this with the `resetKeys` prop. 287 | You pass an array of values to `resetKeys` and if the `ErrorBoundary` is in an 288 | error state and any of those values change, it will reset the error boundary. 289 | 290 | 💰 Your `resetKeys` prop should be: `[pokemonName]` 291 | 292 | ## 🦉 Feedback 293 | 294 | Fill out 295 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=06%3A%20useEffect%3A%20HTTP%20requests&em=). 296 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
    2 |

    🎣 React Hooks 🚀 EpicReact.Dev

    3 | 4 | Learn the ins and outs of React Hooks. 5 | 6 |

    7 | I will take you on a deep dive into 8 | React Hooks, and show you what you need to know to start using them in your 9 | applications right away. 10 |

    11 | 12 | 13 | Learn React from Start to Finish 17 | 18 |
    19 | 20 |
    21 | 22 | 23 | [![Build Status][build-badge]][build] 24 | [![All Contributors][all-contributors-badge]](#contributors-) 25 | [![GPL 3.0 License][license-badge]][license] 26 | [![Code of Conduct][coc-badge]][coc] 27 | 28 | 29 | ## Prerequisites 30 | 31 | - Watch my talk 32 | [Why React Hooks](https://www.youtube.com/watch?v=zWsZcBiwgVE&list=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf) 33 | (35 minutes) 34 | 35 | ## System Requirements 36 | 37 | - [git][git] v2.13 or greater 38 | - [NodeJS][node] `12 || 14 || 15 || 16` 39 | - [npm][npm] v6 or greater 40 | 41 | All of these must be available in your `PATH`. To verify things are set up 42 | properly, you can run this: 43 | 44 | ```shell 45 | git --version 46 | node --version 47 | npm --version 48 | ``` 49 | 50 | If you have trouble with any of these, learn more about the PATH environment 51 | variable and how to fix it here for [windows][win-path] or 52 | [mac/linux][mac-path]. 53 | 54 | ## Setup 55 | 56 | > If you want to commit and push your work as you go, you'll want to 57 | > [fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo) 58 | > first and then clone your fork rather than this repo directly. 59 | 60 | After you've made sure to have the correct things (and versions) installed, you 61 | should be able to just run a few commands to get set up: 62 | 63 | ``` 64 | git clone https://github.com/kentcdodds/react-hooks.git 65 | cd react-hooks 66 | node setup 67 | ``` 68 | 69 | This may take a few minutes. **It will ask you for your email.** This is 70 | optional and just automatically adds your email to the links in the project to 71 | make filling out some forms easier. 72 | 73 | If you get any errors, please read through them and see if you can find out what 74 | the problem is. If you can't work it out on your own then please [file an 75 | issue][issue] and provide _all_ the output from the commands you ran (even if 76 | it's a lot). 77 | 78 | If you can't get the setup script to work, then just make sure you have the 79 | right versions of the requirements listed above, and run the following commands: 80 | 81 | ``` 82 | npm install 83 | npm run validate 84 | ``` 85 | 86 | If you are still unable to fix issues and you know how to use Docker 🐳 you can 87 | setup the project with the following command: 88 | 89 | ``` 90 | docker-compose up 91 | ``` 92 | 93 | It's recommended you run everything locally in the same environment you work in 94 | every day, but if you're having issues getting things set up, you can also set 95 | this up using [GitHub Codespaces](https://github.com/features/codespaces) 96 | ([video demo](https://www.youtube.com/watch?v=gCoVJm3hGk4)) or 97 | [Codesandbox](https://codesandbox.io/s/github/kentcdodds/react-hooks). 98 | 99 | ## Running the app 100 | 101 | To get the app up and running (and really see if it worked), run: 102 | 103 | ```shell 104 | npm start 105 | ``` 106 | 107 | This should start up your browser. If you're familiar, this is a standard 108 | [react-scripts](https://create-react-app.dev/) application. 109 | 110 | You can also open 111 | [the deployment of the app on Netlify](https://react-hooks.netlify.app/). 112 | 113 | ## Running the tests 114 | 115 | ```shell 116 | npm test 117 | ``` 118 | 119 | This will start [Jest](https://jestjs.io/) in watch mode. Read the output and 120 | play around with it. The tests are there to help you reach the final version, 121 | however _sometimes_ you can accomplish the task and the tests still fail if you 122 | implement things differently than I do in my solution, so don't look to them as 123 | a complete authority. 124 | 125 | ### Exercises 126 | 127 | - `src/exercise/00.md`: Background, Exercise Instructions, Extra Credit 128 | - `src/exercise/00.js`: Exercise with Emoji helpers 129 | - `src/__tests__/00.js`: Tests 130 | - `src/final/00.js`: Final version 131 | - `src/final/00.extra-0.js`: Final version of extra credit 132 | 133 | The purpose of the exercise is **not** for you to work through all the material. 134 | It's intended to get your brain thinking about the right questions to ask me as 135 | _I_ walk through the material. 136 | 137 | ### Helpful Emoji 🐨 💰 💯 📝 🦉 📜 💣 💪 🏁 👨‍💼 🚨 138 | 139 | Each exercise has comments in it to help you get through the exercise. These fun 140 | emoji characters are here to help you. 141 | 142 | - **Kody the Koala** 🐨 will tell you when there's something specific you should 143 | do version 144 | - **Marty the Money Bag** 💰 will give you specific tips (and sometimes code) 145 | along the way 146 | - **Hannah the Hundred** 💯 will give you extra challenges you can do if you 147 | finish the exercises early. 148 | - **Nancy the Notepad** 📝 will encourage you to take notes on what you're 149 | learning 150 | - **Olivia the Owl** 🦉 will give you useful tidbits/best practice notes and a 151 | link for elaboration and feedback. 152 | - **Dominic the Document** 📜 will give you links to useful documentation 153 | - **Berry the Bomb** 💣 will be hanging around anywhere you need to blow stuff 154 | up (delete code) 155 | - **Matthew the Muscle** 💪 will indicate that you're working with an exercise 156 | - **Chuck the Checkered Flag** 🏁 will indicate that you're working with a final 157 | - **Peter the Product Manager** 👨‍💼 helps us know what our users want 158 | - **Alfred the Alert** 🚨 will occasionally show up in the test failures with 159 | potential explanations for why the tests are failing. 160 | 161 | ## Contributors 162 | 163 | Thanks goes to these wonderful people 164 | ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 |

    Kent C. Dodds

    💻 📖 🚇 ⚠️

    Tyler Nieman

    💻 📖

    Mike Plis

    💻 ⚠️

    Justin Dorfman

    🔍

    Carlos Pérez Gutiérrez

    💻

    Charlie Stras

    📖 💻

    Lide

    📖

    Marco Moretti

    💻

    Watchmaker

    🐛

    Daniel Chapman

    💻

    flofehrenbacher

    📖

    Pritam Sangani

    💻

    Emmanouil Zoumpoulakis

    📖

    Peter Hozák

    💻 📖

    Timo

    📖

    Thacher Hussain

    📖

    Johnny Magrippis

    💻

    Apola Kipso

    💻

    Markus Lasermann

    ⚠️

    Stijn Geens

    📖

    Adeildo Amorim

    📖

    Greg Sheppard

    📖

    Rafael D. Hernandez

    💻

    Dallas Carraher

    📖

    Roni Castro

    ⚠️

    Brennan

    📖

    Dale Seo

    ⚠️

    Michaël De Boey

    💻

    Bobby Warner

    💻

    Doug Lance

    📖

    Yury Nekhaevskiy

    📖

    Måns Nilsson

    📖

    Clark Winters

    🐛

    Omar Houmz

    💻

    Giovanni Ravalico

    💻 🤔

    Juan Enrique Segebre Zaghmout

    💻

    John Alexander Ferguson

    ⚠️

    Trent Schneweis

    💻

    Dan Loewenherz

    💻

    Shivaprabhu

    📖

    Jacob Paris

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