├── .all-contributorsrc ├── .eslintignore ├── .gitattributes ├── .github └── workflows │ └── validate.yml ├── .gitignore ├── .gitpod.yml ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.kcd.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── craco.config.js ├── docker-compose.yml ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.ico ├── img │ └── pokemon │ │ ├── bulbasaur.jpg │ │ ├── charizard.jpg │ │ ├── ditto.jpg │ │ ├── fallback-pokemon.jpg │ │ ├── mew.jpg │ │ ├── mewtwo.jpg │ │ └── pikachu.jpg ├── index.html ├── manifest.json └── mockServiceWorker.js ├── sandbox.config.json ├── scripts ├── diff.js ├── fix-links ├── pre-commit.js ├── pre-push.js ├── setup.js └── update-deps ├── setup.js └── src ├── __tests__ ├── 01.js ├── 02.js ├── 03.extra-1.js ├── 03.js ├── 04.extra-1.js ├── 04.extra-3.js ├── 04.js ├── 05.js └── 06.js ├── backend.js ├── examples ├── hook-flow.js ├── hook-flow.png └── local-state-key-change.js ├── exercise ├── 01.js ├── 01.md ├── 02.js ├── 02.md ├── 03.js ├── 03.md ├── 04-classes.js ├── 04.js ├── 04.md ├── 05-classes.js ├── 05.js ├── 05.md ├── 06.js └── 06.md ├── final ├── 01.extra-1.js ├── 01.js ├── 02.extra-1.js ├── 02.extra-2.js ├── 02.extra-3.js ├── 02.extra-4.js ├── 02.js ├── 03.extra-1.js ├── 03.js ├── 04.extra-1.js ├── 04.extra-2.js ├── 04.extra-3.js ├── 04.js ├── 05.js ├── 06.extra-1.js ├── 06.extra-2.js ├── 06.extra-3.js ├── 06.extra-4.js ├── 06.extra-5.js ├── 06.extra-6.js ├── 06.extra-7.js ├── 06.extra-8.js └── 06.js ├── index.js ├── pokemon.js ├── setupTests.js ├── styles.css └── utils.js /.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 | "login": "Eik-S", 390 | "name": "Eike Mücksch", 391 | "avatar_url": "https://avatars.githubusercontent.com/u/9152141?v=4", 392 | "profile": "https://github.com/Eik-S", 393 | "contributions": [ 394 | "test" 395 | ] 396 | }, 397 | { 398 | "login": "pvinis", 399 | "name": "Pavlos Vinieratos", 400 | "avatar_url": "https://avatars.githubusercontent.com/u/100233?v=4", 401 | "profile": "http://pavlos.dev", 402 | "contributions": [ 403 | "doc" 404 | ] 405 | }, 406 | { 407 | "login": "mokajima", 408 | "name": "Misaki Okajima", 409 | "avatar_url": "https://avatars.githubusercontent.com/u/10166985?v=4", 410 | "profile": "https://mokajima.com/", 411 | "contributions": [ 412 | "code", 413 | "doc" 414 | ] 415 | }, 416 | { 417 | "login": "marioleed", 418 | "name": "Mario Sannum", 419 | "avatar_url": "https://avatars.githubusercontent.com/u/1763448?v=4", 420 | "profile": "https://github.com/marioleed", 421 | "contributions": [ 422 | "code" 423 | ] 424 | }, 425 | { 426 | "login": "jaquinocode", 427 | "name": "jaquinocode", 428 | "avatar_url": "https://avatars.githubusercontent.com/u/28276675?v=4", 429 | "profile": "https://github.com/jaquinocode", 430 | "contributions": [ 431 | "bug" 432 | ] 433 | }, 434 | { 435 | "login": "Insidiae", 436 | "name": "Kobe Ruado", 437 | "avatar_url": "https://avatars.githubusercontent.com/u/28495550?v=4", 438 | "profile": "https://github.com/Insidiae", 439 | "contributions": [ 440 | "code" 441 | ] 442 | }, 443 | { 444 | "login": "prasanthlouis", 445 | "name": "Prasanth Louis", 446 | "avatar_url": "https://avatars.githubusercontent.com/u/8705429?v=4", 447 | "profile": "https://www.linkedin.com/in/prasanthlouis/", 448 | "contributions": [ 449 | "doc" 450 | ] 451 | }, 452 | { 453 | "login": "SherylHohman", 454 | "name": "Sheryl Hohman", 455 | "avatar_url": "https://avatars.githubusercontent.com/u/8204778?v=4", 456 | "profile": "https://stackoverflow.com/users/5411817/sherylhohman?tab=topactivity", 457 | "contributions": [ 458 | "code" 459 | ] 460 | }, 461 | { 462 | "login": "VictoriaVasys", 463 | "name": "Victoria Vasys", 464 | "avatar_url": "https://avatars.githubusercontent.com/u/10079657?v=4", 465 | "profile": "https://www.victoriavasys.com/", 466 | "contributions": [ 467 | "doc" 468 | ] 469 | }, 470 | { 471 | "login": "ImElan", 472 | "name": "Elan", 473 | "avatar_url": "https://avatars.githubusercontent.com/u/59192383?v=4", 474 | "profile": "https://github.com/ImElan", 475 | "contributions": [ 476 | "doc" 477 | ] 478 | }, 479 | { 480 | "login": "MBehtemam", 481 | "name": "Mohammad Bagher Ehtemam", 482 | "avatar_url": "https://avatars.githubusercontent.com/u/1811124?v=4", 483 | "profile": "http://about.me/mbehtemam", 484 | "contributions": [ 485 | "doc" 486 | ] 487 | }, 488 | { 489 | "login": "leggsimon", 490 | "name": "Simon Legg", 491 | "avatar_url": "https://avatars.githubusercontent.com/u/11544418?v=4", 492 | "profile": "https://github.com/leggsimon", 493 | "contributions": [ 494 | "doc" 495 | ] 496 | }, 497 | { 498 | "login": "PhilippVujic", 499 | "name": "Philipp Vujic", 500 | "avatar_url": "https://avatars.githubusercontent.com/u/28774924?v=4", 501 | "profile": "https://github.com/PhilippVujic", 502 | "contributions": [ 503 | "test" 504 | ] 505 | }, 506 | { 507 | "login": "romach", 508 | "name": "Roman Cherepanov", 509 | "avatar_url": "https://avatars.githubusercontent.com/u/2506484?v=4", 510 | "profile": "https://github.com/romach", 511 | "contributions": [ 512 | "doc" 513 | ] 514 | }, 515 | { 516 | "login": "ambujsahu81", 517 | "name": "Ambuj sahu", 518 | "avatar_url": "https://avatars.githubusercontent.com/u/118078892?v=4", 519 | "profile": "https://github.com/ambujsahu81", 520 | "contributions": [ 521 | "doc" 522 | ] 523 | }, 524 | { 525 | "login": "pnadalini", 526 | "name": "Pietro Nadalini", 527 | "avatar_url": "https://avatars.githubusercontent.com/u/26207809?v=4", 528 | "profile": "https://github.com/pnadalini", 529 | "contributions": [ 530 | "doc", 531 | "code" 532 | ] 533 | }, 534 | { 535 | "login": "stanulilic", 536 | "name": "Stanley Ulili", 537 | "avatar_url": "https://avatars.githubusercontent.com/u/25522835?v=4", 538 | "profile": "https://www.stanleyulili.com", 539 | "contributions": [ 540 | "bug" 541 | ] 542 | }, 543 | { 544 | "login": "jaharnum", 545 | "name": "Jamie Harnum", 546 | "avatar_url": "https://avatars.githubusercontent.com/u/19540539?v=4", 547 | "profile": "https://github.com/jaharnum", 548 | "contributions": [ 549 | "code" 550 | ] 551 | }, 552 | { 553 | "login": "heypano", 554 | "name": "Pano Papadatos", 555 | "avatar_url": "https://avatars.githubusercontent.com/u/1577139?v=4", 556 | "profile": "http://www.heypano.com", 557 | "contributions": [ 558 | "doc" 559 | ] 560 | }, 561 | { 562 | "login": "ASproson", 563 | "name": "Atlas Sproson", 564 | "avatar_url": "https://avatars.githubusercontent.com/u/77736272?v=4", 565 | "profile": "https://github.com/ASproson", 566 | "contributions": [ 567 | "doc" 568 | ] 569 | }, 570 | { 571 | "login": "junagao", 572 | "name": "juliane nagao", 573 | "avatar_url": "https://avatars.githubusercontent.com/u/615616?v=4", 574 | "profile": "http://junagao.com", 575 | "contributions": [ 576 | "doc" 577 | ] 578 | }, 579 | { 580 | "login": "Creeland", 581 | "name": "Creeland A. Provinsal ", 582 | "avatar_url": "https://avatars.githubusercontent.com/u/518406?v=4", 583 | "profile": "https://github.com/Creeland", 584 | "contributions": [ 585 | "doc" 586 | ] 587 | } 588 | ], 589 | "contributorsPerLine": 7, 590 | "repoHost": "https://github.com", 591 | "skipCi": true, 592 | "commitConvention": "angular", 593 | "commitType": "docs" 594 | } 595 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | build 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.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 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - name: ⬇️ Checkout repo 19 | uses: actions/checkout@v3 20 | 21 | - name: ⎔ Setup node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 18 25 | 26 | - name: npm 8 27 | run: npm install --global npm@8 28 | 29 | - name: ▶️ Run setup script 30 | run: npm run setup 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | build 5 | .idea/ 6 | .vscode/ 7 | .eslintcache 8 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | ## Learn more about this file at 'https://www.gitpod.io/docs/references/gitpod-yml' 2 | ## 3 | ## This '.gitpod.yml' file when placed at the root of a project instructs 4 | ## Gitpod how to prepare & build the project, start development environments 5 | ## and configure continuous prebuilds. Prebuilds when enabled builds a project 6 | ## like a CI server so you can start coding right away - no more waiting for 7 | ## dependencies to download and builds to finish when reviewing pull-requests 8 | ## or hacking on something new. 9 | ## 10 | ## With Gitpod you can develop software from any device (even iPads) via 11 | ## desktop or browser based versions of VS Code or any JetBrains IDE and 12 | ## customise it to your individual needs - from themes to extensions, you 13 | ## have full control. 14 | ## 15 | ## The easiest way to try out Gitpod is install the browser extenion: 16 | ## 'https://www.gitpod.io/docs/browser-extension' or by prefixing 17 | ## 'https://gitpod.io#' to the source control URL of any project. 18 | ## 19 | ## For example: 'https://gitpod.io#https://github.com/gitpod-io/gitpod' 20 | 21 | 22 | ## The 'tasks' section defines how Gitpod prepares & builds this project 23 | ## and how it can start development servers. With Gitpod, there are three 24 | ## types of tasks: 25 | ## 26 | ## - before: Use this for tasks that need to run before init and before command. 27 | ## - init: Use this to configure prebuilds of heavy-lifting tasks such as 28 | ## downloading dependencies or compiling source code. 29 | ## - command: Use this to start your database or application when the workspace starts. 30 | ## 31 | ## Learn more about these tasks at 'https://www.gitpod.io/docs/config-start-tasks' 32 | 33 | tasks: 34 | - name: App 35 | init: npm install 36 | command: npm run start 37 | openMode: split-left 38 | 39 | - name: Test 40 | command: npm run test 41 | openMode: split-right 42 | 43 | - name: Set up email 44 | command: | 45 | clear 46 | printf "\n\n\n" 47 | printf "\u001b[36;1mAutofilling Email\u001b[0m\n" 48 | printf "\u001b[2;1mEach exercise comes with a elaboration form to help your retention. Providing your email now will mean you don't have to provide it each time you fill out the form.\u001b[0m\n" 49 | 50 | npx "https://gist.github.com/kentcdodds/2d44448a8997b9964b1be44cd294d1f5" \ 51 | && exit 0 52 | 53 | ## The 'ports' section defines various ports your may listen on are 54 | ## configured in Gitpod on an authenticated URL. By default, all ports 55 | ## are in private visibility state. 56 | ## 57 | ## Learn more about ports at 'https://www.gitpod.io/docs/config-ports' 58 | 59 | ports: 60 | - port: 3000 # alternatively configure entire ranges via '8080-8090' 61 | visibility: private # either 'public' or 'private' (default) 62 | onOpen: open-browser # either 'open-browser', 'open-preview' or 'ignore' 63 | 64 | ## The 'vscode' section defines a list of Visual Studio Code extensions from 65 | ## the OpenVSX.org registry to be installed upon workspace startup. OpenVSX 66 | ## is an open alternative to the proprietary Visual Studio Code Marketplace 67 | ## and extensions can be added by sending a pull-request with the extension 68 | ## identifier to https://github.com/open-vsx/publish-extensions 69 | ## 70 | ## The identifier of an extension is always ${publisher}.${name}. 71 | ## 72 | ## For example: 'vscodevim.vim' 73 | ## 74 | ## Learn more at 'https://www.gitpod.io/docs/ides-and-editors/vscode' 75 | 76 | vscode: 77 | extensions: 78 | - VisualStudioExptTeam.vscodeintellicode 79 | - dbaeumer.vscode-eslint 80 | - formulahendry.auto-rename-tag 81 | - esbenp.prettier-vscode 82 | - ms-azuretools.vscode-docker 83 | 84 | ## The 'github' section defines configuration of continuous prebuilds 85 | ## for GitHub repositories when the GitHub application 86 | ## 'https://github.com/apps/gitpod-io' is installed in GitHub and granted 87 | ## permissions to access the repository. 88 | ## 89 | ## Learn more at 'https://www.gitpod.io/docs/prebuilds' 90 | 91 | github: 92 | prebuilds: 93 | # enable for the default branch 94 | master: true 95 | # enable for all branches in this repo 96 | branches: false 97 | # enable for pull requests coming from this repo 98 | pullRequests: false 99 | # enable for pull requests coming from forks 100 | pullRequestsFromForks: false 101 | # add a check to pull requests 102 | addCheck: false 103 | # add a "Review in Gitpod" button as a comment to pull requests 104 | addComment: false 105 | # add a "Review in Gitpod" button to the pull request's description 106 | addBadge: false 107 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | package-lock=true 3 | yes=true 4 | legacy-peer-deps=true 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | build 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "formulahendry.auto-rename-tag", 6 | "VisualStudioExptTeam.vscodeintellicode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.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.defaultLanguage": "{activeEditorLanguage}", 19 | "javascript.validate.enable": false, 20 | "search.exclude": { 21 | "**/node_modules": true, 22 | "**/bower_components": true, 23 | "**/coverage": true, 24 | "**/dist": true, 25 | "**/build": true, 26 | "**/.build": true, 27 | "**/.gh-pages": true 28 | }, 29 | "editor.codeActionsOnSave": { 30 | "source.fixAll.eslint": false 31 | }, 32 | "eslint.validate": [ 33 | "javascript", 34 | "javascriptreact", 35 | "typescript", 36 | "typescriptreact" 37 | ], 38 | "eslint.options": { 39 | "env": { 40 | "browser": true, 41 | "jest/globals": true, 42 | "es6": true 43 | }, 44 | "parserOptions": { 45 | "ecmaVersion": 2019, 46 | "sourceType": "module", 47 | "ecmaFeatures": { 48 | "jsx": true 49 | } 50 | }, 51 | "rules": { 52 | "no-debugger": "off" 53 | } 54 | }, 55 | "workbench.colorTheme": "Night Owl", 56 | "workbench.iconTheme": "material-icon-theme", 57 | "breadcrumbs.enabled": true, 58 | "grunt.autoDetect": "off", 59 | "gulp.autoDetect": "off", 60 | "npm.runSilent": true, 61 | "explorer.confirmDragAndDrop": false, 62 | "editor.formatOnPaste": false, 63 | "editor.cursorSmoothCaretAnimation": true, 64 | "editor.smoothScrolling": true, 65 | "php.suggest.basic": false 66 | } 67 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Please refer to [kentcdodds.com/conduct/](https://kentcdodds.com/conduct/) 2 | -------------------------------------------------------------------------------- /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 pull 15 | > 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` branch 25 | > to use the upstream main branch whenever you run `git pull`. Then you can make 26 | > all of your pull request branches based on this `main` branch. Whenever you 27 | > 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://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github 38 | [issues]: https://github.com/kentcdodds/react-hooks/issues 39 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | [![Gitpod ready-to-code][gitpod-badge]](https://gitpod.io/#https://github.com/kentcdodds/react-hooks) 28 | 29 | 30 | ## Prerequisites 31 | 32 | - Watch my talk 33 | [Why React Hooks](https://www.youtube.com/watch?v=zWsZcBiwgVE&list=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf) 34 | (35 minutes) 35 | 36 | > NOTE: The EpicReact.dev videos were recorded with React version ^16.13 and all 37 | > material in this repo has been updated to React version ^18. Differences are 38 | > minor and any relevant differences are noted in the instructions. 39 | 40 | ## Quick start 41 | 42 | It's recommended you run everything in the same environment you work in every 43 | day, but if you don't want to set up the repository locally, you can get started 44 | in one click with [Gitpod](https://gitpod.io), 45 | [CodeSandbox](https://codesandbox.io/s/github/kentcdodds/react-hooks), or by 46 | following the [video demo](https://www.youtube.com/watch?v=gCoVJm3hGk4) 47 | instructions for [GitHub Codespaces](https://github.com/features/codespaces). 48 | 49 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/kentcdodds/react-hooks) 50 | 51 | For a local development environment, follow the instructions below 52 | 53 | ## System Requirements 54 | 55 | - [git][git] v2.13 or greater 56 | - [NodeJS][node] `>=16` 57 | - [npm][npm] v8.16.0 or greater 58 | 59 | All of these must be available in your `PATH`. To verify things are set up 60 | properly, you can run this: 61 | 62 | ```shell 63 | git --version 64 | node --version 65 | npm --version 66 | ``` 67 | 68 | If you have trouble with any of these, learn more about the PATH environment 69 | variable and how to fix it here for [windows][win-path] or 70 | [mac/linux][mac-path]. 71 | 72 | ## Setup 73 | 74 | > If you want to commit and push your work as you go, you'll want to 75 | > [fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo) 76 | > first and then clone your fork rather than this repo directly. 77 | 78 | After you've made sure to have the correct things (and versions) installed, you 79 | should be able to just run a few commands to get set up: 80 | 81 | ```shell 82 | git clone https://github.com/kentcdodds/react-hooks.git 83 | cd react-hooks 84 | node setup 85 | ``` 86 | 87 | This may take a few minutes. **It will ask you for your email.** This is 88 | optional and just automatically adds your email to the links in the project to 89 | make filling out some forms easier. 90 | 91 | If you get any errors, please read through them and see if you can find out what 92 | the problem is. If you can't work it out on your own then please [file an 93 | issue][issue] and provide _all_ the output from the commands you ran (even if 94 | it's a lot). 95 | 96 | If you can't get the setup script to work, then just make sure you have the 97 | right versions of the requirements listed above, and run the following commands: 98 | 99 | ```shell 100 | npm install 101 | npm run validate 102 | ``` 103 | 104 | If you are still unable to fix issues and you know how to use Docker 🐳 you can 105 | setup the project with the following command: 106 | 107 | ```shell 108 | docker-compose up 109 | ``` 110 | 111 | ## Running the app 112 | 113 | To get the app up and running (and really see if it worked), run: 114 | 115 | ```shell 116 | npm start 117 | ``` 118 | 119 | This should start up your browser. If you're familiar, this is a standard 120 | [react-scripts](https://create-react-app.dev/) application. 121 | 122 | You can also open 123 | [the deployment of the app on Netlify](https://react-hooks.netlify.app/). 124 | 125 | ## Running the tests 126 | 127 | ```shell 128 | npm test 129 | ``` 130 | 131 | This will start [Jest](https://jestjs.io/) in watch mode. Read the output and 132 | play around with it. The tests are there to help you reach the final version, 133 | however _sometimes_ you can accomplish the task and the tests still fail if you 134 | implement things differently than I do in my solution, so don't look to them as 135 | a complete authority. 136 | 137 | ### Exercises 138 | 139 | - `src/exercise/00.md`: Background, Exercise Instructions, Extra Credit 140 | - `src/exercise/00.js`: Exercise with Emoji helpers 141 | - `src/__tests__/00.js`: Tests 142 | - `src/final/00.js`: Final version 143 | - `src/final/00.extra-0.js`: Final version of extra credit 144 | 145 | The purpose of the exercise is **not** for you to work through all the material. 146 | It's intended to get your brain thinking about the right questions to ask me as 147 | _I_ walk through the material. 148 | 149 | ### Helpful Emoji 🐨 💰 💯 📝 🦉 📜 💣 💪 🏁 👨‍💼 🚨 150 | 151 | Each exercise has comments in it to help you get through the exercise. These fun 152 | emoji characters are here to help you. 153 | 154 | - **Kody the Koala** 🐨 will tell you when there's something specific you should 155 | do 156 | - **Marty the Money Bag** 💰 will give you specific tips (and sometimes code) 157 | along the way 158 | - **Hannah the Hundred** 💯 will give you extra challenges you can do if you 159 | finish the exercises early. 160 | - **Nancy the Notepad** 📝 will encourage you to take notes on what you're 161 | learning 162 | - **Olivia the Owl** 🦉 will give you useful tidbits/best practice notes and a 163 | link for elaboration and feedback. 164 | - **Dominic the Document** 📜 will give you links to useful documentation 165 | - **Berry the Bomb** 💣 will be hanging around anywhere you need to blow stuff 166 | up (delete code) 167 | - **Matthew the Muscle** 💪 will indicate that you're working with an exercise 168 | - **Chuck the Checkered Flag** 🏁 will indicate that you're working with a final 169 | - **Peter the Product Manager** 👨‍💼 helps us know what our users want 170 | - **Alfred the Alert** 🚨 will occasionally show up in the test failures with 171 | potential explanations for why the tests are failing. 172 | 173 | ## Contributors 174 | 175 | Thanks goes to these wonderful people 176 | ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 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 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 |
Kent C. Dodds
Kent C. Dodds

💻 📖 🚇 ⚠️
Tyler Nieman
Tyler Nieman

💻 📖
Mike Plis
Mike Plis

💻 ⚠️
Justin Dorfman
Justin Dorfman

🔍
Carlos Pérez Gutiérrez
Carlos Pérez Gutiérrez

💻
Charlie Stras
Charlie Stras

📖 💻
Lide
Lide

📖
Marco Moretti
Marco Moretti

💻
Watchmaker
Watchmaker

🐛
Daniel Chapman
Daniel Chapman

💻
flofehrenbacher
flofehrenbacher

📖
Pritam Sangani
Pritam Sangani

💻
Emmanouil Zoumpoulakis
Emmanouil Zoumpoulakis

📖
Peter Hozák
Peter Hozák

💻 📖
Timo
Timo

📖
Thacher Hussain
Thacher Hussain

📖
Johnny Magrippis
Johnny Magrippis

💻
Apola Kipso
Apola Kipso

💻
Markus Lasermann
Markus Lasermann

⚠️
Stijn Geens
Stijn Geens

📖
Adeildo Amorim
Adeildo Amorim

📖
Greg Sheppard
Greg Sheppard

📖
Rafael D. Hernandez
Rafael D. Hernandez

💻
Dallas Carraher
Dallas Carraher

📖
Roni Castro
Roni Castro

⚠️
Brennan
Brennan

📖
Dale Seo
Dale Seo

⚠️
Michaël De Boey
Michaël De Boey

💻
Bobby Warner
Bobby Warner

💻
Doug Lance
Doug Lance

📖
Yury Nekhaevskiy
Yury Nekhaevskiy

📖
Måns Nilsson
Måns Nilsson

📖
Clark Winters
Clark Winters

🐛
Omar Houmz
Omar Houmz

💻
Giovanni Ravalico
Giovanni Ravalico

💻 🤔
Juan Enrique Segebre Zaghmout
Juan Enrique Segebre Zaghmout

💻
John Alexander Ferguson
John Alexander Ferguson

⚠️
Trent Schneweis
Trent Schneweis

💻
Dan Loewenherz
Dan Loewenherz

💻
Shivaprabhu
Shivaprabhu

📖
Jacob Paris
Jacob Paris

📖
Eike Mücksch
Eike Mücksch

⚠️
Pavlos Vinieratos
Pavlos Vinieratos

📖
Misaki Okajima
Misaki Okajima

💻 📖
Mario Sannum
Mario Sannum

💻
jaquinocode
jaquinocode

🐛
Kobe Ruado
Kobe Ruado

💻
Prasanth Louis
Prasanth Louis

📖
Sheryl Hohman
Sheryl Hohman

💻
Victoria Vasys
Victoria Vasys

📖
Elan
Elan

📖
Mohammad Bagher Ehtemam
Mohammad Bagher Ehtemam

📖
Simon Legg
Simon Legg

📖
Philipp Vujic
Philipp Vujic

⚠️
Roman Cherepanov
Roman Cherepanov

📖
Ambuj sahu
Ambuj sahu

📖
Pietro Nadalini
Pietro Nadalini

📖 💻
Stanley Ulili
Stanley Ulili

🐛
Jamie Harnum
Jamie Harnum

💻
Pano Papadatos
Pano Papadatos

📖
Atlas Sproson
Atlas Sproson

📖
juliane nagao
juliane nagao

📖
Creeland A. Provinsal
Creeland A. Provinsal

📖
266 | 267 | 268 | 269 | 270 | 271 | 272 | This project follows the 273 | [all-contributors](https://github.com/kentcdodds/all-contributors) 274 | specification. Contributions of any kind welcome! 275 | 276 | ## Workshop Feedback 277 | 278 | Each exercise has an Elaboration and Feedback link. Please fill that out after 279 | the exercise and instruction. 280 | 281 | At the end of the workshop, please go to this URL to give overall feedback. 282 | Thank you! https://kcd.im/rh-ws-feedback 283 | 284 | 285 | [npm]: https://www.npmjs.com/ 286 | [node]: https://nodejs.org 287 | [git]: https://git-scm.com/ 288 | [build-badge]: https://img.shields.io/github/actions/workflow/status/kentcdodds/react-hooks/validate.yml?branch=main&logo=github&style=flat-square 289 | [build]: https://github.com/kentcdodds/react-hooks/actions?query=workflow%3Avalidate 290 | [license-badge]: https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square 291 | [license]: https://github.com/kentcdodds/react-hooks/blob/main/LICENSE 292 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 293 | [gitpod-badge]: https://img.shields.io/badge/Gitpod-ready--to--code-908a85?logo=gitpod 294 | [coc]: https://github.com/kentcdodds/react-hooks/blob/main/CODE_OF_CONDUCT.md 295 | [emojis]: https://github.com/kentcdodds/all-contributors#emoji-key 296 | [all-contributors]: https://github.com/kentcdodds/all-contributors 297 | [all-contributors-badge]: https://img.shields.io/github/all-contributors/kentcdodds/react-hooks?color=orange&style=flat-square 298 | [win-path]: https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/ 299 | [mac-path]: http://stackoverflow.com/a/24322978/971592 300 | [issue]: https://github.com/kentcdodds/react-hooks/issues/new 301 | 302 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@kentcdodds/react-workshop-app/craco.config') 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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": ">=16", 14 | "npm": ">=8.16.0" 15 | }, 16 | "dependencies": { 17 | "@kentcdodds/react-workshop-app": "^6.0.2", 18 | "@testing-library/react": "^13.3.0", 19 | "@testing-library/user-event": "^14.2.1", 20 | "codegen.macro": "^4.1.0", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "react-error-boundary": "^3.1.4", 24 | "vanilla-tilt": "^1.7.2" 25 | }, 26 | "devDependencies": { 27 | "@craco/craco": "^6.4.3", 28 | "@types/react": "^18.0.14", 29 | "@types/react-dom": "^18.0.5", 30 | "husky": "^4.3.8", 31 | "npm-run-all": "^4.1.5", 32 | "prettier": "^2.7.1", 33 | "react-scripts": "^5.0.1", 34 | "typescript": "^4.7.4" 35 | }, 36 | "scripts": { 37 | "start": "craco start", 38 | "build": "craco build", 39 | "test": "craco test", 40 | "test:coverage": "npm run test -- --watchAll=false", 41 | "test:exercises": "npm run test -- testing.*exercises\\/ --onlyChanged", 42 | "setup": "node setup", 43 | "lint": "eslint .", 44 | "format": "prettier --write \"./src\"", 45 | "validate": "npm-run-all --parallel build test:coverage lint" 46 | }, 47 | "husky": { 48 | "hooks": { 49 | "pre-commit": "node ./scripts/pre-commit", 50 | "pre-push": "node ./scripts/pre-push" 51 | } 52 | }, 53 | "jest": { 54 | "collectCoverageFrom": [ 55 | "src/final/**/*.js" 56 | ] 57 | }, 58 | "eslintConfig": { 59 | "extends": "react-app" 60 | }, 61 | "browserslist": { 62 | "development": [ 63 | "last 2 chrome versions", 64 | "last 2 firefox versions", 65 | "last 2 edge versions" 66 | ], 67 | "production": [ 68 | ">1%", 69 | "last 4 versions", 70 | "Firefox ESR", 71 | "not ie < 11" 72 | ] 73 | }, 74 | "repository": { 75 | "type": "git", 76 | "url": "git+https://github.com/kentcdodds/react-hooks.git" 77 | }, 78 | "bugs": { 79 | "url": "https://github.com/kentcdodds/react-hooks/issues" 80 | }, 81 | "msw": { 82 | "workerDirectory": "public" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/react-hooks/b439d9f8001a62783c9337551fd7d648fa2d0943/public/favicon.ico -------------------------------------------------------------------------------- /public/img/pokemon/bulbasaur.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/react-hooks/b439d9f8001a62783c9337551fd7d648fa2d0943/public/img/pokemon/bulbasaur.jpg -------------------------------------------------------------------------------- /public/img/pokemon/charizard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/react-hooks/b439d9f8001a62783c9337551fd7d648fa2d0943/public/img/pokemon/charizard.jpg -------------------------------------------------------------------------------- /public/img/pokemon/ditto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/react-hooks/b439d9f8001a62783c9337551fd7d648fa2d0943/public/img/pokemon/ditto.jpg -------------------------------------------------------------------------------- /public/img/pokemon/fallback-pokemon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/react-hooks/b439d9f8001a62783c9337551fd7d648fa2d0943/public/img/pokemon/fallback-pokemon.jpg -------------------------------------------------------------------------------- /public/img/pokemon/mew.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/react-hooks/b439d9f8001a62783c9337551fd7d648fa2d0943/public/img/pokemon/mew.jpg -------------------------------------------------------------------------------- /public/img/pokemon/mewtwo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/react-hooks/b439d9f8001a62783c9337551fd7d648fa2d0943/public/img/pokemon/mewtwo.jpg -------------------------------------------------------------------------------- /public/img/pokemon/pikachu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/react-hooks/b439d9f8001a62783c9337551fd7d648fa2d0943/public/img/pokemon/pikachu.jpg -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | React Hooks 🎣 13 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "node", 3 | "container": { 4 | "startScript": "start", 5 | "port": 3000, 6 | "node": "14" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/fix-links: -------------------------------------------------------------------------------- 1 | npx https://gist.github.com/kentcdodds/436a77ff8977269e5fee39d9d89956de 2 | npm run format 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 output = spawnSync('npm --version', {shell: true}).stdout.toString().trim() 20 | var outputParts = output.split('.') 21 | var major = Number(outputParts[0]) 22 | var minor = Number(outputParts[1]) 23 | if (major < 8 || (major === 8 && minor < 16)) { 24 | console.error( 25 | color( 26 | 'danger', 27 | '🚨 npm version is ' + 28 | output + 29 | ' which is out of date. Please install npm@8.16.0 or greater', 30 | ), 31 | ) 32 | throw new Error('npm version is out of date') 33 | } 34 | 35 | var command = 36 | 'npx "https://gist.github.com/kentcdodds/bb452ffe53a5caa3600197e1d8005733" -q' 37 | console.log( 38 | color('subtitle', ' Running the following command: ' + command), 39 | ) 40 | 41 | var result = spawnSync(command, {stdio: 'inherit', shell: true}) 42 | 43 | if (result.status === 0) { 44 | console.log(color('success', '✅ Workshop setup complete...')) 45 | } else { 46 | process.exit(result.status) 47 | } 48 | 49 | /* 50 | eslint 51 | no-var: "off", 52 | "vars-on-top": "off", 53 | */ 54 | -------------------------------------------------------------------------------- /scripts/update-deps: -------------------------------------------------------------------------------- 1 | # prettier-ignore 2 | npx npm-check-updates --upgrade --reject husky,chalk 3 | rm -rf node_modules package-lock.json 4 | npx npm@8 install 5 | npm run validate 6 | -------------------------------------------------------------------------------- /setup.js: -------------------------------------------------------------------------------- 1 | require('./scripts/setup') 2 | 3 | -------------------------------------------------------------------------------- /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', async () => { 8 | render() 9 | await userEvent.type(screen.getByRole('textbox', {name: /name/i}), 'bob') 10 | expect(screen.getByText(/hello.*bob/i)).toBeInTheDocument() 11 | }) 12 | -------------------------------------------------------------------------------- /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', async () => { 12 | const {rerender} = render() 13 | const inputTextbox = screen.getByRole('textbox', {name: /name/i}) 14 | 15 | await userEvent.clear(inputTextbox) 16 | await userEvent.type(inputTextbox, 'bob') 17 | const lsName = window.localStorage.getItem('name') 18 | 19 | // extra credit 4 serializes the value in localStorage so there's a bit of a 20 | // variation here. 21 | const isSerialized = lsName === '"bob"' 22 | if (isSerialized) { 23 | expect(screen.getByText(/hello.*bob/i)).toBeInTheDocument() 24 | } else if (lsName === 'bob') { 25 | expect(screen.getByText(/hello.*bob/i)).toBeInTheDocument() 26 | } else { 27 | throw new Error( 28 | `🚨 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.`, 29 | ) 30 | } 31 | 32 | // make sure it's initialized properly 33 | window.localStorage.setItem('name', isSerialized ? '"jill"' : 'jill') 34 | rerender() 35 | const greetingText = screen.getByText(/hello/i).textContent 36 | if (!greetingText.includes('jill')) { 37 | throw new Error( 38 | `🚨 the app is not initialized with the name that's in localStorage. Make sure useState is called with the value in localStorage.`, 39 | ) 40 | } 41 | if (greetingText.includes('"')) { 42 | throw new Error( 43 | `🚨 the value in localStorage is not getting deserialized properly. Make sure the value is deserialized when read from localStorage.`, 44 | ) 45 | } 46 | expect(screen.getByRole('textbox', {name: /name/i})).toHaveValue('jill') 47 | }) 48 | -------------------------------------------------------------------------------- /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', async () => { 8 | render() 9 | await userEvent.type(screen.getByRole('textbox', {name: /name/i}), 'mulan') 10 | await 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__/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', async () => { 8 | render() 9 | await userEvent.type(screen.getByRole('textbox', {name: /name/i}), 'mulan') 10 | await 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__/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', async () => { 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 | await userEvent.click(s1) 19 | expect(s1).toHaveTextContent('X') 20 | 21 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 22 | await userEvent.click(s5) 23 | expect(s5).toHaveTextContent('O') 24 | 25 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 26 | await userEvent.click(s9) 27 | expect(s9).toHaveTextContent('X') 28 | 29 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 30 | await userEvent.click(s7) 31 | expect(s7).toHaveTextContent('O') 32 | 33 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 34 | await userEvent.click(s3) 35 | expect(s3).toHaveTextContent('X') 36 | 37 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 38 | await userEvent.click(s2) 39 | expect(s2).toHaveTextContent('O') 40 | 41 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 42 | await 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 | await 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/__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', async () => { 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 | await 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 | await 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 | await 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 | await userEvent.click(gameStart) 73 | expect(s1).toHaveTextContent('') 74 | expect(s5).toHaveTextContent('') 75 | expect(screen.queryAllByRole('listitem').length).toBe(3) 76 | 77 | await 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/__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', async () => { 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 | await userEvent.click(s1) 18 | expect(s1).toHaveTextContent('X') 19 | 20 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 21 | await userEvent.click(s5) 22 | expect(s5).toHaveTextContent('O') 23 | 24 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 25 | await userEvent.click(s9) 26 | expect(s9).toHaveTextContent('X') 27 | 28 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 29 | await userEvent.click(s7) 30 | expect(s7).toHaveTextContent('O') 31 | 32 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 33 | await userEvent.click(s3) 34 | expect(s3).toHaveTextContent('X') 35 | 36 | expect(screen.getByText('Next player: O')).toBeInTheDocument() 37 | await userEvent.click(s2) 38 | expect(s2).toHaveTextContent('O') 39 | 40 | expect(screen.getByText('Next player: X')).toBeInTheDocument() 41 | await 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 | await userEvent.click(s4) 47 | expect(s4).toHaveTextContent('') 48 | }) 49 | 50 | test('does not change square value when it is clicked multiple times', async () => { 51 | render() 52 | const [square1] = Array.from(screen.queryAllByRole('button')) 53 | 54 | await userEvent.click(square1) 55 | await userEvent.click(square1) 56 | expect(square1).toHaveTextContent('X') 57 | }) 58 | 59 | test('can reset game and start from beginning', async () => { 60 | const {container} = render() 61 | const [square1, square2] = Array.from(screen.queryAllByRole('button')) 62 | const [reset] = container.getElementsByClassName('restart') 63 | 64 | await userEvent.click(square1) 65 | expect(square1).toHaveTextContent('X') 66 | await userEvent.click(square2) 67 | expect(square2).toHaveTextContent('O') 68 | 69 | await userEvent.click(reset) 70 | 71 | await userEvent.click(square2) 72 | expect(square2).toHaveTextContent('X') 73 | }) 74 | -------------------------------------------------------------------------------- /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', async () => { 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/__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 | await userEvent.type(input, 'pikachu') 18 | await userEvent.click(submit) 19 | 20 | await screen.findByRole('heading', {name: /pikachu/i}) 21 | 22 | // verify that a request is made when props change 23 | await userEvent.clear(input) 24 | await userEvent.type(input, 'ditto') 25 | await 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 | await 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 | -------------------------------------------------------------------------------- /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/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/epicweb-dev/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/examples/hook-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/react-hooks/b439d9f8001a62783c9337551fd7d648fa2d0943/src/examples/hook-flow.png -------------------------------------------------------------------------------- /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/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() { 7 | // 💣 delete this variable declaration and replace it with a React.useState call 8 | const name = '' 9 | 10 | function handleChange(event) { 11 | // 🐨 update the name here based on event.target.value 12 | } 13 | 14 | return ( 15 |
16 |
17 | 18 | 19 |
20 | {name ? Hello {name} : 'Please type your name'} 21 |
22 | ) 23 | } 24 | 25 | function App() { 26 | return 27 | } 28 | 29 | export default App 30 | -------------------------------------------------------------------------------- /src/exercise/01.md: -------------------------------------------------------------------------------- 1 | # useState: greeting 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/01.md` 6 | 7 | ## Background 8 | 9 | Normally an interactive application will need to hold state somewhere. In React, 10 | you use special functions called "hooks" to do this. Common built-in hooks 11 | include: 12 | 13 | - `React.useState` 14 | - `React.useEffect` 15 | - `React.useContext` 16 | - `React.useRef` 17 | - `React.useReducer` 18 | 19 | Each of these is a special function that you can call inside your custom React 20 | component function to store data (like state) or perform actions (or 21 | side-effects). There are a few more built-in hooks that have special use cases, 22 | but the ones above are what you'll be using most of the time. 23 | 24 | Each of the hooks has a unique API. Some return a value (like `React.useRef` and 25 | `React.useContext`), others return a pair of values (like `React.useState` and 26 | `React.useReducer`), and others return nothing at all (like `React.useEffect`). 27 | 28 | Here's an example of a component that uses the `useState` hook and an onClick 29 | event handler to update that state: 30 | 31 | ```jsx 32 | function Counter() { 33 | const [count, setCount] = React.useState(0) 34 | const increment = () => setCount(count + 1) 35 | return 36 | } 37 | ``` 38 | 39 | `React.useState` is a function that accepts a single argument. That argument is 40 | the initial state for the instance of the component. In our case, the state will 41 | start as `0`. 42 | 43 | `React.useState` returns a pair of values. It does this by returning an array 44 | with two elements (and we use destructuring syntax to assign each of those 45 | values to distinct variables). The first of the pair is the state value and the 46 | second is a function we can call to update the state. We can name these 47 | variables whatever we want. Common convention is to choose a name for the state 48 | variable, then prefix `set` in front of that for the updater function. 49 | 50 | State can be defined as: data that changes over time. So how does this work over 51 | time? When the button is clicked, our `increment` function will be called at 52 | which time we update the `count` by calling `setCount`. 53 | 54 | When we call `setCount`, that tells React to re-render our component. When it 55 | does this, the entire `Counter` function is re-run, so when `React.useState` is 56 | called this time, the value we get back is the value that we called `setCount` 57 | with. And it continues like that until `Counter` is unmounted (removed from the 58 | application), or the user closes the application. 59 | 60 | ## Exercise 61 | 62 | Production deploys: 63 | 64 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/01.js) 65 | - [Final](https://react-hooks.netlify.app/isolated/final/01.js) 66 | 67 | In this exercise we have a form where you can type in your name and it will give 68 | you a greeting as you type. Fill out the `Greeting` component so that it manages 69 | the state of the name and shows the greeting as the name is changed. 70 | 71 | ## Extra Credit 72 | 73 | ### 1. 💯 accept an initialName 74 | 75 | [Production deploy](https://react-hooks.netlify.app/isolated/final/01.extra-1.js) 76 | 77 | Make the `Greeting` accept a prop called `initialName` and initialize the `name` 78 | state to that value. 79 | 80 | ## 🦉 Feedback 81 | 82 | Fill out 83 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=01%3A%20useState%3A%20greeting&em=). 84 | -------------------------------------------------------------------------------- /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 | function Greeting({initialName = ''}) { 7 | // 🐨 initialize the state to the value from localStorage 8 | // 💰 window.localStorage.getItem('name') ?? initialName 9 | const [name, setName] = React.useState(initialName) 10 | 11 | // 🐨 Here's where you'll use `React.useEffect`. 12 | // The callback should set the `name` in localStorage. 13 | // 💰 window.localStorage.setItem('name', name) 14 | 15 | function handleChange(event) { 16 | setName(event.target.value) 17 | } 18 | return ( 19 |
20 |
21 | 22 | 23 |
24 | {name ? Hello {name} : 'Please type your name'} 25 |
26 | ) 27 | } 28 | 29 | function App() { 30 | return 31 | } 32 | 33 | export default App 34 | -------------------------------------------------------------------------------- /src/exercise/02.md: -------------------------------------------------------------------------------- 1 | # useEffect: persistent state 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/02.md` 6 | 7 | ## Background 8 | 9 | `React.useEffect` is a built-in hook that allows you to run some custom code 10 | after React renders (and re-renders) your component to the DOM. It accepts a 11 | callback function which React will call after the DOM has been updated: 12 | 13 | ```javascript 14 | React.useEffect(() => { 15 | // your side-effect code here. 16 | // this is where you can make HTTP requests or interact with browser APIs. 17 | }) 18 | ``` 19 | 20 | Feel free to take a look at `src/examples/hook-flow.png` if you're interested in 21 | the timing of when your functions are run. This will make more sense after 22 | finishing the exercises/extra credit/instruction. 23 | 24 | ## Exercise 25 | 26 | Production deploys: 27 | 28 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/02.js) 29 | - [Final](https://react-hooks.netlify.app/isolated/final/02.js) 30 | 31 | In this exercise, we're going to enhance our `` component to get its 32 | initial state value from localStorage (if available) and keep localStorage 33 | updated as the `name` is updated. 34 | 35 | ## Extra Credit 36 | 37 | ### 1. 💯 lazy state initialization 38 | 39 | [Production deploy](https://react-hooks.netlify.app/isolated/final/02.extra-1.js) 40 | 41 | Right now, every time our component function is run, our function reads from 42 | localStorage. This is problematic because it could be a performance bottleneck 43 | (reading from localStorage can be slow). And what's more we only actually need 44 | to know the value from localStorage the first time this component is rendered! 45 | So the additional reads are wasted effort. 46 | 47 | To avoid this problem, React's useState hook allows you to pass a function 48 | instead of the actual value, and then it will only call that function to get the 49 | state value when the component is rendered the first time. So you can go from 50 | this: `React.useState(someExpensiveComputation())` To this: 51 | `React.useState(() => someExpensiveComputation())` 52 | 53 | And the `someExpensiveComputation` function will only be called when it's 54 | needed! 55 | 56 | Make the `React.useState` call use lazy initialization to avoid a performance 57 | bottleneck of reading into localStorage on every render. 58 | 59 | > Learn more about 60 | > [lazy state initialization](https://kentcdodds.com/blog/use-state-lazy-initialization-and-function-updates) 61 | 62 | ### 2. 💯 effect dependencies 63 | 64 | [Production deploy](https://react-hooks.netlify.app/isolated/final/02.extra-2.js) 65 | 66 | The callback we're passing to `React.useEffect` is called after _every_ render 67 | of our component (including re-renders). This is exactly what we want because we 68 | want to make sure that the `name` is saved into localStorage whenever it 69 | changes, but there are various reasons a component can be re-rendered (for 70 | example, when a parent component in the application tree gets re-rendered). 71 | 72 | Really, we _only_ want localStorage to get updated when the `name` state 73 | actually changes. It doesn't need to re-run any other time. Luckily for us, 74 | `React.useEffect` allows you to pass a second argument called the "dependency 75 | array" which signals to React that your effect callback function should be 76 | called when (and only when) those dependencies change. So we can use this to 77 | avoid doing unnecessary work! 78 | 79 | Add a dependencies array for `React.useEffect` to avoid the callback being 80 | called too frequently. 81 | 82 | ### 3. 💯 custom hook 83 | 84 | [Production deploy](https://react-hooks.netlify.app/isolated/final/02.extra-3.js) 85 | 86 | The best part of hooks is that if you find a bit of logic inside your component 87 | function that you think would be useful elsewhere, you can put that in another 88 | function and call it from the components that need it (just like regular 89 | JavaScript). These functions you create are called "custom hooks". 90 | 91 | Create a custom hook called `useLocalStorageState` for reusability of all this 92 | logic. 93 | 94 | ### 4. 💯 flexible localStorage hook 95 | 96 | [Production deploy](https://react-hooks.netlify.app/isolated/final/02.extra-4.js) 97 | 98 | Take your custom `useLocalStorageState` hook and make it generic enough to 99 | support any data type (remember, you have to serialize objects to strings... use 100 | `JSON.stringify` and `JSON.parse`). Go wild with this! 101 | 102 | ## Notes 103 | 104 | If you'd like to learn more about when different hooks are called and the order 105 | in which they're called, then open up `src/examples/hook-flow.png` and 106 | `src/examples/hook-flow.js`. Play around with that a bit and hopefully that will 107 | help solidify this for you. Note that understanding this isn't absolutely 108 | necessary for you to understand hooks, but it _will_ help you in some situations 109 | so it's useful to understand. 110 | 111 | > PLEASE NOTE: there was a subtle change in the order of cleanup functions 112 | > getting called in React 17: 113 | > 114 | 115 | ## 🦉 Feedback 116 | 117 | Fill out 118 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=02%3A%20useEffect%3A%20persistent%20state&em=). 119 | -------------------------------------------------------------------------------- /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({name, onNameChange}) { 7 | return ( 8 |
9 | 10 | 11 |
12 | ) 13 | } 14 | 15 | // 🐨 accept `animal` and `onAnimalChange` props to this component 16 | function FavoriteAnimal() { 17 | // 💣 delete this, it's now managed by the App 18 | const [animal, setAnimal] = React.useState('') 19 | return ( 20 |
21 | 22 | setAnimal(event.target.value)} 26 | /> 27 |
28 | ) 29 | } 30 | 31 | // 🐨 uncomment this 32 | // function Display({name, animal}) { 33 | // return
{`Hey ${name}, your favorite animal is: ${animal}!`}
34 | // } 35 | 36 | // 💣 remove this component in favor of the new one 37 | function Display({name}) { 38 | return
{`Hey ${name}, you are great!`}
39 | } 40 | 41 | function App() { 42 | // 🐨 add a useState for the animal 43 | const [name, setName] = React.useState('') 44 | return ( 45 |
46 | setName(event.target.value)} /> 47 | {/* 🐨 pass the animal and onAnimalChange prop here (similar to the Name component above) */} 48 | 49 | {/* 🐨 pass the animal prop here */} 50 | 51 | 52 | ) 53 | } 54 | 55 | export default App 56 | -------------------------------------------------------------------------------- /src/exercise/03.md: -------------------------------------------------------------------------------- 1 | # Lifting state 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/03.md` 6 | 7 | ## Background 8 | 9 | A common question from React beginners is how to share state between two sibling 10 | components. The answer is to 11 | ["lift the state"](https://react.dev/learn/sharing-state-between-components) 12 | which basically amounts to finding the lowest common parent shared between the 13 | two components and placing the state management there, and then passing the 14 | state and a mechanism for updating that state down into the components that need 15 | it. 16 | 17 | ## Exercise 18 | 19 | Production deploys: 20 | 21 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/03.js) 22 | - [Final](https://react-hooks.netlify.app/isolated/final/03.js) 23 | 24 | 👨‍💼 Peter told us we've got a new feature request for the `Display` component. He 25 | wants us to display the `animal` the user selects. But that state is managed in 26 | a "sibling" component, so we have to move that management to the lowest common 27 | parent (`App`) and then pass it down. 28 | 29 | ## Extra Credit 30 | 31 | ### 1. 💯 colocating state 32 | 33 | [Production deploy](https://react-hooks.netlify.app/isolated/final/03.extra-1.js) 34 | 35 | As a community we’re pretty good at lifting state. It becomes natural over time. 36 | One thing that we typically have trouble remembering to do is to push state back 37 | down (or 38 | [colocate state](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster)). 39 | 40 | 👨‍💼 Peter told us that now users only want the animal displayed instead of the 41 | name: 42 | 43 | ```javascript 44 | function Display({animal}) { 45 | return
{`Your favorite animal is: ${animal}!`}
46 | } 47 | ``` 48 | 49 | You'll notice that just updating the `Display` component to this works fine, but 50 | for the extra credit, go through the process of moving state to the components 51 | that need it. You know what you just did for the `Animal` component? You need to 52 | do the opposite thing for the `Name` component. 53 | 54 | ## 🦉 Feedback 55 | 56 | Fill out 57 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=03%3A%20Lifting%20state&em=). 58 | -------------------------------------------------------------------------------- /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/exercise/04.js: -------------------------------------------------------------------------------- 1 | // useState: tic tac toe 2 | // http://localhost:3000/isolated/exercise/04.js 3 | 4 | import * as React from 'react' 5 | 6 | function Board() { 7 | // 🐨 squares is the state for this component. Add useState for squares 8 | const squares = Array(9).fill(null) 9 | 10 | // 🐨 We'll need the following bits of derived state: 11 | // - nextValue ('X' or 'O') 12 | // - winner ('X', 'O', or null) 13 | // - status (`Winner: ${winner}`, `Scratch: Cat's game`, or `Next player: ${nextValue}`) 14 | // 💰 I've written the calculations for you! So you can use my utilities 15 | // below to create these variables 16 | 17 | // This is the function your square click handler will call. `square` should 18 | // be an index. So if they click the center square, this will be `4`. 19 | function selectSquare(square) { 20 | // 🐨 first, if there's already a winner or there's already a value at the 21 | // given square index (like someone clicked a square that's already been 22 | // clicked), then return early so we don't make any state changes 23 | // 24 | // 🦉 It's typically a bad idea to mutate or directly change state in React. 25 | // Doing so can lead to subtle bugs that can easily slip into production. 26 | // 27 | // 🐨 make a copy of the squares array 28 | // 💰 `[...squares]` will do it!) 29 | // 30 | // 🐨 set the value of the square that was selected 31 | // 💰 `squaresCopy[square] = nextValue` 32 | // 33 | // 🐨 set the squares to your copy 34 | } 35 | 36 | function restart() { 37 | // 🐨 reset the squares 38 | // 💰 `Array(9).fill(null)` will do it! 39 | } 40 | 41 | function renderSquare(i) { 42 | return ( 43 | 46 | ) 47 | } 48 | 49 | return ( 50 |
51 | {/* 🐨 put the status in the div below */} 52 |
STATUS
53 |
54 | {renderSquare(0)} 55 | {renderSquare(1)} 56 | {renderSquare(2)} 57 |
58 |
59 | {renderSquare(3)} 60 | {renderSquare(4)} 61 | {renderSquare(5)} 62 |
63 |
64 | {renderSquare(6)} 65 | {renderSquare(7)} 66 | {renderSquare(8)} 67 |
68 | 71 |
72 | ) 73 | } 74 | 75 | function Game() { 76 | return ( 77 |
78 |
79 | 80 |
81 |
82 | ) 83 | } 84 | 85 | // eslint-disable-next-line no-unused-vars 86 | function calculateStatus(winner, squares, nextValue) { 87 | return winner 88 | ? `Winner: ${winner}` 89 | : squares.every(Boolean) 90 | ? `Scratch: Cat's game` 91 | : `Next player: ${nextValue}` 92 | } 93 | 94 | // eslint-disable-next-line no-unused-vars 95 | function calculateNextValue(squares) { 96 | return squares.filter(Boolean).length % 2 === 0 ? 'X' : 'O' 97 | } 98 | 99 | // eslint-disable-next-line no-unused-vars 100 | function calculateWinner(squares) { 101 | const lines = [ 102 | [0, 1, 2], 103 | [3, 4, 5], 104 | [6, 7, 8], 105 | [0, 3, 6], 106 | [1, 4, 7], 107 | [2, 5, 8], 108 | [0, 4, 8], 109 | [2, 4, 6], 110 | ] 111 | for (let i = 0; i < lines.length; i++) { 112 | const [a, b, c] = lines[i] 113 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 114 | return squares[a] 115 | } 116 | } 117 | return null 118 | } 119 | 120 | function App() { 121 | return 122 | } 123 | 124 | export default App 125 | -------------------------------------------------------------------------------- /src/exercise/04.md: -------------------------------------------------------------------------------- 1 | # useState: tic tac toe 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/04.md` 6 | 7 | ## Background 8 | 9 | A `name` is one thing, but a real UI is a bit different. Often you need more 10 | than one element of state in your component, so you'll call `React.useState` 11 | more than once. Please note that each call to `React.useState` in a given 12 | component will give you a unique state and updater function. 13 | 14 | ## Exercise 15 | 16 | Production deploys: 17 | 18 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/04.js) 19 | - [Final](https://react-hooks.netlify.app/isolated/final/04.js) 20 | 21 | We're going to build tic-tac-toe (with localStorage support)! If you've gone 22 | through React's official tutorial, this was lifted from that. 23 | 24 | You're going to need some managed state and some derived state: 25 | 26 | - **Managed State:** State that you need to explicitly manage 27 | - **Derived State:** State that you can calculate based on other state 28 | 29 | `squares` is the managed state and it's the state of the board in a 30 | single-dimensional array: 31 | 32 | ``` 33 | [ 34 | 'X', 'O', 'X', 35 | 'X', 'O', 'O', 36 | 'X', 'X', 'O' 37 | ] 38 | ``` 39 | 40 | This will start out as an empty array because it's the start of the game. 41 | 42 | `nextValue` will be either the string `X` or `O` and is derived state which you 43 | can determine based on the value of `squares`. We can determine whose turn it is 44 | based on how many "X" and "O" squares there are. We've written this out for you 45 | in a `calculateNextValue` function at the bottom of the file. 46 | 47 | `winner` will be either the string `X` or `O` and is derived state which can 48 | also be determined based on the value of `squares` and we've provided a 49 | `calculateWinner` function you can use to get that value. 50 | 51 | 📜 Read more about derived state in 52 | [Don't Sync State. Derive It!](https://kentcdodds.com/blog/dont-sync-state-derive-it) 53 | 54 | ### Alternate 55 | 56 | If you'd prefer to practice refactoring a class that does this to a hook, then 57 | you can open `src/exercise/04-classes.js` and open that on 58 | [an isolated page](http://localhost:3000/isolated/exercise/04-classes.js) to 59 | practice that. 60 | 61 | ## Extra Credit 62 | 63 | ### 1. 💯 preserve state in localStorage 64 | 65 | [Production deploy](https://react-hooks.netlify.app/isolated/final/04.extra-1.js) 66 | 67 | 👨‍💼 Our customers want to be able to pause a game, close the tab, and then resume 68 | the game later. Can you store the game's state in `localStorage`? 69 | 70 | ### 2. 💯 useLocalStorageState 71 | 72 | [Production deploy](https://react-hooks.netlify.app/isolated/final/04.extra-2.js) 73 | 74 | It's cool that we can get localStorage support with a simple `useEffect`, but 75 | it'd be even cooler to use the `useLocalStorageState` hook that's already 76 | written for us in `src/utils.js`! 77 | 78 | Refactor your code to use that custom hook instead. (This should be a pretty 79 | quick extra credit). 80 | 81 | ### 3. 💯 add game history feature 82 | 83 | [Production deploy](https://react-hooks.netlify.app/isolated/final/04.extra-3.js) 84 | 85 | Open [http://localhost:3000/isolated/final/04.extra-3.js](http://localhost:3000/isolated/final/04.extra-3.js) and see that the extra 86 | version supports keeping a history of the game and allows you to go backward and 87 | forward in time. See if you can implement that! 88 | 89 | NOTE: This extra credit is one of the harder extra credits. Don't worry if you 90 | struggle on it! 91 | 92 | 💰 Tip, in the final example, we store the history of squares in an array of 93 | arrays. `[[/* step 0 squares */], [/* step 1 squares */], ...etc]`, so we have 94 | two states: `history` and `currentStep`. 95 | 96 | 💰 Tip, in the final example, we move the state management from the `Board` 97 | component to the `Game` component and that helps a bit. Here's what the JSX 98 | returned from the `Game` component is in the final version: 99 | 100 | ```javascript 101 | return ( 102 |
103 |
104 | 105 | 108 |
109 |
110 |
{status}
111 |
    {moves}
112 |
113 |
114 | ) 115 | ``` 116 | 117 | ## 🦉 Feedback 118 | 119 | Fill out 120 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=04%3A%20useState%3A%20tic%20tac%20toe&em=). 121 | -------------------------------------------------------------------------------- /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/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 | // 🐨 create a ref here with React.useRef() 10 | 11 | // 🐨 add a `React.useEffect` callback here and use VanillaTilt to make your 12 | // div look fancy. 13 | // 💰 like this: 14 | // const tiltNode = tiltRef.current 15 | // VanillaTilt.init(tiltNode, { 16 | // max: 25, 17 | // speed: 400, 18 | // glare: true, 19 | // 'max-glare': 0.5, 20 | // }) 21 | // 22 | // 💰 Don't forget to return a cleanup function. VanillaTilt.init will add an 23 | // object to your DOM node to cleanup: 24 | // `return () => tiltNode.vanillaTilt.destroy()` 25 | // 26 | // 💰 Don't forget to specify your effect's dependencies array! In our case 27 | // we know that the tilt node will never change, so make it `[]`. Ask me about 28 | // this for a more in depth explanation. 29 | 30 | // 🐨 add the `ref` prop to the `tilt-root` div here: 31 | return ( 32 |
33 |
{children}
34 |
35 | ) 36 | } 37 | 38 | function App() { 39 | return ( 40 | 41 |
vanilla-tilt.js
42 |
43 | ) 44 | } 45 | 46 | export default App 47 | -------------------------------------------------------------------------------- /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 function component. In fact, 13 | DOM nodes aren't created at all until the `ReactDOM.render` method is called. 14 | Your function component 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/exercise/06.js: -------------------------------------------------------------------------------- 1 | // useEffect: HTTP requests 2 | // http://localhost:3000/isolated/exercise/06.js 3 | 4 | import * as React from 'react' 5 | // 🐨 you'll want the following additional things from '../pokemon': 6 | // fetchPokemon: the function we call to get the pokemon info 7 | // PokemonInfoFallback: the thing we show while we're loading the pokemon info 8 | // PokemonDataView: the stuff we use to display the pokemon info 9 | import {PokemonForm} from '../pokemon' 10 | 11 | function PokemonInfo({pokemonName}) { 12 | // 🐨 Have state for the pokemon (null) 13 | // 🐨 use React.useEffect where the callback should be called whenever the 14 | // pokemon name changes. 15 | // 💰 DON'T FORGET THE DEPENDENCIES ARRAY! 16 | // 💰 if the pokemonName is falsy (an empty string) then don't bother making the request (exit early). 17 | // 🐨 before calling `fetchPokemon`, clear the current pokemon state by setting it to null. 18 | // (This is to enable the loading state when switching between different pokemon.) 19 | // 💰 Use the `fetchPokemon` function to fetch a pokemon by its name: 20 | // fetchPokemon('Pikachu').then( 21 | // pokemonData => {/* update all the state here */}, 22 | // ) 23 | // 🐨 return the following things based on the `pokemon` state and `pokemonName` prop: 24 | // 1. no pokemonName: 'Submit a pokemon' 25 | // 2. pokemonName but no pokemon: 26 | // 3. pokemon: 27 | 28 | // 💣 remove this 29 | return 'TODO' 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/exercise/06.md: -------------------------------------------------------------------------------- 1 | # useEffect: HTTP requests 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/06.md` 6 | 7 | ## Background 8 | 9 | HTTP requests are another common side-effect that we need to do in applications. 10 | This is no different from the side-effects we need to apply to a rendered DOM or 11 | when interacting with browser APIs like localStorage. In all these cases, we do 12 | that within a `useEffect` hook callback. This hook allows us to ensure that 13 | whenever certain changes take place, we apply the side-effects based on those 14 | changes. 15 | 16 | One important thing to note about the `useEffect` hook is that you cannot return 17 | anything other than the cleanup function. This has interesting implications with 18 | regard to async/await syntax: 19 | 20 | ```javascript 21 | // this does not work, don't do this: 22 | React.useEffect(async () => { 23 | const result = await doSomeAsyncThing() 24 | // do something with the result 25 | }) 26 | ``` 27 | 28 | The reason this doesn't work is because when you make a function async, it 29 | automatically returns a promise (whether you're not returning anything at all, 30 | or explicitly returning a function). This is due to the semantics of async/await 31 | syntax. So if you want to use async/await, the best way to do that is like so: 32 | 33 | ```javascript 34 | React.useEffect(() => { 35 | async function effect() { 36 | const result = await doSomeAsyncThing() 37 | // do something with the result 38 | } 39 | effect() 40 | }) 41 | ``` 42 | 43 | This ensures that you don't return anything but a cleanup function. 44 | 45 | 🦉 I find that it's typically just easier to extract all the async code into a 46 | utility function which I call and then use the promise-based `.then` method 47 | instead of using async/await syntax: 48 | 49 | ```javascript 50 | React.useEffect(() => { 51 | doSomeAsyncThing().then(result => { 52 | // do something with the result 53 | }) 54 | }) 55 | ``` 56 | 57 | But how you prefer to do this is totally up to you :) 58 | 59 | ## Exercise 60 | 61 | Production deploys: 62 | 63 | - [Exercise](https://react-hooks.netlify.app/isolated/exercise/06.js) 64 | - [Final](https://react-hooks.netlify.app/isolated/final/06.js) 65 | 66 | In this exercise, we'll be doing data fetching directly in a useEffect hook 67 | callback within our component. 68 | 69 | Here we have a form where users can enter the name of a pokemon and fetch data 70 | about that pokemon. Your job will be to create a component which makes that 71 | fetch request. When the user submits a pokemon name, our `PokemonInfo` component 72 | will get re-rendered with the `pokemonName` 73 | 74 | ## Extra Credit 75 | 76 | ### 1. 💯 handle errors 77 | 78 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-1.js) 79 | 80 | Unfortunately, sometimes things go wrong and we need to handle errors when they 81 | do so we can show the user useful information. Handle that error and render it 82 | out like so: 83 | 84 | ```jsx 85 |
86 | There was an error:
{error.message}
87 |
88 | ``` 89 | 90 | You can make an error happen by typing an incorrect pokemon name into the input. 91 | 92 | One common question I get about this extra credit is how to handle promise 93 | errors. There are two ways to do it in this extra credit: 94 | 95 | ```javascript 96 | // option 1: using .catch 97 | fetchPokemon(pokemonName) 98 | .then(pokemon => setPokemon(pokemon)) 99 | .catch(error => setError(error)) 100 | 101 | // option 2: using the second argument to .then 102 | fetchPokemon(pokemonName).then( 103 | pokemon => setPokemon(pokemon), 104 | error => setError(error), 105 | ) 106 | ``` 107 | 108 | These are functionally equivalent for our purposes, but they are semantically 109 | different in general. 110 | 111 | Using `.catch` means that you'll handle an error in the `fetchPokemon` promise, 112 | but you'll _also_ handle an error in the `setPokemon(pokemon)` call as well. 113 | This is due to the semantics of how promises work. 114 | 115 | Using the second argument to `.then` means that you will catch an error that 116 | happens in `fetchPokemon` only. In this case, I knew that calling `setPokemon` 117 | would not throw an error (React handles errors and we have an API to catch those 118 | which we'll use later), so I decided to go with the second argument option. 119 | 120 | However, in this situation, it doesn't really make much of a difference. If you 121 | want to go with the safe option, then opt for `.catch`. 122 | 123 | ### 2. 💯 use a status 124 | 125 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-2.js) 126 | 127 | Our logic for what to show the user when is kind of convoluted and requires that 128 | we be really careful about which state we set and when. 129 | 130 | We could make things much simpler by having some state to set the explicit 131 | status of our component. Our component can be in the following "states": 132 | 133 | - `idle`: no request made yet 134 | - `pending`: request started 135 | - `resolved`: request successful 136 | - `rejected`: request failed 137 | 138 | Try to use a status state by setting it to these string values rather than 139 | relying on existing state or booleans. 140 | 141 | Learn more about this concept here: 142 | [Stop using isLoading booleans](https://kentcdodds.com/blog/stop-using-isloading-booleans) 143 | 144 | 💰 Warning: Make sure you call `setPokemon` before calling `setStatus`. We'll 145 | address that more in the next extra credit. 146 | 147 | ### 3. 💯 store the state in an object 148 | 149 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-3.js) 150 | 151 | You'll notice that we're calling a bunch of state updaters in a row. This is 152 | normally not a problem, but each call to our state updater can result in a 153 | re-render of our component. React normally batches these calls so you only get a 154 | single re-render, but it's unable to do this in an asynchronous callback (like 155 | our promise success and error handlers). 156 | 157 | So you might notice that if you do this: 158 | 159 | ```javascript 160 | setStatus('resolved') 161 | setPokemon(pokemon) 162 | ``` 163 | 164 | You'll get an error indicating that you cannot read `image` of `null`. This is 165 | because the `setStatus` call results in a re-render that happens before the 166 | `setPokemon` happens. 167 | 168 | > but it's unable to do this in an asynchronous callback 169 | 170 | This is no longer the case in React 18 as it supports automatic batching for 171 | asynchronous callback too. 172 | 173 | Learn more about this concept here: 174 | [New Feature: Automatic Batching](https://react.dev/blog/2022/03/29/react-v18#new-feature-automatic-batching) 175 | 176 | Still it is better to maintain closely related states as an object rather than 177 | maintaining them using individual useState hooks. 178 | 179 | Learn more about this concept here: 180 | [Should I useState or useReducer?](https://kentcdodds.com/blog/should-i-usestate-or-usereducer#conclusion) 181 | 182 | In the future, you'll learn about how `useReducer` can solve this problem really 183 | elegantly, but we can still accomplish this by storing our state as an object 184 | that has all the properties of state we're managing. 185 | 186 | See if you can figure out how to store all of your state in a single object with 187 | a single `React.useState` call so I can update my state like this: 188 | 189 | ```javascript 190 | setState({status: 'resolved', pokemon}) 191 | ``` 192 | 193 | ### 4. 💯 create an ErrorBoundary component 194 | 195 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-4.js) 196 | 197 | We've already solved the problem for errors in our request, we're only handling 198 | that one error. But there are a lot of different kinds of errors that can happen 199 | in our applications. 200 | 201 | No matter how hard you try, eventually your app code just isn’t going to behave 202 | the way you expect it to and you’ll need to handle those exceptions. If an error 203 | is thrown and unhandled, your application will be removed from the page, leaving 204 | the user with a blank screen... Kind of awkward... 205 | 206 | Luckily for us, there’s a simple way to handle errors in your application using 207 | a special kind of component called an 208 | [Error Boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary). 209 | Unfortunately, there is currently no way to create an Error Boundary component 210 | with a function and you have to use a class component instead. 211 | 212 | In this extra credit, read up on ErrorBoundary components, and try to create one 213 | that handles this and any other error for the `PokemonInfo` component. 214 | 215 | 💰 to make your error boundary component handle errors from the `PokemonInfo` 216 | component, instead of rendering the error within the `PokemonInfo` component, 217 | you'll need to `throw error` right in the function so React can hand that to the 218 | error boundary. So `if (status === 'rejected') throw error`. 219 | 220 | ### 5. 💯 re-mount the error boundary 221 | 222 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-5.js) 223 | 224 | You might notice that with the changes we've added, we now cannot recover from 225 | an error. For example: 226 | 227 | 1. Type an incorrect pokemon 228 | 2. Notice the error 229 | 3. Type a correct pokemon 230 | 4. Notice it doesn't show that new pokemon's information 231 | 232 | The reason this is happening is because the `error` that's stored in the 233 | internal state of the `ErrorBoundary` component isn't getting reset, so it's not 234 | rendering the `children` we're passing to it. 235 | 236 | So what we need to do is reset the ErrorBoundary's `error` state to `null` so it 237 | will re-render. But how do we access the internal state of our `ErrorBoundary` 238 | to reset it? Well, there are a few ways we could do this by modifying the 239 | `ErrorBoundary`, but one thing you can do when you want to _reset_ the state of 240 | a component, is by providing it a `key` prop which can be used to unmount and 241 | re-mount a component. 242 | 243 | The `key` you can use? Try the `pokemonName`! 244 | 245 | ### 6. 💯 use react-error-boundary 246 | 247 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-6.js) 248 | 249 | As cool as our own `ErrorBoundary` is, I'd rather not have to maintain it in the 250 | long-term. Luckily for us, there's an npm package we can use instead and it's 251 | already installed into this project. It's called 252 | [`react-error-boundary`](https://github.com/bvaughn/react-error-boundary). 253 | 254 | Go ahead and give that a look and swap out our own `ErrorBoundary` for the one 255 | from `react-error-boundary`. 256 | 257 | 💰 It is important to note that `react-error-boundary` do _not_ handle all type 258 | of errors. In our example it works nicely because as mentioned in extra credit 4 259 | that we are throwing error right in the function. It can also happen that some 260 | errors are not handled by `react-error-boundary`. To learn more about how to 261 | handle all type of error you can read 262 | [Handle all errors](https://kentcdodds.com/blog/use-react-error-boundary-to-handle-errors-in-react#handle-all-errors). 263 | 264 | ### 7. 💯 reset the error boundary 265 | 266 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-7.js) 267 | 268 | You may have noticed a problem with the way we're resetting the internal state 269 | of the `ErrorBoundary` using the `key`. Unfortunately, we're not only 270 | re-mounting the `ErrorBoundary`, we're also re-mounting the `PokemonInfo` which 271 | results in a flash of the initial "Submit a pokemon" state whenever we change 272 | our pokemon. 273 | 274 | So let's backtrack on that and instead we'll use `react-error-boundary`'s 275 | `resetErrorBoundary` function (which will be passed to our `ErrorFallback` 276 | component) to reset the state of the `ErrorBoundary` when the user clicks a "try 277 | again" button. 278 | 279 | > 💰 feel free to open up the finished version by clicking the link in the app 280 | > so you can get an idea of how this is supposed to work. 281 | 282 | Once you have this button wired up, we need to react to this reset of the 283 | `ErrorBoundary`'s state by resetting our own state so we don't wind up 284 | triggering the error again. To do this we can use the `onReset` prop of the 285 | `ErrorBoundary`. In that function we can simply `setPokemonName` to an empty 286 | string. 287 | 288 | ### 8. 💯 use resetKeys 289 | 290 | [Production deploy](https://react-hooks.netlify.app/isolated/final/06.extra-8.js) 291 | 292 | Unfortunately now the user can't simply select a new pokemon and continue with 293 | their day. They have to first click "Try again" and then select their new 294 | pokemon. I think it would be cooler if they can just submit a new `pokemonName` 295 | and the `ErrorBoundary` would reset itself automatically. 296 | 297 | Luckily for us `react-error-boundary` supports this with the `resetKeys` prop. 298 | You pass an array of values to `resetKeys` and if the `ErrorBoundary` is in an 299 | error state and any of those values change, it will reset the error boundary. 300 | 301 | 💰 Your `resetKeys` prop should be: `[pokemonName]` 302 | 303 | ## 🦉 Feedback 304 | 305 | Fill out 306 | [the feedback form](https://ws.kcd.im/?ws=React%20Hooks%20%F0%9F%8E%A3&e=06%3A%20useEffect%3A%20HTTP%20requests&em=). 307 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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 | return squares.filter(Boolean).length % 2 === 0 ? 'X' : 'O' 87 | } 88 | 89 | function calculateWinner(squares) { 90 | const lines = [ 91 | [0, 1, 2], 92 | [3, 4, 5], 93 | [6, 7, 8], 94 | [0, 3, 6], 95 | [1, 4, 7], 96 | [2, 5, 8], 97 | [0, 4, 8], 98 | [2, 4, 6], 99 | ] 100 | for (let i = 0; i < lines.length; i++) { 101 | const [a, b, c] = lines[i] 102 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 103 | return squares[a] 104 | } 105 | } 106 | return null 107 | } 108 | 109 | function App() { 110 | return 111 | } 112 | 113 | export default App 114 | -------------------------------------------------------------------------------- /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 | return squares.filter(Boolean).length % 2 === 0 ? 'X' : 'O' 84 | } 85 | 86 | function calculateWinner(squares) { 87 | const lines = [ 88 | [0, 1, 2], 89 | [3, 4, 5], 90 | [6, 7, 8], 91 | [0, 3, 6], 92 | [1, 4, 7], 93 | [2, 5, 8], 94 | [0, 4, 8], 95 | [2, 4, 6], 96 | ] 97 | for (let i = 0; i < lines.length; i++) { 98 | const [a, b, c] = lines[i] 99 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 100 | return squares[a] 101 | } 102 | } 103 | return null 104 | } 105 | 106 | function App() { 107 | return 108 | } 109 | 110 | export default App 111 | -------------------------------------------------------------------------------- /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 | return squares.filter(Boolean).length % 2 === 0 ? 'X' : 'O' 108 | } 109 | 110 | function calculateWinner(squares) { 111 | const lines = [ 112 | [0, 1, 2], 113 | [3, 4, 5], 114 | [6, 7, 8], 115 | [0, 3, 6], 116 | [1, 4, 7], 117 | [2, 5, 8], 118 | [0, 4, 8], 119 | [2, 4, 6], 120 | ] 121 | for (let i = 0; i < lines.length; i++) { 122 | const [a, b, c] = lines[i] 123 | if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { 124 | return squares[a] 125 | } 126 | } 127 | return null 128 | } 129 | 130 | function App() { 131 | return 132 | } 133 | 134 | export default App 135 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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.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/index.js: -------------------------------------------------------------------------------- 1 | import './styles.css' 2 | import codegen from 'codegen.macro' 3 | 4 | codegen`module.exports = require('@kentcdodds/react-workshop-app/codegen')` 5 | -------------------------------------------------------------------------------- /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://wayfair.github.io/dociql/ 32 | // test pokemon queries here: https://graphql-pokemon2.vercel.app/ 33 | method: 'POST', 34 | headers: { 35 | 'content-type': 'application/json;charset=UTF-8', 36 | delay: delay, 37 | }, 38 | body: JSON.stringify({ 39 | query: pokemonQuery, 40 | variables: {name: name.toLowerCase()}, 41 | }), 42 | }) 43 | .then(async response => { 44 | const {data} = await response.json() 45 | if (response.ok) { 46 | const pokemon = data?.pokemon 47 | if (pokemon) { 48 | pokemon.fetchedAt = formatDate(new Date()) 49 | return pokemon 50 | } else { 51 | return Promise.reject(new Error(`No pokemon with the name "${name}"`)) 52 | } 53 | } else { 54 | // handle the graphql errors 55 | const error = { 56 | message: data?.errors?.map(e => e.message).join('\n'), 57 | } 58 | return Promise.reject(error) 59 | } 60 | }) 61 | } 62 | 63 | function PokemonInfoFallback({name}) { 64 | const initialName = React.useRef(name).current 65 | const fallbackPokemonData = { 66 | name: initialName, 67 | number: 'XXX', 68 | image: '/img/pokemon/fallback-pokemon.jpg', 69 | attacks: { 70 | special: [ 71 | {name: 'Loading Attack 1', type: 'Type', damage: 'XX'}, 72 | {name: 'Loading Attack 2', type: 'Type', damage: 'XX'}, 73 | ], 74 | }, 75 | fetchedAt: 'loading...', 76 | } 77 | return 78 | } 79 | 80 | function PokemonDataView({pokemon}) { 81 | return ( 82 |
    83 |
    84 | {pokemon.name} 85 |
    86 |
    87 |

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

    91 |
    92 |
    93 |
      94 | {pokemon.attacks.special.map(attack => ( 95 |
    • 96 | :{' '} 97 | 98 | {attack.damage} ({attack.type}) 99 | 100 |
    • 101 | ))} 102 |
    103 |
    104 | {pokemon.fetchedAt} 105 |
    106 | ) 107 | } 108 | 109 | function PokemonForm({ 110 | pokemonName: externalPokemonName, 111 | initialPokemonName = externalPokemonName || '', 112 | onSubmit, 113 | }) { 114 | const [pokemonName, setPokemonName] = React.useState(initialPokemonName) 115 | 116 | // this is generally not a great idea. We're synchronizing state when it is 117 | // normally better to derive it https://kentcdodds.com/blog/dont-sync-state-derive-it 118 | // however, we're doing things this way to make it easier for the exercises 119 | // to not have to worry about the logic for this PokemonForm component. 120 | React.useEffect(() => { 121 | // note that because it's a string value, if the externalPokemonName 122 | // is the same as the one we're managing, this will not trigger a re-render 123 | if (typeof externalPokemonName === 'string') { 124 | setPokemonName(externalPokemonName) 125 | } 126 | }, [externalPokemonName]) 127 | 128 | function handleChange(e) { 129 | setPokemonName(e.target.value) 130 | } 131 | 132 | function handleSubmit(e) { 133 | e.preventDefault() 134 | onSubmit(pokemonName) 135 | } 136 | 137 | function handleSelect(newPokemonName) { 138 | setPokemonName(newPokemonName) 139 | onSubmit(newPokemonName) 140 | } 141 | 142 | return ( 143 |
    144 | 145 | 146 | Try{' '} 147 | 154 | {', '} 155 | 162 | {', or '} 163 | 170 | 171 |
    172 | 180 | 183 |
    184 |
    185 | ) 186 | } 187 | 188 | function ErrorFallback({error, resetErrorBoundary}) { 189 | return ( 190 |
    191 | There was an error:{' '} 192 |
    {error.message}
    193 | 194 |
    195 | ) 196 | } 197 | 198 | function PokemonErrorBoundary(props) { 199 | return 200 | } 201 | 202 | export { 203 | PokemonInfoFallback, 204 | PokemonForm, 205 | PokemonDataView, 206 | fetchPokemon, 207 | PokemonErrorBoundary, 208 | } 209 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import '@kentcdodds/react-workshop-app/setup-tests' 2 | -------------------------------------------------------------------------------- /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/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 | // the = {} fixes the error we would get from destructuring when no argument was passed 14 | // Check https://jacobparis.com/blog/destructure-arguments for a detailed explanation 15 | {serialize = JSON.stringify, deserialize = JSON.parse} = {}, 16 | ) { 17 | const [state, setState] = React.useState(() => { 18 | const valueInLocalStorage = window.localStorage.getItem(key) 19 | if (valueInLocalStorage) { 20 | // the try/catch is here in case the localStorage value was set before 21 | // we had the serialization in place (like we do in previous extra credits) 22 | try { 23 | return deserialize(valueInLocalStorage) 24 | } catch (error) { 25 | window.localStorage.removeItem(key) 26 | } 27 | } 28 | return typeof defaultValue === 'function' ? defaultValue() : defaultValue 29 | }) 30 | 31 | const prevKeyRef = React.useRef(key) 32 | 33 | // Check the example at src/examples/local-state-key-change.js to visualize a key change 34 | React.useEffect(() => { 35 | const prevKey = prevKeyRef.current 36 | if (prevKey !== key) { 37 | window.localStorage.removeItem(prevKey) 38 | } 39 | prevKeyRef.current = key 40 | window.localStorage.setItem(key, serialize(state)) 41 | }, [key, state, serialize]) 42 | 43 | return [state, setState] 44 | } 45 | 46 | export {useLocalStorageState} 47 | --------------------------------------------------------------------------------