├── .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 ├── _headers ├── _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 └── serve.json ├── 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.extra-3.js ├── 02.js ├── 03.extra-2.js ├── 03.js ├── 04.js ├── 05.js ├── 06.extra-1.js └── 06.js ├── backend.js ├── exercise ├── 01.js ├── 01.md ├── 02.js ├── 02.md ├── 03.extra-2.js ├── 03.js ├── 03.md ├── 04.js ├── 04.md ├── 05.js ├── 05.md ├── 06-devtools-after.png ├── 06-devtools-before.png ├── 06.js └── 06.md ├── final ├── 01.extra-1.js ├── 01.extra-2.js ├── 01.extra-3.js ├── 01.extra-4.js ├── 01.js ├── 02.extra-1.js ├── 02.extra-2.js ├── 02.extra-3.js ├── 02.js ├── 03.extra-1.js ├── 03.extra-2.js ├── 03.js ├── 04.js ├── 05.js ├── 06.extra-1.js └── 06.js ├── index.js ├── pokemon.js ├── setupTests.js ├── styles.css └── utils.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "advanced-react-hooks", 3 | "projectOwner": "kentcdodds", 4 | "repoType": "github", 5 | "files": [ 6 | "README.md" 7 | ], 8 | "imageSize": 100, 9 | "commit": false, 10 | "contributors": [ 11 | { 12 | "login": "kentcdodds", 13 | "name": "Kent C. Dodds", 14 | "avatar_url": "https://avatars.githubusercontent.com/u/1500684?v=3", 15 | "profile": "https://kentcdodds.com", 16 | "contributions": [ 17 | "code", 18 | "doc", 19 | "infra", 20 | "test" 21 | ] 22 | }, 23 | { 24 | "login": "frankcalise", 25 | "name": "Frank Calise", 26 | "avatar_url": "https://avatars0.githubusercontent.com/u/374022?v=4", 27 | "profile": "http://frankcalise.com", 28 | "contributions": [ 29 | "code" 30 | ] 31 | }, 32 | { 33 | "login": "Zara603", 34 | "name": "Zara603", 35 | "avatar_url": "https://avatars1.githubusercontent.com/u/4918423?v=4", 36 | "profile": "https://github.com/Zara603", 37 | "contributions": [ 38 | "code" 39 | ] 40 | }, 41 | { 42 | "login": "michaelfriedman", 43 | "name": "Michael Friedman", 44 | "avatar_url": "https://avatars3.githubusercontent.com/u/17555926?v=4", 45 | "profile": "https://github.com/michaelfriedman", 46 | "contributions": [ 47 | "doc" 48 | ] 49 | }, 50 | { 51 | "login": "btnwtn", 52 | "name": "Brandon Newton", 53 | "avatar_url": "https://avatars1.githubusercontent.com/u/20847518?v=4", 54 | "profile": "https://bitwise.cool", 55 | "contributions": [ 56 | "doc", 57 | "code" 58 | ] 59 | }, 60 | { 61 | "login": "JonathanBruce", 62 | "name": "Jonathan Bruce", 63 | "avatar_url": "https://avatars3.githubusercontent.com/u/1743411?v=4", 64 | "profile": "https://github.com/JonathanBruce", 65 | "contributions": [ 66 | "code" 67 | ] 68 | }, 69 | { 70 | "login": "lgandecki", 71 | "name": "Łukasz Gandecki", 72 | "avatar_url": "https://avatars1.githubusercontent.com/u/4002543?v=4", 73 | "profile": "http://team.thebrain.pro", 74 | "contributions": [ 75 | "doc" 76 | ] 77 | }, 78 | { 79 | "login": "jdorfman", 80 | "name": "Justin Dorfman", 81 | "avatar_url": "https://avatars1.githubusercontent.com/u/398230?v=4", 82 | "profile": "https://stackshare.io/jdorfman/decisions", 83 | "contributions": [ 84 | "fundingFinding" 85 | ] 86 | }, 87 | { 88 | "login": "motdde", 89 | "name": "Oluwaseun Oyebade", 90 | "avatar_url": "https://avatars1.githubusercontent.com/u/12215060?v=4", 91 | "profile": "http://motdde.com", 92 | "contributions": [ 93 | "doc" 94 | ] 95 | }, 96 | { 97 | "login": "kevscript", 98 | "name": "Kevin Ostafinski", 99 | "avatar_url": "https://avatars0.githubusercontent.com/u/28754130?v=4", 100 | "profile": "http://kevinostafinski.com", 101 | "contributions": [ 102 | "doc" 103 | ] 104 | }, 105 | { 106 | "login": "Snaptags", 107 | "name": "Markus Lasermann", 108 | "avatar_url": "https://avatars1.githubusercontent.com/u/1249745?v=4", 109 | "profile": "https://github.com/Snaptags", 110 | "contributions": [ 111 | "code", 112 | "test" 113 | ] 114 | }, 115 | { 116 | "login": "zacjones93", 117 | "name": "Zac Jones", 118 | "avatar_url": "https://avatars2.githubusercontent.com/u/6188161?v=4", 119 | "profile": "https://zacjones.io", 120 | "contributions": [ 121 | "doc" 122 | ] 123 | }, 124 | { 125 | "login": "rbusquet", 126 | "name": "Ricardo Busquet", 127 | "avatar_url": "https://avatars1.githubusercontent.com/u/7198302?v=4", 128 | "profile": "https://ricardobusquet.com", 129 | "contributions": [ 130 | "code" 131 | ] 132 | }, 133 | { 134 | "login": "kylereblora", 135 | "name": "Kyle Matthew Reblora", 136 | "avatar_url": "https://avatars2.githubusercontent.com/u/33372538?v=4", 137 | "profile": "https://kylereblora.github.io/", 138 | "contributions": [ 139 | "doc" 140 | ] 141 | }, 142 | { 143 | "login": "marcosvega91", 144 | "name": "Marco Moretti", 145 | "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4", 146 | "profile": "https://github.com/marcosvega91", 147 | "contributions": [ 148 | "code" 149 | ] 150 | }, 151 | { 152 | "login": "nywleswoey", 153 | "name": "Selwyn Yeow", 154 | "avatar_url": "https://avatars3.githubusercontent.com/u/28249994?v=4", 155 | "profile": "https://github.com/nywleswoey", 156 | "contributions": [ 157 | "doc" 158 | ] 159 | }, 160 | { 161 | "login": "gugol2", 162 | "name": "Watchmaker", 163 | "avatar_url": "https://avatars0.githubusercontent.com/u/4933016?v=4", 164 | "profile": "https://github.com/gugol2", 165 | "contributions": [ 166 | "code", 167 | "doc" 168 | ] 169 | }, 170 | { 171 | "login": "fonstack", 172 | "name": "Carlos Fontes", 173 | "avatar_url": "https://avatars3.githubusercontent.com/u/35873992?v=4", 174 | "profile": "https://fonstack.dev/", 175 | "contributions": [ 176 | "bug" 177 | ] 178 | }, 179 | { 180 | "login": "PritamSangani", 181 | "name": "Pritam Sangani", 182 | "avatar_url": "https://avatars3.githubusercontent.com/u/22857896?v=4", 183 | "profile": "https://www.linkedin.com/in/pritamsangani/", 184 | "contributions": [ 185 | "code" 186 | ] 187 | }, 188 | { 189 | "login": "wbeuil", 190 | "name": "William BEUIL", 191 | "avatar_url": "https://avatars1.githubusercontent.com/u/8110579?v=4", 192 | "profile": "http://wbeuil.com", 193 | "contributions": [ 194 | "doc" 195 | ] 196 | }, 197 | { 198 | "login": "emzoumpo", 199 | "name": "Emmanouil Zoumpoulakis", 200 | "avatar_url": "https://avatars2.githubusercontent.com/u/2103443?v=4", 201 | "profile": "https://github.com/emzoumpo", 202 | "contributions": [ 203 | "doc" 204 | ] 205 | }, 206 | { 207 | "login": "Aprillion", 208 | "name": "Peter Hozák", 209 | "avatar_url": "https://avatars0.githubusercontent.com/u/1087670?v=4", 210 | "profile": "http://peter.hozak.info/", 211 | "contributions": [ 212 | "code" 213 | ] 214 | }, 215 | { 216 | "login": "joemaffei", 217 | "name": "Joe Maffei", 218 | "avatar_url": "https://avatars1.githubusercontent.com/u/9068746?v=4", 219 | "profile": "https://github.com/joemaffei", 220 | "contributions": [ 221 | "doc" 222 | ] 223 | }, 224 | { 225 | "login": "jmagrippis", 226 | "name": "Johnny Magrippis", 227 | "avatar_url": "https://avatars0.githubusercontent.com/u/3502800?v=4", 228 | "profile": "https://magrippis.com", 229 | "contributions": [ 230 | "code" 231 | ] 232 | }, 233 | { 234 | "login": "rphuber", 235 | "name": "Ryan Huber", 236 | "avatar_url": "https://avatars0.githubusercontent.com/u/8245890?v=4", 237 | "profile": "http://blog.rphuber.com", 238 | "contributions": [ 239 | "doc", 240 | "code" 241 | ] 242 | }, 243 | { 244 | "login": "dominicchapman", 245 | "name": "Dominic Chapman", 246 | "avatar_url": "https://avatars2.githubusercontent.com/u/7607007?v=4", 247 | "profile": "https://dominicchapman.com", 248 | "contributions": [ 249 | "doc" 250 | ] 251 | }, 252 | { 253 | "login": "imalbert", 254 | "name": "imalbert", 255 | "avatar_url": "https://avatars1.githubusercontent.com/u/12537973?v=4", 256 | "profile": "https://github.com/imalbert", 257 | "contributions": [ 258 | "doc" 259 | ] 260 | }, 261 | { 262 | "login": "Huuums", 263 | "name": "Dennis Collon", 264 | "avatar_url": "https://avatars1.githubusercontent.com/u/9745322?v=4", 265 | "profile": "https://github.com/Huuums", 266 | "contributions": [ 267 | "doc" 268 | ] 269 | }, 270 | { 271 | "login": "jrozbicki", 272 | "name": "Jakub Różbicki", 273 | "avatar_url": "https://avatars3.githubusercontent.com/u/35103924?v=4", 274 | "profile": "https://github.com/jrozbicki", 275 | "contributions": [ 276 | "doc" 277 | ] 278 | }, 279 | { 280 | "login": "vasilii-kovalev", 281 | "name": "Vasilii Kovalev", 282 | "avatar_url": "https://avatars0.githubusercontent.com/u/10310491?v=4", 283 | "profile": "https://vk.com/vasilii_kovalev", 284 | "contributions": [ 285 | "bug" 286 | ] 287 | }, 288 | { 289 | "login": "alexfertel", 290 | "name": "Alexander Gonzalez", 291 | "avatar_url": "https://avatars3.githubusercontent.com/u/22298999?v=4", 292 | "profile": "http://alexfertel.netlify.app", 293 | "contributions": [ 294 | "code" 295 | ] 296 | }, 297 | { 298 | "login": "DaleSeo", 299 | "name": "Dale Seo", 300 | "avatar_url": "https://avatars1.githubusercontent.com/u/5466341?v=4", 301 | "profile": "https://www.daleseo.com", 302 | "contributions": [ 303 | "doc", 304 | "test" 305 | ] 306 | }, 307 | { 308 | "login": "MichaelDeBoey", 309 | "name": "Michaël De Boey", 310 | "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4", 311 | "profile": "https://michaeldeboey.be", 312 | "contributions": [ 313 | "code" 314 | ] 315 | }, 316 | { 317 | "login": "thegoodsheppard", 318 | "name": "Greg Sheppard", 319 | "avatar_url": "https://avatars1.githubusercontent.com/u/13774377?v=4", 320 | "profile": "https://github.com/thegoodsheppard", 321 | "contributions": [ 322 | "doc" 323 | ] 324 | }, 325 | { 326 | "login": "bobbywarner", 327 | "name": "Bobby Warner", 328 | "avatar_url": "https://avatars0.githubusercontent.com/u/554961?v=4", 329 | "profile": "http://bobbywarner.com", 330 | "contributions": [ 331 | "code" 332 | ] 333 | }, 334 | { 335 | "login": "jwm0", 336 | "name": "Jakub Majorek", 337 | "avatar_url": "https://avatars0.githubusercontent.com/u/28310983?v=4", 338 | "profile": "https://github.com/jwm0", 339 | "contributions": [ 340 | "code" 341 | ] 342 | }, 343 | { 344 | "login": "suddenlyGiovanni", 345 | "name": "Giovanni Ravalico", 346 | "avatar_url": "https://avatars2.githubusercontent.com/u/15946771?v=4", 347 | "profile": "https://suddenlyGiovanni.dev", 348 | "contributions": [ 349 | "ideas" 350 | ] 351 | }, 352 | { 353 | "login": "jsberlanga", 354 | "name": "Julio Soto", 355 | "avatar_url": "https://avatars.githubusercontent.com/u/32543746?v=4", 356 | "profile": "https://juliosoto.dev", 357 | "contributions": [ 358 | "code" 359 | ] 360 | }, 361 | { 362 | "login": "jmtes", 363 | "name": "Juno Tesoro", 364 | "avatar_url": "https://avatars.githubusercontent.com/u/38450133?v=4", 365 | "profile": "http://jmtes.github.io", 366 | "contributions": [ 367 | "doc" 368 | ] 369 | }, 370 | { 371 | "login": "aosante", 372 | "name": "Andrés Osante", 373 | "avatar_url": "https://avatars.githubusercontent.com/u/37124700?v=4", 374 | "profile": "http://www.andresosante.com", 375 | "contributions": [ 376 | "code" 377 | ] 378 | }, 379 | { 380 | "login": "IanVS", 381 | "name": "Ian VanSchooten", 382 | "avatar_url": "https://avatars.githubusercontent.com/u/4616705?v=4", 383 | "profile": "https://github.com/IanVS", 384 | "contributions": [ 385 | "test" 386 | ] 387 | }, 388 | { 389 | "login": "giancarlol", 390 | "name": "Giancarlo Brusca", 391 | "avatar_url": "https://avatars.githubusercontent.com/u/33439343?v=4", 392 | "profile": "https://github.com/giancarlol", 393 | "contributions": [ 394 | "doc" 395 | ] 396 | }, 397 | { 398 | "login": "tsargent", 399 | "name": "Tyler Sargent", 400 | "avatar_url": "https://avatars.githubusercontent.com/u/173215?v=4", 401 | "profile": "https://github.com/tsargent", 402 | "contributions": [ 403 | "test" 404 | ] 405 | }, 406 | { 407 | "login": "pvinis", 408 | "name": "Pavlos Vinieratos", 409 | "avatar_url": "https://avatars.githubusercontent.com/u/100233?v=4", 410 | "profile": "http://pavlos.dev", 411 | "contributions": [ 412 | "doc" 413 | ] 414 | }, 415 | { 416 | "login": "Hillsie", 417 | "name": "Hills", 418 | "avatar_url": "https://avatars.githubusercontent.com/u/17975287?v=4", 419 | "profile": "https://github.com/Hillsie", 420 | "contributions": [ 421 | "doc" 422 | ] 423 | }, 424 | { 425 | "login": "diegotc86", 426 | "name": "Diego Torres", 427 | "avatar_url": "https://avatars.githubusercontent.com/u/23508800?v=4", 428 | "profile": "https://github.com/diegotc86", 429 | "contributions": [ 430 | "code" 431 | ] 432 | }, 433 | { 434 | "login": "icyJoseph", 435 | "name": "Joseph", 436 | "avatar_url": "https://avatars.githubusercontent.com/u/21013447?v=4", 437 | "profile": "https://icyjoseph.dev/", 438 | "contributions": [ 439 | "doc" 440 | ] 441 | }, 442 | { 443 | "login": "marioleed", 444 | "name": "Mario Sannum", 445 | "avatar_url": "https://avatars.githubusercontent.com/u/1763448?v=4", 446 | "profile": "https://github.com/marioleed", 447 | "contributions": [ 448 | "code" 449 | ] 450 | }, 451 | { 452 | "login": "wdj82", 453 | "name": "wdj82", 454 | "avatar_url": "https://avatars.githubusercontent.com/u/37749088?v=4", 455 | "profile": "https://github.com/wdj82", 456 | "contributions": [ 457 | "doc" 458 | ] 459 | }, 460 | { 461 | "login": "ssmkhrj", 462 | "name": "Som Shekhar Mukherjee", 463 | "avatar_url": "https://avatars.githubusercontent.com/u/49264891?v=4", 464 | "profile": "https://github.com/ssmkhrj", 465 | "contributions": [ 466 | "code" 467 | ] 468 | }, 469 | { 470 | "login": "DarkHorse1997", 471 | "name": "Tanmoy Das", 472 | "avatar_url": "https://avatars.githubusercontent.com/u/22052923?v=4", 473 | "profile": "https://github.com/DarkHorse1997", 474 | "contributions": [ 475 | "doc" 476 | ] 477 | }, 478 | { 479 | "login": "maheshjag", 480 | "name": "MJ", 481 | "avatar_url": "https://avatars.githubusercontent.com/u/1705603?v=4", 482 | "profile": "https://github.com/maheshjag", 483 | "contributions": [ 484 | "doc" 485 | ] 486 | }, 487 | { 488 | "login": "SherylHohman", 489 | "name": "Sheryl Hohman", 490 | "avatar_url": "https://avatars.githubusercontent.com/u/8204778?v=4", 491 | "profile": "https://stackoverflow.com/users/5411817/sherylhohman?tab=topactivity", 492 | "contributions": [ 493 | "code" 494 | ] 495 | }, 496 | { 497 | "login": "shahbaz17", 498 | "name": "Mohammad Shahbaz Alam", 499 | "avatar_url": "https://avatars.githubusercontent.com/u/6962565?v=4", 500 | "profile": "http://mdsbzalam.dev", 501 | "contributions": [ 502 | "doc" 503 | ] 504 | }, 505 | { 506 | "login": "anabellaspinelli", 507 | "name": "Anabella", 508 | "avatar_url": "https://avatars.githubusercontent.com/u/7825875?v=4", 509 | "profile": "https://github.com/anabellaspinelli", 510 | "contributions": [ 511 | "doc" 512 | ] 513 | }, 514 | { 515 | "login": "lsminter", 516 | "name": "Lucas Minter", 517 | "avatar_url": "https://avatars.githubusercontent.com/u/26470581?v=4", 518 | "profile": "http://lucasminter.dev", 519 | "contributions": [ 520 | "doc" 521 | ] 522 | }, 523 | { 524 | "login": "leggsimon", 525 | "name": "Simon Legg", 526 | "avatar_url": "https://avatars.githubusercontent.com/u/11544418?v=4", 527 | "profile": "https://github.com/leggsimon", 528 | "contributions": [ 529 | "doc" 530 | ] 531 | }, 532 | { 533 | "login": "kenneth-gray", 534 | "name": "Kenny Gray", 535 | "avatar_url": "https://avatars.githubusercontent.com/u/10341832?v=4", 536 | "profile": "https://github.com/kenneth-gray", 537 | "contributions": [ 538 | "doc" 539 | ] 540 | }, 541 | { 542 | "login": "alexsurelee", 543 | "name": "Alex Lee", 544 | "avatar_url": "https://avatars.githubusercontent.com/u/11603625?v=4", 545 | "profile": "https://github.com/alexsurelee", 546 | "contributions": [ 547 | "doc" 548 | ] 549 | }, 550 | { 551 | "login": "plumcoding", 552 | "name": "plumcoding", 553 | "avatar_url": "https://avatars.githubusercontent.com/u/88927709?v=4", 554 | "profile": "https://github.com/plumcoding", 555 | "contributions": [ 556 | "test" 557 | ] 558 | }, 559 | { 560 | "login": "CNate", 561 | "name": "Nathan", 562 | "avatar_url": "https://avatars.githubusercontent.com/u/13683291?v=4", 563 | "profile": "https://github.com/CNate", 564 | "contributions": [ 565 | "doc" 566 | ] 567 | }, 568 | { 569 | "login": "GavinOsborn", 570 | "name": "Gavin Osborn", 571 | "avatar_url": "https://avatars.githubusercontent.com/u/581588?v=4", 572 | "profile": "https://github.com/GavinOsborn", 573 | "contributions": [ 574 | "doc" 575 | ] 576 | }, 577 | { 578 | "login": "creador-dev", 579 | "name": "Pawan Kumar", 580 | "avatar_url": "https://avatars.githubusercontent.com/u/40248406?v=4", 581 | "profile": "https://creador.dev", 582 | "contributions": [ 583 | "doc" 584 | ] 585 | }, 586 | { 587 | "login": "LorisYanis", 588 | "name": "Loris-Yanis", 589 | "avatar_url": "https://avatars.githubusercontent.com/u/115280526?v=4", 590 | "profile": "https://github.com/LorisYanis", 591 | "contributions": [ 592 | "code" 593 | ] 594 | }, 595 | { 596 | "login": "ianjmacintosh", 597 | "name": "Ian MacIntosh", 598 | "avatar_url": "https://avatars.githubusercontent.com/u/1103259?v=4", 599 | "profile": "http://www.ianjmacintosh.com", 600 | "contributions": [ 601 | "doc" 602 | ] 603 | }, 604 | { 605 | "login": "Creeland", 606 | "name": "Creeland A. Provinsal ", 607 | "avatar_url": "https://avatars.githubusercontent.com/u/518406?v=4", 608 | "profile": "https://github.com/Creeland", 609 | "contributions": [ 610 | "doc" 611 | ] 612 | } 613 | ], 614 | "contributorsPerLine": 7, 615 | "repoHost": "https://github.com", 616 | "skipCi": true, 617 | "commitConvention": "angular", 618 | "commitType": "docs" 619 | } 620 | -------------------------------------------------------------------------------- /.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 | npx "https://gist.github.com/kentcdodds/2d44448a8997b9964b1be44cd294d1f5" \ 50 | && exit 0 51 | ## The 'ports' section defines various ports your may listen on are 52 | ## configured in Gitpod on an authenticated URL. By default, all ports 53 | ## are in private visibility state. 54 | ## 55 | ## Learn more about ports at 'https://www.gitpod.io/docs/config-ports' 56 | 57 | ports: 58 | - port: 3000 # alternatively configure entire ranges via '8080-8090' 59 | visibility: private # either 'public' or 'private' (default) 60 | onOpen: open-browser # either 'open-browser', 'open-preview' or 'ignore' 61 | 62 | ## The 'vscode' section defines a list of Visual Studio Code extensions from 63 | ## the OpenVSX.org registry to be installed upon workspace startup. OpenVSX 64 | ## is an open alternative to the proprietary Visual Studio Code Marketplace 65 | ## and extensions can be added by sending a pull-request with the extension 66 | ## identifier to https://github.com/open-vsx/publish-extensions 67 | ## 68 | ## The identifier of an extension is always ${publisher}.${name}. 69 | ## 70 | ## For example: 'vscodevim.vim' 71 | ## 72 | ## Learn more at 'https://www.gitpod.io/docs/ides-and-editors/vscode' 73 | 74 | vscode: 75 | extensions: 76 | - VisualStudioExptTeam.vscodeintellicode 77 | - dbaeumer.vscode-eslint 78 | - formulahendry.auto-rename-tag 79 | - esbenp.prettier-vscode 80 | - ms-azuretools.vscode-docker 81 | 82 | ## The 'github' section defines configuration of continuous prebuilds 83 | ## for GitHub repositories when the GitHub application 84 | ## 'https://github.com/apps/gitpod-io' is installed in GitHub and granted 85 | ## permissions to access the repository. 86 | ## 87 | ## Learn more at 'https://www.gitpod.io/docs/prebuilds' 88 | 89 | github: 90 | prebuilds: 91 | # enable for the default branch 92 | master: true 93 | # enable for all branches in this repo 94 | branches: false 95 | # enable for pull requests coming from this repo 96 | pullRequests: false 97 | # enable for pull requests coming from forks 98 | pullRequestsFromForks: false 99 | # add a check to pull requests 100 | addCheck: false 101 | # add a "Review in Gitpod" button as a comment to pull requests 102 | addComment: false 103 | # add a "Review in Gitpod" button to the pull request's description 104 | addBadge: false 105 | -------------------------------------------------------------------------------- /.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 15 | > pull requests from branches on your fork. To do this, run: 16 | > 17 | > ``` 18 | > git remote add upstream https://github.com/kentcdodds/advanced-react-hooks.git 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/main main 21 | > ``` 22 | > 23 | > This will add the original repository as a "remote" called "upstream," Then 24 | > fetch the git information from that remote, then set your local `main` 25 | > branch to use the upstream main branch whenever you run `git pull`. Then you 26 | > can make all of your pull request branches based on this `main` branch. 27 | > Whenever you want to update your version of `main`, do a regular `git pull`. 28 | 29 | ## Help needed 30 | 31 | Please checkout the [the open issues][issues] 32 | 33 | Also, please watch the repo and respond to questions/bug reports/feature 34 | requests! Thanks! 35 | 36 | [egghead]: 37 | https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github 38 | [issues]: https://github.com/kentcdodds/advanced-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 |

🔥 Advanced React Hooks 🚀 EpicReact.Dev

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

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

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

💻 📖 🚇 ⚠️
Frank Calise
Frank Calise

💻
Zara603
Zara603

💻
Michael Friedman
Michael Friedman

📖
Brandon Newton
Brandon Newton

📖 💻
Jonathan Bruce
Jonathan Bruce

💻
Łukasz Gandecki
Łukasz Gandecki

📖
Justin Dorfman
Justin Dorfman

🔍
Oluwaseun Oyebade
Oluwaseun Oyebade

📖
Kevin Ostafinski
Kevin Ostafinski

📖
Markus Lasermann
Markus Lasermann

💻 ⚠️
Zac Jones
Zac Jones

📖
Ricardo Busquet
Ricardo Busquet

💻
Kyle Matthew Reblora
Kyle Matthew Reblora

📖
Marco Moretti
Marco Moretti

💻
Selwyn Yeow
Selwyn Yeow

📖
Watchmaker
Watchmaker

💻 📖
Carlos Fontes
Carlos Fontes

🐛
Pritam Sangani
Pritam Sangani

💻
William BEUIL
William BEUIL

📖
Emmanouil Zoumpoulakis
Emmanouil Zoumpoulakis

📖
Peter Hozák
Peter Hozák

💻
Joe Maffei
Joe Maffei

📖
Johnny Magrippis
Johnny Magrippis

💻
Ryan Huber
Ryan Huber

📖 💻
Dominic Chapman
Dominic Chapman

📖
imalbert
imalbert

📖
Dennis Collon
Dennis Collon

📖
Jakub Różbicki
Jakub Różbicki

📖
Vasilii Kovalev
Vasilii Kovalev

🐛
Alexander Gonzalez
Alexander Gonzalez

💻
Dale Seo
Dale Seo

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

💻
Greg Sheppard
Greg Sheppard

📖
Bobby Warner
Bobby Warner

💻
Jakub Majorek
Jakub Majorek

💻
Giovanni Ravalico
Giovanni Ravalico

🤔
Julio Soto
Julio Soto

💻
Juno Tesoro
Juno Tesoro

📖
Andrés Osante
Andrés Osante

💻
Ian VanSchooten
Ian VanSchooten

⚠️
Giancarlo Brusca
Giancarlo Brusca

📖
Tyler Sargent
Tyler Sargent

⚠️
Pavlos Vinieratos
Pavlos Vinieratos

📖
Hills
Hills

📖
Diego Torres
Diego Torres

💻
Joseph
Joseph

📖
Mario Sannum
Mario Sannum

💻
wdj82
wdj82

📖
Som Shekhar Mukherjee
Som Shekhar Mukherjee

💻
Tanmoy Das
Tanmoy Das

📖
MJ
MJ

📖
Sheryl Hohman
Sheryl Hohman

💻
Mohammad Shahbaz Alam
Mohammad Shahbaz Alam

📖
Anabella
Anabella

📖
Lucas Minter
Lucas Minter

📖
Simon Legg
Simon Legg

📖
Kenny Gray
Kenny Gray

📖
Alex Lee
Alex Lee

📖
plumcoding
plumcoding

⚠️
Nathan
Nathan

📖
Gavin Osborn
Gavin Osborn

📖
Pawan Kumar
Pawan Kumar

📖
Loris-Yanis
Loris-Yanis

💻
Ian MacIntosh
Ian MacIntosh

📖
Creeland A. Provinsal
Creeland A. Provinsal

📖
277 | 278 | 279 | 280 | 281 | 282 | 283 | This project follows the 284 | [all-contributors](https://github.com/kentcdodds/all-contributors) 285 | specification. Contributions of any kind welcome! 286 | 287 | ## Workshop Feedback 288 | 289 | Each exercise has an Elaboration and Feedback link. Please fill that out after 290 | the exercise and instruction. 291 | 292 | At the end of the workshop, please go to this URL to give overall feedback. 293 | Thank you! https://kcd.im/arh-ws-feedback 294 | 295 | 296 | [npm]: https://www.npmjs.com/ 297 | [node]: https://nodejs.org 298 | [git]: https://git-scm.com/ 299 | [build-badge]: https://img.shields.io/github/actions/workflow/status/kentcdodds/advanced-react-hooks/validate.yml?branch=main&logo=github&style=flat-square 300 | [build]: https://github.com/kentcdodds/advanced-react-hooks/actions?query=workflow%3Avalidate 301 | [license-badge]: https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square 302 | [license]: https://github.com/kentcdodds/advanced-react-hooks/blob/main/LICENSE 303 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 304 | [gitpod-badge]: https://img.shields.io/badge/Gitpod-ready--to--code-908a85?logo=gitpod 305 | [coc]: https://github.com/kentcdodds/advanced-react-hooks/blob/main/CODE_OF_CONDUCT.md 306 | [emojis]: https://github.com/kentcdodds/all-contributors#emoji-key 307 | [all-contributors]: https://github.com/kentcdodds/all-contributors 308 | [all-contributors-badge]: https://img.shields.io/github/all-contributors/kentcdodds/advanced-react-hooks?color=orange&style=flat-square 309 | [win-path]: https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/ 310 | [mac-path]: http://stackoverflow.com/a/24322978/971592 311 | [issue]: https://github.com/kentcdodds/advanced-react-hooks/issues/new 312 | 313 | -------------------------------------------------------------------------------- /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": "advanced-react-hooks", 3 | "title": "Advanced React Hooks 🔥", 4 | "description": "The best resources for you to learn advanced react hooks", 5 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 6 | "version": "1.0.0", 7 | "private": true, 8 | "keywords": [], 9 | "homepage": "https://advanced-react-hooks.netlify.com/", 10 | "license": "GPL-3.0-only", 11 | "main": "src/index.js", 12 | "engines": { 13 | "node": ">=16", 14 | "npm": ">=8.16.0" 15 | }, 16 | "dependencies": { 17 | "@kentcdodds/react-workshop-app": "^6.0.1", 18 | "@testing-library/react": "^13.3.0", 19 | "@testing-library/user-event": "^14.2.1", 20 | "chalk": "^4.1.2", 21 | "codegen.macro": "^4.1.0", 22 | "mq-polyfill": "^1.1.8", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-error-boundary": "^3.1.4" 26 | }, 27 | "devDependencies": { 28 | "@craco/craco": "^6.4.3", 29 | "@types/react": "^18.0.14", 30 | "@types/react-dom": "^18.0.5", 31 | "husky": "^4.3.8", 32 | "npm-run-all": "^4.1.5", 33 | "prettier": "^2.7.1", 34 | "react-scripts": "^5.0.1" 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/advanced-react-hooks.git" 77 | }, 78 | "bugs": { 79 | "url": "https://github.com/kentcdodds/advanced-react-hooks/issues" 80 | }, 81 | "msw": { 82 | "workerDirectory": "public" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /img/* 2 | # we want to cache these images for one hour 3 | cache-control: public,max-age=3600,immutable 4 | /img/pokemon/* 5 | # we want to cache these images for one hour 6 | cache-control: public,max-age=3600,immutable -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/public/favicon.ico -------------------------------------------------------------------------------- /public/img/pokemon/bulbasaur.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/public/img/pokemon/bulbasaur.jpg -------------------------------------------------------------------------------- /public/img/pokemon/charizard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/public/img/pokemon/charizard.jpg -------------------------------------------------------------------------------- /public/img/pokemon/ditto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/public/img/pokemon/ditto.jpg -------------------------------------------------------------------------------- /public/img/pokemon/fallback-pokemon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/public/img/pokemon/fallback-pokemon.jpg -------------------------------------------------------------------------------- /public/img/pokemon/mew.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/public/img/pokemon/mew.jpg -------------------------------------------------------------------------------- /public/img/pokemon/mewtwo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/public/img/pokemon/mewtwo.jpg -------------------------------------------------------------------------------- /public/img/pokemon/pikachu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/public/img/pokemon/pikachu.jpg -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | Advanced React Hooks 🔥 13 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Advanced React Hooks", 3 | "name": "Advanced React Hooks", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#1675ff", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": [ 3 | { 4 | "source": "/img/pokemon/*", 5 | "headers": [ 6 | { 7 | "key": "cache-control", 8 | "value": "public,max-age=3600,immutable" 9 | } 10 | ] 11 | }, 12 | { 13 | "source": "/img/*", 14 | "headers": [ 15 | { 16 | "key": "cache-control", 17 | "value": "public,max-age=3600,immutable" 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /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 {alfredTip} from '@kentcdodds/react-workshop-app/test-utils' 3 | import {render} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import App from '../final/01' 6 | // import App from '../exercise/01' 7 | import react from 'react' 8 | 9 | // don't do this in regular tests! 10 | const Counter = App().type 11 | 12 | jest.mock('react', () => {return { 13 | ...jest.requireActual('react'), 14 | useReducer: jest.fn(), 15 | useState: jest.fn(), 16 | } 17 | }) 18 | 19 | if (!Counter) { 20 | alfredTip( 21 | true, 22 | `Can't find the Counter from the exported App component. Please make sure to not edit the App component so I can find the Counter and run some tests on it.`, 23 | ) 24 | } 25 | 26 | beforeEach(() => { 27 | const {useReducer, useState} = jest.requireActual('react') 28 | react.useReducer.mockImplementation(useReducer) 29 | react.useState.mockImplementation(useState) 30 | }) 31 | 32 | test('clicking the button increments the count with useReducer', async () => { 33 | const {container} = render() 34 | const button = container.querySelector('button') 35 | await userEvent.click(button) 36 | expect(button).toHaveTextContent('1') 37 | await userEvent.click(button) 38 | expect(button).toHaveTextContent('2') 39 | 40 | alfredTip(() => { 41 | expect(react.useReducer).toHaveBeenCalled() 42 | expect(react.useState).not.toHaveBeenCalled() 43 | }, 'The Counter component that is rendered must call "useReducer" and not "useState" to get the "state" and "dispatch" function and you should get rid of that useState call.') 44 | }) 45 | -------------------------------------------------------------------------------- /src/__tests__/02.extra-3.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils' 3 | import {render, screen, act} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import App from '../final/02.extra-3' 6 | // import App from '../exercise/02' 7 | 8 | beforeEach(() => { 9 | jest.spyOn(window, 'fetch') 10 | jest.spyOn(console, 'error') 11 | }) 12 | 13 | afterEach(() => { 14 | window.fetch.mockRestore() 15 | console.error.mockRestore() 16 | }) 17 | 18 | test('displays the pokemon', async () => { 19 | const {unmount} = render() 20 | const input = screen.getByLabelText(/pokemon/i) 21 | const submit = screen.getByText(/^submit$/i) 22 | 23 | // verify that an initial request is made when mounted 24 | await userEvent.type(input, 'pikachu') 25 | await userEvent.click(submit) 26 | 27 | await screen.findByRole('heading', {name: /pikachu/i}) 28 | 29 | // verify that a request is made when props change 30 | await userEvent.clear(input) 31 | await userEvent.type(input, 'ditto') 32 | await userEvent.click(submit) 33 | 34 | await screen.findByRole('heading', {name: /ditto/i}) 35 | 36 | // verify that when props remain the same a request is not made 37 | window.fetch.mockClear() 38 | 39 | await userEvent.click(submit) 40 | 41 | await screen.findByRole('heading', {name: /ditto/i}) 42 | 43 | alfredTip( 44 | () => expect(window.fetch).not.toHaveBeenCalled(), 45 | 'Make certain that you are providing a dependencies list in useEffect!', 46 | ) 47 | 48 | // verify error handling 49 | console.error.mockImplementation(() => {}) 50 | 51 | await userEvent.clear(input) 52 | await userEvent.type(input, 'george') 53 | await userEvent.click(submit) 54 | expect(await screen.findByRole('alert')).toHaveTextContent( 55 | /There was an error.*Unsupported pokemon.*george/, 56 | ) 57 | expect(console.error).toHaveBeenCalledTimes(3) 58 | 59 | // restore the original implementation 60 | console.error.mockRestore() 61 | // but we still want to make sure it's not called 62 | jest.spyOn(console, 'error') 63 | 64 | await userEvent.type(input, 'mew') 65 | await userEvent.click(submit) 66 | 67 | // verify unmounting does not result in an error 68 | unmount() 69 | // wait for a bit for the mocked request to resolve: 70 | await act(() => new Promise(r => setTimeout(r, 100))) 71 | alfredTip( 72 | () => expect(console.error).not.toHaveBeenCalled(), 73 | 'Make sure that when the component is unmounted the component does not attempt to trigger a rerender with `dispatch`', 74 | ) 75 | }) 76 | -------------------------------------------------------------------------------- /src/__tests__/02.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils' 3 | import {render, screen} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import App from '../final/02' 6 | // import App from '../exercise/02' 7 | 8 | beforeEach(() => { 9 | jest.spyOn(window, 'fetch') 10 | jest.spyOn(console, 'error') 11 | }) 12 | 13 | afterEach(() => { 14 | window.fetch.mockRestore() 15 | console.error.mockRestore() 16 | }) 17 | 18 | test('displays the pokemon', async () => { 19 | render() 20 | const input = screen.getByLabelText(/pokemon/i) 21 | const submit = screen.getByText(/^submit$/i) 22 | 23 | // verify that an initial request is made when mounted 24 | await userEvent.type(input, 'pikachu') 25 | await userEvent.click(submit) 26 | 27 | await screen.findByRole('heading', {name: /pikachu/i}) 28 | 29 | // verify that a request is made when props change 30 | await userEvent.clear(input) 31 | await userEvent.type(input, 'ditto') 32 | await userEvent.click(submit) 33 | 34 | await screen.findByRole('heading', {name: /ditto/i}) 35 | 36 | // verify that when props remain the same a request is not made 37 | window.fetch.mockClear() 38 | 39 | await userEvent.click(submit) 40 | 41 | await screen.findByRole('heading', {name: /ditto/i}) 42 | 43 | alfredTip( 44 | () => expect(window.fetch).not.toHaveBeenCalled(), 45 | 'Make certain that you are providing a dependencies list in useEffect!', 46 | ) 47 | 48 | // verify error handling 49 | console.error.mockImplementation(() => {}) 50 | 51 | await userEvent.clear(input) 52 | await userEvent.type(input, 'george') 53 | await userEvent.click(submit) 54 | expect(await screen.findByRole('alert')).toHaveTextContent( 55 | /There was an error.*Unsupported pokemon.*george/, 56 | ) 57 | expect(console.error).toHaveBeenCalledTimes(3) 58 | 59 | console.error.mockReset() 60 | }) 61 | -------------------------------------------------------------------------------- /src/__tests__/03.extra-2.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils' 3 | import {render, screen} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import App from '../final/03.extra-2' 6 | // import App from '../exercise/03.extra-2' 7 | 8 | beforeEach(() => { 9 | jest.spyOn(window, 'fetch') 10 | jest.spyOn(console, 'error') 11 | }) 12 | 13 | afterEach(() => { 14 | window.fetch.mockRestore() 15 | console.error.mockRestore() 16 | }) 17 | 18 | test('displays the pokemon', async () => { 19 | render() 20 | const input = screen.getByLabelText(/pokemon/i) 21 | const submit = screen.getByText(/^submit$/i) 22 | 23 | // verify that an initial request is made when mounted 24 | await userEvent.type(input, 'pikachu') 25 | await userEvent.click(submit) 26 | 27 | await screen.findByRole('heading', {name: /pikachu/i}) 28 | 29 | // verify that a request is made when props change 30 | await userEvent.clear(input) 31 | await userEvent.type(input, 'ditto') 32 | await userEvent.click(submit) 33 | 34 | await screen.findByRole('heading', {name: /ditto/i}) 35 | 36 | // verify that when props remain the same a request is not made 37 | window.fetch.mockClear() 38 | 39 | await userEvent.click(submit) 40 | 41 | await screen.findByRole('heading', {name: /ditto/i}) 42 | 43 | alfredTip( 44 | () => expect(window.fetch).not.toHaveBeenCalled(), 45 | 'Make certain that you are providing a dependencies list in useEffect!', 46 | ) 47 | 48 | // verify error handling 49 | console.error.mockImplementation(() => {}) 50 | 51 | await userEvent.clear(input) 52 | await userEvent.type(input, 'george') 53 | await userEvent.click(submit) 54 | expect(await screen.findByRole('alert')).toHaveTextContent( 55 | /There was an error.*Unsupported pokemon.*george/, 56 | ) 57 | expect(console.error).toHaveBeenCalledTimes(3) 58 | 59 | console.error.mockReset() 60 | window.fetch.mockClear() 61 | 62 | // use the cached value 63 | await userEvent.click(screen.getByRole('button', {name: /ditto/i})) 64 | expect(window.fetch).not.toHaveBeenCalled() 65 | await screen.findByRole('heading', {name: /ditto/i}) 66 | }) 67 | -------------------------------------------------------------------------------- /src/__tests__/03.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import App from '../final/03' 5 | // import App from '../exercise/03' 6 | 7 | test('clicking the button increments the count', async () => { 8 | render() 9 | const button = screen.getByText(/increment count/i) 10 | const display = screen.getByText(/the current count/i) 11 | expect(display).toHaveTextContent(/0/) 12 | await userEvent.click(button) 13 | expect(display).toHaveTextContent(/1/) 14 | await userEvent.click(button) 15 | expect(display).toHaveTextContent(/2/) 16 | }) 17 | -------------------------------------------------------------------------------- /src/__tests__/04.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import App from '../final/04' 5 | // import App from '../exercise/04' 6 | 7 | test('adds and removes children from the log', async () => { 8 | const {getByText, getByRole} = render() 9 | const log = getByRole('log') 10 | const chatCount = log.children.length 11 | const add = getByText(/add/i) 12 | const remove = getByText(/remove/i) 13 | await userEvent.click(add) 14 | expect(log.children).toHaveLength(chatCount + 1) 15 | await userEvent.click(remove) 16 | expect(log.children).toHaveLength(chatCount) 17 | }) 18 | 19 | test('scrolls to the bottom', async () => { 20 | const {getByText, getByRole} = render() 21 | const log = getByRole('log') 22 | const add = getByText(/add/i) 23 | const remove = getByText(/remove/i) 24 | const scrollTopSetter = jest.fn() 25 | Object.defineProperties(log, { 26 | scrollHeight: { 27 | get() { 28 | return 100 29 | }, 30 | }, 31 | scrollTop: { 32 | get() { 33 | return 0 34 | }, 35 | set: scrollTopSetter, 36 | }, 37 | }) 38 | 39 | await userEvent.click(add) 40 | expect(scrollTopSetter).toHaveBeenCalledTimes(1) 41 | expect(scrollTopSetter).toHaveBeenCalledWith(log.scrollHeight) 42 | 43 | scrollTopSetter.mockClear() 44 | 45 | await userEvent.click(remove) 46 | expect(scrollTopSetter).toHaveBeenCalledTimes(1) 47 | expect(scrollTopSetter).toHaveBeenCalledWith(log.scrollHeight) 48 | }) 49 | -------------------------------------------------------------------------------- /src/__tests__/05.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import App from '../final/05' 5 | // import App from '../exercise/05' 6 | 7 | test('adds and removes children from the log', async () => { 8 | const {getByText, getByRole} = render() 9 | const log = getByRole('log') 10 | const chatCount = log.children.length 11 | const add = getByText(/add/i) 12 | const remove = getByText(/remove/i) 13 | await userEvent.click(add) 14 | expect(log.children).toHaveLength(chatCount + 1) 15 | await userEvent.click(remove) 16 | expect(log.children).toHaveLength(chatCount) 17 | }) 18 | 19 | test('scroll to top scrolls to the top', async () => { 20 | const {getByText, getByRole} = render() 21 | const log = getByRole('log') 22 | const scrollToTop = getByText(/scroll to top/i) 23 | const scrollToBottom = getByText(/scroll to bottom/i) 24 | const scrollTopSetter = jest.fn() 25 | Object.defineProperties(log, { 26 | scrollHeight: { 27 | get() { 28 | return 100 29 | }, 30 | }, 31 | scrollTop: { 32 | get() { 33 | return 0 34 | }, 35 | set: scrollTopSetter, 36 | }, 37 | }) 38 | await userEvent.click(scrollToTop) 39 | expect(scrollTopSetter).toHaveBeenCalledTimes(1) 40 | expect(scrollTopSetter).toHaveBeenCalledWith(0) 41 | 42 | scrollTopSetter.mockClear() 43 | 44 | await userEvent.click(scrollToBottom) 45 | expect(scrollTopSetter).toHaveBeenCalledTimes(1) 46 | expect(scrollTopSetter).toHaveBeenCalledWith(log.scrollHeight) 47 | }) 48 | -------------------------------------------------------------------------------- /src/__tests__/06.extra-1.js: -------------------------------------------------------------------------------- 1 | import matchMediaPolyfill from 'mq-polyfill' 2 | import * as React from 'react' 3 | import {render, act} from '@testing-library/react' 4 | import App from '../final/06.extra-1' 5 | // import App from '../exercise/06' 6 | 7 | beforeAll(() => { 8 | matchMediaPolyfill(window) 9 | window.resizeTo = function resizeTo(width, height) { 10 | Object.assign(this, { 11 | innerWidth: width, 12 | innerHeight: height, 13 | outerWidth: width, 14 | outerHeight: height, 15 | }).dispatchEvent(new this.Event('resize')) 16 | } 17 | }) 18 | 19 | // sorry, I just couldn't find a reliable way to test your implementation 20 | // so this test just ensures you don't break anything 😅 21 | 22 | test('works', async () => { 23 | const {container} = render() 24 | 25 | const box = container.querySelector('[style]') 26 | 27 | act(() => { 28 | window.resizeTo(1001, 1001) 29 | }) 30 | expect(box).toHaveStyle(`background-color: green;`) 31 | 32 | act(() => { 33 | window.resizeTo(800, 800) 34 | }) 35 | expect(box).toHaveStyle(`background-color: yellow;`) 36 | 37 | act(() => { 38 | window.resizeTo(600, 600) 39 | }) 40 | expect(box).toHaveStyle(`background-color: red;`) 41 | }) 42 | -------------------------------------------------------------------------------- /src/__tests__/06.js: -------------------------------------------------------------------------------- 1 | import matchMediaPolyfill from 'mq-polyfill' 2 | import * as React from 'react' 3 | import {render, act} from '@testing-library/react' 4 | import App from '../final/06' 5 | // import App from '../exercise/06' 6 | 7 | beforeAll(() => { 8 | matchMediaPolyfill(window) 9 | window.resizeTo = function resizeTo(width, height) { 10 | Object.assign(this, { 11 | innerWidth: width, 12 | innerHeight: height, 13 | outerWidth: width, 14 | outerHeight: height, 15 | }).dispatchEvent(new this.Event('resize')) 16 | } 17 | }) 18 | 19 | // sorry, I just couldn't find a reliable way to test your implementation 20 | // so this test just ensures you don't break anything 😅 21 | 22 | test('works', async () => { 23 | const {container} = render() 24 | 25 | const box = container.querySelector('[style]') 26 | 27 | act(() => { 28 | window.resizeTo(1001, 1001) 29 | }) 30 | expect(box).toHaveStyle(`background-color: green;`) 31 | 32 | act(() => { 33 | window.resizeTo(800, 800) 34 | }) 35 | expect(box).toHaveStyle(`background-color: yellow;`) 36 | 37 | act(() => { 38 | window.resizeTo(600, 600) 39 | }) 40 | expect(box).toHaveStyle(`background-color: red;`) 41 | }) 42 | -------------------------------------------------------------------------------- /src/backend.js: -------------------------------------------------------------------------------- 1 | import {graphql} from '@kentcdodds/react-workshop-app/server' 2 | 3 | const pokemonApi = graphql.link('https://graphql-pokemon2.vercel.app') 4 | 5 | export const handlers = [ 6 | pokemonApi.query('PokemonInfo', (req, res, ctx) => { 7 | const pokemon = allPokemon[req.variables.name.toLowerCase()] 8 | if (pokemon) { 9 | return res(ctx.status(200), ctx.data({pokemon})) 10 | } else { 11 | const pokemonNames = Object.keys(allPokemon) 12 | const randomName = 13 | pokemonNames[Math.floor(pokemonNames.length * Math.random())] 14 | return res( 15 | ctx.status(404), 16 | ctx.data({ 17 | errors: [ 18 | { 19 | message: `Unsupported pokemon: "${req.variables.name}". Try "${randomName}"`, 20 | }, 21 | ], 22 | }), 23 | ) 24 | } 25 | }), 26 | ] 27 | 28 | const allPokemon = { 29 | pikachu: { 30 | id: 'UG9rZW1vbjowMjU=', 31 | number: '025', 32 | name: 'Pikachu', 33 | image: '/img/pokemon/pikachu.jpg', 34 | attacks: { 35 | special: [ 36 | { 37 | name: 'Discharge', 38 | type: 'Electric', 39 | damage: 35, 40 | }, 41 | { 42 | name: 'Thunder', 43 | type: 'Electric', 44 | damage: 100, 45 | }, 46 | { 47 | name: 'Thunderbolt', 48 | type: 'Electric', 49 | damage: 55, 50 | }, 51 | ], 52 | }, 53 | }, 54 | mew: { 55 | id: 'UG9rZW1vbjoxNTE=', 56 | number: '151', 57 | image: '/img/pokemon/mew.jpg', 58 | name: 'Mew', 59 | attacks: { 60 | special: [ 61 | { 62 | name: 'Dragon Pulse', 63 | type: 'Dragon', 64 | damage: 65, 65 | }, 66 | { 67 | name: 'Earthquake', 68 | type: 'Ground', 69 | damage: 100, 70 | }, 71 | { 72 | name: 'Fire Blast', 73 | type: 'Fire', 74 | damage: 100, 75 | }, 76 | { 77 | name: 'Hurricane', 78 | type: 'Flying', 79 | damage: 80, 80 | }, 81 | { 82 | name: 'Hyper Beam', 83 | type: 'Normal', 84 | damage: 120, 85 | }, 86 | { 87 | name: 'Moonblast', 88 | type: 'Fairy', 89 | damage: 85, 90 | }, 91 | { 92 | name: 'Psychic', 93 | type: 'Psychic', 94 | damage: 55, 95 | }, 96 | { 97 | name: 'Solar Beam', 98 | type: 'Grass', 99 | damage: 120, 100 | }, 101 | { 102 | name: 'Thunder', 103 | type: 'Electric', 104 | damage: 100, 105 | }, 106 | ], 107 | }, 108 | }, 109 | mewtwo: { 110 | id: 'UG9rZW1vbjoxNTA=', 111 | number: '150', 112 | image: '/img/pokemon/mewtwo.jpg', 113 | name: 'Mewtwo', 114 | attacks: { 115 | special: [ 116 | { 117 | name: 'Hyper Beam', 118 | type: 'Normal', 119 | damage: 120, 120 | }, 121 | { 122 | name: 'Psychic', 123 | type: 'Psychic', 124 | damage: 55, 125 | }, 126 | { 127 | name: 'Shadow Ball', 128 | type: 'Ghost', 129 | damage: 45, 130 | }, 131 | ], 132 | }, 133 | }, 134 | ditto: { 135 | id: 'UG9rZW1vbjoxMzI=', 136 | number: '132', 137 | image: '/img/pokemon/ditto.jpg', 138 | name: 'Ditto', 139 | attacks: { 140 | special: [ 141 | { 142 | name: 'Struggle', 143 | type: 'Normal', 144 | damage: 15, 145 | }, 146 | ], 147 | }, 148 | }, 149 | charizard: { 150 | id: 'UG9rZW1vbjowMDY=', 151 | number: '006', 152 | name: 'Charizard', 153 | image: '/img/pokemon/charizard.jpg', 154 | attacks: { 155 | special: [ 156 | { 157 | name: 'Dragon Claw', 158 | type: 'Dragon', 159 | damage: 35, 160 | }, 161 | { 162 | name: 'Fire Blast', 163 | type: 'Fire', 164 | damage: 100, 165 | }, 166 | { 167 | name: 'Flamethrower', 168 | type: 'Fire', 169 | damage: 55, 170 | }, 171 | ], 172 | }, 173 | }, 174 | bulbasaur: { 175 | id: 'UG9rZW1vbjowMDE=', 176 | number: '001', 177 | name: 'Bulbasaur', 178 | image: '/img/pokemon/bulbasaur.jpg', 179 | attacks: { 180 | special: [ 181 | { 182 | name: 'Power Whip', 183 | type: 'Grass', 184 | damage: 70, 185 | }, 186 | { 187 | name: 'Seed Bomb', 188 | type: 'Grass', 189 | damage: 40, 190 | }, 191 | { 192 | name: 'Sludge Bomb', 193 | type: 'Poison', 194 | damage: 55, 195 | }, 196 | ], 197 | }, 198 | }, 199 | } 200 | -------------------------------------------------------------------------------- /src/exercise/01.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // http://localhost:3000/isolated/exercise/01.js 3 | 4 | import * as React from 'react' 5 | 6 | function Counter({initialCount = 0, step = 1}) { 7 | // 🐨 replace React.useState with React.useReducer. 8 | // 💰 React.useReducer(countReducer, initialCount) 9 | const [count, setCount] = React.useState(initialCount) 10 | 11 | // 💰 you can write the countReducer function so you don't have to make any 12 | // changes to the next two lines of code! Remember: 13 | // The 1st argument is called "state" - the current value of count 14 | // The 2nd argument is called "newState" - the value passed to setCount 15 | const increment = () => setCount(count + step) 16 | return 17 | } 18 | 19 | function App() { 20 | return 21 | } 22 | 23 | export default App 24 | -------------------------------------------------------------------------------- /src/exercise/01.md: -------------------------------------------------------------------------------- 1 | # useReducer: simple Counter 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/01.md` 6 | 7 | ## Background 8 | 9 | React's `useState` hook can get you a really long way with React state 10 | management. That said, sometimes you want to separate the state logic from the 11 | components that make the state changes. In addition, if you have multiple 12 | elements of state that typically change together, then having an object that 13 | contains those elements of state can be quite helpful. 14 | 15 | This is where `useReducer` comes in really handy. If you're familiar with redux, 16 | then you'll feel pretty comfortable here. If not, then you have less to unlearn 17 | 😉 18 | 19 | This exercise will take you pretty deep into `useReducer`. Typically, you'll use 20 | `useReducer` with an object of state, but we're going to start by managing a 21 | single number (a `count`). We're doing this to ease you into `useReducer` and 22 | help you learn the difference between the convention and the actual API. 23 | 24 | Here's an example of using `useReducer` to manage the value of a name in an 25 | input. 26 | 27 | ```javascript 28 | function nameReducer(previousName, newName) { 29 | return newName 30 | } 31 | 32 | const initialNameValue = 'Joe' 33 | 34 | function NameInput() { 35 | const [name, setName] = React.useReducer(nameReducer, initialNameValue) 36 | const handleChange = event => setName(event.target.value) 37 | return ( 38 | <> 39 | 42 |
You typed: {name}
43 | 44 | ) 45 | } 46 | ``` 47 | 48 | One important thing to note here is that the reducer (called `nameReducer` 49 | above) is called with two arguments: 50 | 51 | 1. the current state 52 | 2. whatever it is that the dispatch function (called `setName` above) is called 53 | with. This is often called an "action." 54 | 55 | ## Exercise 56 | 57 | Production deploys: 58 | 59 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/01.js) 60 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/01.js) 61 | 62 | We're going to start off as simple as possible with a `` component. 63 | `useReducer` is absolutely overkill for a counter component like ours, but for 64 | now, just focus on making things work with `useReducer`. 65 | 66 | 📜 Here are two really helpful blog posts comparing `useState` and `useReducer`: 67 | 68 | - [Should I useState or useReducer?](https://kentcdodds.com/blog/should-i-usestate-or-usereducer) 69 | - [How to implement useState with useReducer](https://kentcdodds.com/blog/how-to-implement-usestate-with-usereducer) 70 | 71 | ## Extra Credit 72 | 73 | ### 1. 💯 accept the step as the action 74 | 75 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-1.js) 76 | 77 | I want to change things a bit to have this API: 78 | 79 | ```javascript 80 | const [count, changeCount] = React.useReducer(countReducer, initialCount) 81 | const increment = () => changeCount(step) 82 | ``` 83 | 84 | How would you need to change your reducer to make this work? 85 | 86 | This one is just to show that you can pass anything as the action. 87 | 88 | ### 2. 💯 simulate setState with an object 89 | 90 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-2.js) 91 | 92 | Remember `this.setState` from class components? If not, lucky you 😉. Either 93 | way, let's see if you can figure out how to make the state updater (`dispatch` 94 | function) behave in a similar way by changing our `state` to an object 95 | (`{count: 0}`) and then calling the state updater with an object which merges 96 | with the current state. 97 | 98 | So here's how I want things to look now: 99 | 100 | ```javascript 101 | const [state, setState] = React.useReducer(countReducer, { 102 | count: initialCount, 103 | }) 104 | const {count} = state 105 | const increment = () => setState({count: count + step}) 106 | ``` 107 | 108 | How would you need to change the reducer to make this work? 109 | 110 | ### 3. 💯 simulate setState with an object OR function 111 | 112 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-3.js) 113 | 114 | `this.setState` from class components can also accept a function. So let's add 115 | support for that with our simulated `setState` function. See if you can figure 116 | out how to make your reducer support both the object as in the last extra credit 117 | as well as a function callback: 118 | 119 | ```javascript 120 | const [state, setState] = React.useReducer(countReducer, { 121 | count: initialCount, 122 | }) 123 | const {count} = state 124 | const increment = () => 125 | setState(currentState => ({count: currentState.count + step})) 126 | ``` 127 | 128 | ### 4. 💯 traditional dispatch object with a type and switch statement 129 | 130 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-4.js) 131 | 132 | Ok, now we can finally see what most people do conventionally (mostly thanks to 133 | redux). Update your reducer so I can do this: 134 | 135 | ```javascript 136 | const [state, dispatch] = React.useReducer(countReducer, { 137 | count: initialCount, 138 | }) 139 | const {count} = state 140 | const increment = () => dispatch({type: 'INCREMENT', step}) 141 | ``` 142 | 143 | ## 🦉 Other notes 144 | 145 | ### lazy initialization 146 | 147 | This one's not an extra credit, but _sometimes_ lazy initialization can be 148 | useful, so here's how we'd do that with our original hook App: 149 | 150 | ```javascript 151 | function init(initialStateFromProps) { 152 | return { 153 | pokemon: null, 154 | loading: false, 155 | error: null, 156 | } 157 | } 158 | 159 | // ... 160 | 161 | const [state, dispatch] = React.useReducer(reducer, props.initialState, init) 162 | ``` 163 | 164 | So, if you pass a third function argument to `useReducer`, it passes the second 165 | argument to that function and uses the return value for the initial state. 166 | 167 | This could be useful if our `init` function read into localStorage or something 168 | else that we wouldn't want happening every re-render. 169 | 170 | ### The full `useReducer` API 171 | 172 | If you're into TypeScript, here's some type definitions for `useReducer`: 173 | 174 | > Thanks to [Trey's blog post](https://levelup.gitconnected.com/db1858d1fb9c) 175 | 176 | > Please don't spend too much time reading through this by the way! 177 | 178 | ```typescript 179 | type Dispatch = (value: A) => void 180 | type Reducer = (prevState: S, action: A) => S 181 | type ReducerState> = R extends Reducer 182 | ? S 183 | : never 184 | type ReducerAction> = R extends Reducer< 185 | any, 186 | infer A 187 | > 188 | ? A 189 | : never 190 | 191 | function useReducer, I>( 192 | reducer: R, 193 | initializerArg: I & ReducerState, 194 | initializer: (arg: I & ReducerState) => ReducerState, 195 | ): [ReducerState, Dispatch>] 196 | 197 | function useReducer, I>( 198 | reducer: R, 199 | initializerArg: I, 200 | initializer: (arg: I) => ReducerState, 201 | ): [ReducerState, Dispatch>] 202 | 203 | function useReducer>( 204 | reducer: R, 205 | initialState: ReducerState, 206 | initializer?: undefined, 207 | ): [ReducerState, Dispatch>] 208 | ``` 209 | 210 | `useReducer` is pretty versatile. The key takeaway here is that while 211 | conventions are useful, understanding the API and its capabilities is more 212 | important. 213 | 214 | ## 🦉 Feedback 215 | 216 | Fill out 217 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=01%3A%20useReducer%3A%20simple%20Counter&em=). 218 | -------------------------------------------------------------------------------- /src/exercise/02.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // http://localhost:3000/isolated/exercise/02.js 3 | 4 | import * as React from 'react' 5 | import { 6 | fetchPokemon, 7 | PokemonForm, 8 | PokemonDataView, 9 | PokemonInfoFallback, 10 | PokemonErrorBoundary, 11 | } from '../pokemon' 12 | 13 | // 🐨 this is going to be our generic asyncReducer 14 | function pokemonInfoReducer(state, action) { 15 | switch (action.type) { 16 | case 'pending': { 17 | // 🐨 replace "pokemon" with "data" 18 | return {status: 'pending', pokemon: null, error: null} 19 | } 20 | case 'resolved': { 21 | // 🐨 replace "pokemon" with "data" (in the action too!) 22 | return {status: 'resolved', pokemon: action.pokemon, error: null} 23 | } 24 | case 'rejected': { 25 | // 🐨 replace "pokemon" with "data" 26 | return {status: 'rejected', pokemon: null, error: action.error} 27 | } 28 | default: { 29 | throw new Error(`Unhandled action type: ${action.type}`) 30 | } 31 | } 32 | } 33 | 34 | function PokemonInfo({pokemonName}) { 35 | // 🐨 move all the code between the lines into a new useAsync function. 36 | // 💰 look below to see how the useAsync hook is supposed to be called 37 | // 💰 If you want some help, here's the function signature (or delete this 38 | // comment really quick if you don't want the spoiler)! 39 | // function useAsync(asyncCallback, initialState, dependencies) {/* code in here */} 40 | 41 | // -------------------------- start -------------------------- 42 | 43 | const [state, dispatch] = React.useReducer(pokemonInfoReducer, { 44 | status: pokemonName ? 'pending' : 'idle', 45 | // 🐨 this will need to be "data" instead of "pokemon" 46 | pokemon: null, 47 | error: null, 48 | }) 49 | 50 | React.useEffect(() => { 51 | // 💰 this first early-exit bit is a little tricky, so let me give you a hint: 52 | // const promise = asyncCallback() 53 | // if (!promise) { 54 | // return 55 | // } 56 | // then you can dispatch and handle the promise etc... 57 | if (!pokemonName) { 58 | return 59 | } 60 | dispatch({type: 'pending'}) 61 | fetchPokemon(pokemonName).then( 62 | pokemon => { 63 | dispatch({type: 'resolved', pokemon}) 64 | }, 65 | error => { 66 | dispatch({type: 'rejected', error}) 67 | }, 68 | ) 69 | // 🐨 you'll accept dependencies as an array and pass that here. 70 | // 🐨 because of limitations with ESLint, you'll need to ignore 71 | // the react-hooks/exhaustive-deps rule. We'll fix this in an extra credit. 72 | }, [pokemonName]) 73 | // --------------------------- end --------------------------- 74 | 75 | // 🐨 here's how you'll use the new useAsync hook you're writing: 76 | // const state = useAsync(() => { 77 | // if (!pokemonName) { 78 | // return 79 | // } 80 | // return fetchPokemon(pokemonName) 81 | // }, {/* initial state */}, [pokemonName]) 82 | // 🐨 this will change from "pokemon" to "data" 83 | const {pokemon, status, error} = state 84 | 85 | switch (status) { 86 | case 'idle': 87 | return Submit a pokemon 88 | case 'pending': 89 | return 90 | case 'rejected': 91 | throw error 92 | case 'resolved': 93 | return 94 | default: 95 | throw new Error('This should be impossible') 96 | } 97 | } 98 | 99 | function App() { 100 | const [pokemonName, setPokemonName] = React.useState('') 101 | 102 | function handleSubmit(newPokemonName) { 103 | setPokemonName(newPokemonName) 104 | } 105 | 106 | function handleReset() { 107 | setPokemonName('') 108 | } 109 | 110 | return ( 111 |
112 | 113 |
114 |
115 | 116 | 117 | 118 |
119 |
120 | ) 121 | } 122 | 123 | function AppWithUnmountCheckbox() { 124 | const [mountApp, setMountApp] = React.useState(true) 125 | return ( 126 |
127 | 135 |
136 | {mountApp ? : null} 137 |
138 | ) 139 | } 140 | 141 | export default AppWithUnmountCheckbox 142 | -------------------------------------------------------------------------------- /src/exercise/02.md: -------------------------------------------------------------------------------- 1 | # useCallback: custom hooks 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/02.md` 6 | 7 | ## Background 8 | 9 | ### Memoization in general 10 | 11 | Memoization: a performance optimization technique which eliminates the need to 12 | recompute a value for a given input by storing the original computation and 13 | returning that stored value when the same input is provided. Memoization is a 14 | form of caching. Here's a simple implementation of memoization: 15 | 16 | ```typescript 17 | const values = {} 18 | function addOne(num: number) { 19 | if (values[num] === undefined) { 20 | values[num] = num + 1 // <-- here's the computation 21 | } 22 | return values[num] 23 | } 24 | ``` 25 | 26 | One other aspect of memoization is value referential equality. For example: 27 | 28 | ```typescript 29 | const dog1 = new Dog('sam') 30 | const dog2 = new Dog('sam') 31 | console.log(dog1 === dog2) // false 32 | ``` 33 | 34 | Even though those two dogs have the same name, they are not the same. However, 35 | we can use memoization to get the same dog: 36 | 37 | ```typescript 38 | const dogs = {} 39 | function getDog(name: string) { 40 | if (dogs[name] === undefined) { 41 | dogs[name] = new Dog(name) 42 | } 43 | return dogs[name] 44 | } 45 | 46 | const dog1 = getDog('sam') 47 | const dog2 = getDog('sam') 48 | console.log(dog1 === dog2) // true 49 | ``` 50 | 51 | You might have noticed that our memoization examples look very similar. 52 | Memoization is something you can implement as a generic abstraction: 53 | 54 | ```typescript 55 | function memoize(cb: (arg: ArgType) => ReturnValue) { 56 | const cache: Record = {} 57 | return function memoized(arg: ArgType) { 58 | if (cache[arg] === undefined) { 59 | cache[arg] = cb(arg) 60 | } 61 | return cache[arg] 62 | } 63 | } 64 | 65 | const addOne = memoize((num: number) => num + 1) 66 | const getDog = memoize((name: string) => new Dog(name)) 67 | ``` 68 | 69 | Our abstraction only supports one argument, if you want to make it work for any 70 | type/number of arguments, knock yourself out. 71 | 72 | ### Memoization in React 73 | 74 | Luckily, in React we don't have to implement a memoization abstraction. They 75 | made two for us! `useMemo` and `useCallback`. For more on this read: 76 | [Memoization and React](https://epicreact.dev/memoization-and-react). 77 | 78 | You know the dependency list of `useEffect`? Here's a quick refresher: 79 | 80 | ```javascript 81 | React.useEffect(() => { 82 | window.localStorage.setItem('count', count) 83 | }, [count]) // <-- that's the dependency list 84 | ``` 85 | 86 | Remember that the dependency list is how React knows whether to call your 87 | callback (and if you don't provide one then React will call your callback every 88 | render). It does this to ensure that the side effect you're performing in the 89 | callback doesn't get out of sync with the state of the application. 90 | 91 | But what happens if I use a function in my callback? 92 | 93 | ```javascript 94 | const updateLocalStorage = () => window.localStorage.setItem('count', count) 95 | React.useEffect(() => { 96 | updateLocalStorage() 97 | }, []) // <-- what goes in that dependency list? 98 | ``` 99 | 100 | We could just put the `count` in the dependency list and that would 101 | actually/accidentally work, but what would happen if one day someone were to 102 | change `updateLocalStorage`? 103 | 104 | ```diff 105 | - const updateLocalStorage = () => window.localStorage.setItem('count', count) 106 | + const updateLocalStorage = () => window.localStorage.setItem(key, count) 107 | ``` 108 | 109 | Would we remember to update the dependency list to include the `key`? Hopefully 110 | we would. But this can be a pain to keep track of dependencies. Especially if 111 | the function that we're using in our `useEffect` callback is coming to us from 112 | props (in the case of a custom component) or arguments (in the case of a custom 113 | hook). 114 | 115 | Instead, it would be much easier if we could just put the function itself in the 116 | dependency list: 117 | 118 | ```javascript 119 | const updateLocalStorage = () => window.localStorage.setItem('count', count) 120 | React.useEffect(() => { 121 | updateLocalStorage() 122 | }, [updateLocalStorage]) // <-- function as a dependency 123 | ``` 124 | 125 | The problem with doing that is that it will trigger the `useEffect` to run every 126 | render. This is because `updateLocalStorage` is defined inside the component 127 | function body. So it's re-initialized every render. Which means it's brand new 128 | every render. Which means it changes every render. Which means... you guessed 129 | it, our `useEffect` callback will be called every render! 130 | 131 | **This is the problem `useCallback` solves**. And here's how you solve it 132 | 133 | ```javascript 134 | const updateLocalStorage = React.useCallback( 135 | () => window.localStorage.setItem('count', count), 136 | [count], // <-- yup! That's a dependency list! 137 | ) 138 | React.useEffect(() => { 139 | updateLocalStorage() 140 | }, [updateLocalStorage]) 141 | ``` 142 | 143 | What that does is we pass React a function and React gives that same function 144 | back to us... Sounds kinda useless right? Imagine: 145 | 146 | ```javascript 147 | // this is not how React actually implements this function. We're just imagining! 148 | function useCallback(callback) { 149 | return callback 150 | } 151 | ``` 152 | 153 | Uhhh... But there's a catch! On subsequent renders, if the elements in the 154 | dependency list are unchanged, instead of giving the same function back that we 155 | give to it, React will give us the same function it gave us last time. So 156 | imagine: 157 | 158 | ```javascript 159 | // this is not how React actually implements this function. We're just imagining! 160 | let lastCallback 161 | function useCallback(callback, deps) { 162 | if (depsChanged(deps)) { 163 | lastCallback = callback 164 | return callback 165 | } else { 166 | return lastCallback 167 | } 168 | } 169 | ``` 170 | 171 | So while we still create a new function every render (to pass to `useCallback`), 172 | React only gives us the new one if the dependency list changes. 173 | 174 | In this exercise, we're going to be using `useCallback`, but `useCallback` is 175 | just a shortcut to using `useMemo` for functions: 176 | 177 | ```typescript 178 | // the useMemo version: 179 | const updateLocalStorage = React.useMemo( 180 | // useCallback saves us from this annoying double-arrow function thing: 181 | () => () => window.localStorage.setItem('count', count), 182 | [count], 183 | ) 184 | 185 | // the useCallback version 186 | const updateLocalStorage = React.useCallback( 187 | () => window.localStorage.setItem('count', count), 188 | [count], 189 | ) 190 | ``` 191 | 192 | 🦉 A common question with this is: "Why don't we just wrap every function in 193 | `useCallback`?" You can read about this in my blog post 194 | [When to useMemo and useCallback](https://kentcdodds.com/blog/usememo-and-usecallback). 195 | 196 | 🦉 And if the concept of a "closure" is new or confusing to you, then 197 | [give this a read](https://mdn.io/closure). (Closures are one of the reasons 198 | it's important to keep dependency lists correct.) 199 | 200 | ## Exercise 201 | 202 | Production deploys: 203 | 204 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/02.js) 205 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/02.js) 206 | 207 | **People tend to find this exercise more difficult,** so I strongly advise 208 | spending some time understanding how the code works before making any changes! 209 | 210 | Also, one thing to keep in mind is that React hooks are a great foundation upon 211 | which to build libraries and many have been built. For that reason, you don't 212 | often need to go this deep into making custom hooks. So if you find this one 213 | isn't clicking for you, know that you _are_ learning and when you _do_ face a 214 | situation when you need to use this knowledge, you'll be able to come back and 215 | it will click right into place. 216 | 217 | 👨‍💼 Peter the Product Manager told us that we've got more features coming our way 218 | that will require managing async state. We've already got some code for our 219 | pokemon lookup feature (if you've gone through the "React Hooks" workshop 220 | already, then this should be familiar, if not, spend some time playing with the 221 | app to get up to speed with what we're dealing with here). We're going to 222 | refactor out the async logic so we can reuse this in other areas of the app. 223 | 224 | **So, your job is** to extract the logic from the `PokemonInfo` component into a 225 | custom and generic `useAsync` hook. In the process you'll find you need to do 226 | some fancy things with dependencies (dependency arrays are the biggest challenge 227 | to deal with when making custom hooks). 228 | 229 | NOTE: In this part of the exercise, we don't need `useCallback`. We'll add it in 230 | the extra credits. It's important that you work on this refactor first so you 231 | can appreciate the value `useCallback` provides in certain circumstances. 232 | 233 | ## Extra Credit 234 | 235 | ### 1. 💯 use useCallback to empower the user to customize memoization 236 | 237 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/02.extra-1.js) 238 | 239 | Unfortunately, the ESLint plugin is unable to determine whether the 240 | `dependencies` argument is a valid argument for `useEffect` which is a shame, 241 | and normally I'd say just ignore it and move on. But, there's another solution 242 | to this problem which I think is probably better. 243 | 244 | Instead of accepting `dependencies` to `useAsync`, why don't we just treat the 245 | `asyncCallback` as a dependency? Any time `asyncCallback` changes, we know that 246 | we should call it again. The problem is that because our `asyncCallback` depends 247 | on the `pokemonName` which comes from props, it has to be defined within the 248 | body of the component, which means that it will be defined on every render which 249 | means it will be new every render. This is where `React.useCallback` comes in! 250 | 251 | Here's another example of the `React.useCallback` API: 252 | 253 | ```javascript 254 | function ConsoleGreeting(props) { 255 | const greet = React.useCallback( 256 | greeting => console.log(`${greeting} ${props.name}`), 257 | [props.name], 258 | ) 259 | 260 | React.useEffect(() => { 261 | const helloGreeting = 'Hello' 262 | greet(helloGreeting) 263 | }, [greet]) 264 | return
check the console
265 | } 266 | ``` 267 | 268 | The first argument to `useCallback` is the callback you want called, the second 269 | argument is an array of dependencies which is similar to `useEffect`. When one 270 | of the dependencies changes between renders, the callback you passed in the 271 | first argument will be the one returned from `useCallback`. If they do not 272 | change, then you'll get the callback which was returned the previous time (so 273 | the callback remains the same between renders). 274 | 275 | So we only want our `asyncCallback` to change when the `pokemonName` changes. 276 | See if you can make things work like this: 277 | 278 | ```javascript 279 | // 🐨 you'll need to wrap asyncCallback in React.useCallback 280 | function asyncCallback() { 281 | if (!pokemonName) { 282 | return 283 | } 284 | return fetchPokemon(pokemonName) 285 | } 286 | 287 | // 🐨 you'll need to update useAsync to remove the dependencies and list the 288 | // async callback as a dependency. 289 | const state = useAsync(asyncCallback) 290 | ``` 291 | 292 | ### 2. 💯 return a memoized `run` function from useAsync 293 | 294 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/02.extra-2.js) 295 | 296 | Requiring users to provide a memoized value is fine. You can document it as part 297 | of the API and expect people to just read the docs right? lol, that's hilarious 298 | 😂 It'd be WAY better if we could redesign the API a bit so we (as the hook 299 | developers) are the ones who have to memoize the function, and the users of our 300 | hook don't have to worry about it. 301 | 302 | So see if you can redesign this a little bit by providing a (memoized) `run` 303 | function that people can call in their own `useEffect` like this: 304 | 305 | ```javascript 306 | // 💰 destructuring this here now because it just felt weird to call this 307 | // "state" still when it's also returning a function called "run" 🙃 308 | const { 309 | data: pokemon, 310 | status, 311 | error, 312 | run, 313 | } = useAsync({status: pokemonName ? 'pending' : 'idle'}) 314 | 315 | React.useEffect(() => { 316 | if (!pokemonName) { 317 | return 318 | } 319 | // 💰 note the absence of `await` here. We're literally passing the promise 320 | // to `run` so `useAsync` can attach its own `.then` handler on it to keep 321 | // track of the state of the promise. 322 | const pokemonPromise = fetchPokemon(pokemonName) 323 | run(pokemonPromise) 324 | }, [pokemonName, run]) 325 | ``` 326 | 327 | ### 3. 💯 make safeDispatch with useCallback, useRef, and useEffect 328 | 329 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/02.extra-3.js) 330 | 331 | **NOTICE: Things have changed.** React 18 has since [deprecated 332 | the warning](https://github.com/facebook/react/pull/22114) highlighted in this 333 | exercise. This extra credit has been left here in-case you are **curious only**. You 334 | **should not** expect to be able to reproduce the scenario as described in the exercise. 335 | 336 | Phew, ok, back to your extra credit! 337 | 338 | This one's a bit tricky, and I'm going to be intentionally vague here to give 339 | you a bit of a challenge, but consider the scenario where we fetch a pokemon, 340 | and before the request finishes, we change our mind and navigate to a different 341 | page (or uncheck the mount checkbox). In that case, the component would get 342 | removed from the page ("unmounted") and when the request finally does complete, 343 | it will call `dispatch`, but because the component has been removed from the 344 | page, we'll get this warning from React: 345 | 346 | ```text 347 | Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function. 348 | ``` 349 | 350 | The best solution to this problem would be to 351 | [cancel the request](https://developers.google.com/web/updates/2017/09/abortable-fetch), 352 | but even then, we'd have to handle the error and prevent the `dispatch` from 353 | being called for the rejected promise. 354 | 355 | So see whether you can work out a solution for preventing `dispatch` from being 356 | called if the component is unmounted. Depending on how you implement this, you 357 | might need `useRef`, `useCallback`, and `useEffect`. 358 | 359 | ## 🦉 Other notes 360 | 361 | ### `useEffect` and `useCallback` 362 | 363 | The use case for `useCallback` in the exercise is a perfect example of the types 364 | of problems `useCallback` is intended to solve. However the examples in these 365 | instructions are intentionally contrived. You can simplify things a great deal 366 | by _not_ extracting code from `useEffect` into functions that you then have to 367 | memoize with `useCallback`. Read more about this here: 368 | [Myths about useEffect](https://epicreact.dev/myths-about-useeffect). 369 | 370 | ### `useCallback` use cases 371 | 372 | The entire purpose of `useCallback` is to memoize a callback for use in 373 | dependency lists and props on memoized components (via `React.memo`, which you 374 | can learn more about from the performance workshop). The _only_ time it's useful 375 | to use `useCallback` is when the function you're memoizing is used in one of 376 | those two situations. 377 | 378 | ## 🦉 Feedback 379 | 380 | Fill out 381 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=02%3A%20useCallback%3A%20custom%20hooks&em=). 382 | -------------------------------------------------------------------------------- /src/exercise/03.extra-2.js: -------------------------------------------------------------------------------- 1 | // useContext: Caching response data in context 2 | // 💯 caching in a context provider (exercise) 3 | // http://localhost:3000/isolated/exercise/03.extra-2.js 4 | 5 | // you can edit this here and look at the isolated page or you can copy/paste 6 | // this in the regular exercise file. 7 | 8 | import * as React from 'react' 9 | import { 10 | fetchPokemon, 11 | PokemonForm, 12 | PokemonDataView, 13 | PokemonInfoFallback, 14 | PokemonErrorBoundary, 15 | } from '../pokemon' 16 | import {useAsync} from '../utils' 17 | 18 | // 🐨 Create a PokemonCacheContext 19 | 20 | // 🐨 create a PokemonCacheProvider function 21 | // 🐨 useReducer with pokemonCacheReducer in your PokemonCacheProvider 22 | // 💰 you can grab the one that's in PokemonInfo 23 | // 🐨 return your context provider with the value assigned to what you get back from useReducer 24 | // 💰 value={[cache, dispatch]} 25 | // 💰 make sure you forward the props.children! 26 | 27 | function pokemonCacheReducer(state, action) { 28 | switch (action.type) { 29 | case 'ADD_POKEMON': { 30 | return {...state, [action.pokemonName]: action.pokemonData} 31 | } 32 | default: { 33 | throw new Error(`Unhandled action type: ${action.type}`) 34 | } 35 | } 36 | } 37 | 38 | function PokemonInfo({pokemonName}) { 39 | // 💣 remove the useReducer here (or move it up to your PokemonCacheProvider) 40 | const [cache, dispatch] = React.useReducer(pokemonCacheReducer, {}) 41 | // 🐨 get the cache and dispatch from useContext with PokemonCacheContext 42 | 43 | const {data: pokemon, status, error, run, setData} = useAsync() 44 | 45 | React.useEffect(() => { 46 | if (!pokemonName) { 47 | return 48 | } else if (cache[pokemonName]) { 49 | setData(cache[pokemonName]) 50 | } else { 51 | run( 52 | fetchPokemon(pokemonName).then(pokemonData => { 53 | dispatch({type: 'ADD_POKEMON', pokemonName, pokemonData}) 54 | return pokemonData 55 | }), 56 | ) 57 | } 58 | }, [cache, pokemonName, run, setData]) 59 | 60 | if (status === 'idle') { 61 | return 'Submit a pokemon' 62 | } else if (status === 'pending') { 63 | return 64 | } else if (status === 'rejected') { 65 | throw error 66 | } else if (status === 'resolved') { 67 | return 68 | } 69 | } 70 | 71 | function PreviousPokemon({onSelect}) { 72 | // 🐨 get the cache from useContext with PokemonCacheContext 73 | const cache = {} 74 | return ( 75 |
76 | Previous Pokemon 77 |
    78 | {Object.keys(cache).map(pokemonName => ( 79 |
  • 80 | 86 |
  • 87 | ))} 88 |
89 |
90 | ) 91 | } 92 | 93 | function PokemonSection({onSelect, pokemonName}) { 94 | // 🐨 wrap this in the PokemonCacheProvider so the PreviousPokemon 95 | // and PokemonInfo components have access to that context. 96 | return ( 97 |
98 | 99 |
100 | onSelect('')} 102 | resetKeys={[pokemonName]} 103 | > 104 | 105 | 106 |
107 |
108 | ) 109 | } 110 | 111 | function App() { 112 | const [pokemonName, setPokemonName] = React.useState(null) 113 | 114 | function handleSubmit(newPokemonName) { 115 | setPokemonName(newPokemonName) 116 | } 117 | 118 | function handleSelect(newPokemonName) { 119 | setPokemonName(newPokemonName) 120 | } 121 | 122 | return ( 123 |
124 | 125 |
126 | 127 |
128 | ) 129 | } 130 | 131 | export default App 132 | -------------------------------------------------------------------------------- /src/exercise/03.js: -------------------------------------------------------------------------------- 1 | // useContext: simple Counter 2 | // http://localhost:3000/isolated/exercise/03.js 3 | 4 | import * as React from 'react' 5 | 6 | // 🐨 create your CountContext here with React.createContext 7 | 8 | // 🐨 create a CountProvider component here that does this: 9 | // 🐨 get the count state and setCount updater with React.useState 10 | // 🐨 create a `value` array with count and setCount 11 | // 🐨 return your context provider with the value assigned to that array and forward all the other props 12 | // 💰 more specifically, we need the children prop forwarded to the context provider 13 | 14 | function CountDisplay() { 15 | // 🐨 get the count from useContext with the CountContext 16 | const count = 0 17 | return
{`The current count is ${count}`}
18 | } 19 | 20 | function Counter() { 21 | // 🐨 get the setCount from useContext with the CountContext 22 | const setCount = () => {} 23 | const increment = () => setCount(c => c + 1) 24 | return 25 | } 26 | 27 | function App() { 28 | return ( 29 |
30 | {/* 31 | 🐨 wrap these two components in the CountProvider so they can access 32 | the CountContext value 33 | */} 34 | 35 | 36 |
37 | ) 38 | } 39 | 40 | export default App 41 | -------------------------------------------------------------------------------- /src/exercise/03.md: -------------------------------------------------------------------------------- 1 | # useContext: simple Counter 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/03.md` 6 | 7 | ## Background 8 | 9 | Sharing state between components is a common problem. The best solution for this 10 | is to 📜 [lift your state](https://react.dev/learn/sharing-state-between-components). This 11 | requires 📜 [prop drilling](https://kentcdodds.com/blog/prop-drilling) which is 12 | not a problem, but there are some times where prop drilling can cause a real 13 | pain. 14 | 15 | To avoid this pain, we can insert some state into a section of our react tree, 16 | and then extract that state anywhere within that react tree without having to 17 | explicitly pass it everywhere. This feature is called `context`. In some ways 18 | it's like global variables, but it doesn't suffer from the same problems (and 19 | maintainability nightmares) of global variables thanks to how the API works to 20 | make the relationships explicit. 21 | 22 | Here's how you use context: 23 | 24 | ```javascript 25 | import * as React from 'react' 26 | 27 | const FooContext = React.createContext() 28 | 29 | function FooDisplay() { 30 | const foo = React.useContext(FooContext) 31 | return
Foo is: {foo}
32 | } 33 | 34 | ReactDOM.render( 35 | 36 | 37 | , 38 | document.getElementById('root'), 39 | ) 40 | // renders
Foo is: I am foo
41 | ``` 42 | 43 | `` could appear anywhere in the render tree, and it will have 44 | access to the `value` which is passed by the `FooContext.Provider` component. 45 | 46 | Note that as a first argument to `createContext`, you can provide a default 47 | value which React will use in the event someone calls `useContext` with your 48 | context, when no value has been provided: 49 | 50 | ```javascript 51 | ReactDOM.render(, document.getElementById('root')) 52 | ``` 53 | 54 | Most of the time, I don't recommend using a default value because it's probably 55 | a mistake to try and use context outside a provider, so in our exercise I'll 56 | show you how to avoid that from happening. 57 | 58 | 🦉 Keep in mind that while context makes sharing state easy, it's not the only 59 | solution to Prop Drilling pains and it's not necessarily the best solution 60 | either. React's composition model is powerful and can be used to avoid issues 61 | with prop drilling as well. Learn more about this from 62 | [Michael Jackson on X](https://x.com/mjackson/status/1195495535483817984) 63 | 64 | ## Exercise 65 | 66 | Production deploys: 67 | 68 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/03.js) 69 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/03.js) 70 | 71 | We're putting everything in one file to keep things simple, but I've labeled 72 | things a bit so you know that typically your context provider will be placed in 73 | a different file and expose the provider component itself as well as the custom 74 | hook to access the context value. 75 | 76 | We're going to take the Count component that we had before and separate the 77 | button from the count display. We need to access both the `count` state as well 78 | as the `setCount` updater in these different components which live in different 79 | parts of the tree. Normally lifting state up would be the way to solve this 80 | trivial problem, but this is a contrived example so you can focus on learning 81 | how to use context. 82 | 83 | Your job is to fill in the `CountProvider` function component so that the app 84 | works and the tests pass. 85 | 86 | ## Extra Credit 87 | 88 | ### 1. 💯 create a consumer hook 89 | 90 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/03.extra-1.js) 91 | 92 | Imagine what would happen if someone tried to consume your context value without 93 | using your context provider. For example, as mentioned above when discussing the 94 | default value: 95 | 96 | ```javascript 97 | ReactDOM.render(, document.getElementById('root')) 98 | ``` 99 | 100 | If you don't provide a default context value, that would render 101 | `
Foo is:
`. This is because the context value would be `undefined`. 102 | In real-world scenarios, having an unexpected `undefined` value can result in 103 | errors that can be difficult to debug. 104 | 105 | In this extra credit, you need to create a custom hook that I can use like this: 106 | 107 | ```javascript 108 | const [count, setCount] = useCount() 109 | ``` 110 | 111 | And if you change the `App` to this: 112 | 113 | ```javascript 114 | function App() { 115 | return ( 116 |
117 | 118 | 119 |
120 | ) 121 | } 122 | ``` 123 | 124 | It should throw an error indicating that `useCount` may only be used from within a (child of a) 125 | CountProvider. 126 | 127 | ### 2. 💯 caching in a context provider 128 | 129 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/03.extra-2.js) 130 | 131 | Let's try the last exercise over again with a bit more of a complex/practical 132 | example. That's right! We're back to the Pokemon info app! This time it has 133 | caching in place which is cool. So if you enter the same pokemon information, 134 | it's cached so it loads instantaneously. 135 | 136 | However, we have a requirement that we want to list all the cached pokemon in 137 | another part of the app, so we're going to use context to store the cache. This 138 | way both parts of the app which need access to the pokemon cache will have 139 | access. 140 | 141 | Because this is hard to describe in words (and because it's a completely 142 | separate example), there's a starting point for you in 143 | `./src/exercise/03.extra-2.js`. 144 | 145 | ## 🦉 Other notes 146 | 147 | `Context` also has the unique ability to be scoped to a specific section of the 148 | React component tree. A common mistake of context (and generally any 149 | "application" state) is to make it globally available anywhere in your 150 | application when it's actually only needed to be available in a part of the app 151 | (like a single page). Keeping a context value scoped to the area that needs it 152 | most has improved performance and maintainability characteristics. 153 | 154 | ## 🦉 Feedback 155 | 156 | Fill out 157 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=03%3A%20useContext%3A%20simple%20Counter&em=). 158 | -------------------------------------------------------------------------------- /src/exercise/04.js: -------------------------------------------------------------------------------- 1 | // useLayoutEffect: auto-scrolling textarea 2 | // http://localhost:3000/isolated/exercise/04.js 3 | 4 | import * as React from 'react' 5 | 6 | function MessagesDisplay({messages}) { 7 | const containerRef = React.useRef() 8 | // 🐨 replace useEffect with useLayoutEffect 9 | React.useEffect(() => { 10 | containerRef.current.scrollTop = containerRef.current.scrollHeight 11 | }) 12 | 13 | return ( 14 |
15 | {messages.map((message, index, array) => ( 16 |
17 | {message.author}: {message.content} 18 | {array.length - 1 === index ? null :
} 19 |
20 | ))} 21 |
22 | ) 23 | } 24 | 25 | // this is to simulate major computation/big rendering tree/etc. 26 | function sleep(time = 0) { 27 | const wakeUpTime = Date.now() + time 28 | while (Date.now() < wakeUpTime) {} 29 | } 30 | 31 | function SlooooowSibling() { 32 | // try this with useLayoutEffect as well to see 33 | // how it impacts interactivity of the page before updates. 34 | React.useEffect(() => { 35 | // increase this number to see a more stark difference 36 | sleep(300) 37 | }) 38 | return null 39 | } 40 | 41 | function App() { 42 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8)) 43 | const addMessage = () => 44 | messages.length < allMessages.length 45 | ? setMessages(allMessages.slice(0, messages.length + 1)) 46 | : null 47 | const removeMessage = () => 48 | messages.length > 0 49 | ? setMessages(allMessages.slice(0, messages.length - 1)) 50 | : null 51 | 52 | return ( 53 |
54 |
55 | 56 | 57 |
58 |
59 | 60 | 61 |
62 | ) 63 | } 64 | 65 | export default App 66 | 67 | const allMessages = [ 68 | `Leia: Aren't you a little short to be a stormtrooper?`, 69 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`, 70 | `Leia: You're who?`, 71 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`, 72 | `Leia: Ben Kenobi is here! Where is he?`, 73 | `Luke: Come on!`, 74 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`, 75 | `Leia: Put that thing away! You're going to get us all killed.`, 76 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`, 77 | `Leia: It could be worse...`, 78 | `Han: It's worse.`, 79 | `Luke: There's something alive in here!`, 80 | `Han: That's your imagination.`, 81 | `Luke: Something just moved past my leg! Look! Did you see that?`, 82 | `Han: What?`, 83 | `Luke: Help!`, 84 | `Han: Luke! Luke! Luke!`, 85 | `Leia: Luke!`, 86 | `Leia: Luke, Luke, grab a hold of this.`, 87 | `Luke: Blast it, will you! My gun's jammed.`, 88 | `Han: Where?`, 89 | `Luke: Anywhere! Oh!!`, 90 | `Han: Luke! Luke!`, 91 | `Leia: Grab him!`, 92 | `Leia: What happened?`, 93 | `Luke: I don't know, it just let go of me and disappeared...`, 94 | `Han: I've got a very bad feeling about this.`, 95 | `Luke: The walls are moving!`, 96 | `Leia: Don't just stand there. Try to brace it with something.`, 97 | `Luke: Wait a minute!`, 98 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`, 99 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]})) 100 | -------------------------------------------------------------------------------- /src/exercise/04.md: -------------------------------------------------------------------------------- 1 | # useLayoutEffect: auto-scrolling textarea 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/04.md` 6 | 7 | ## Background 8 | 9 | There are two ways to tell React to run side-effects after it renders: 10 | 11 | 1. `useEffect` 12 | 2. `useLayoutEffect` 13 | 14 | The difference about these is subtle (they have the exact same API), but 15 | significant. 99% of the time `useEffect` is what you want, but sometimes 16 | `useLayoutEffect` can improve your user experience. 17 | 18 | To learn about the difference, read 19 | [useEffect vs useLayoutEffect](https://kentcdodds.com/blog/useeffect-vs-uselayouteffect) 20 | 21 | And check out the [hook flow diagram](https://github.com/donavon/hook-flow) as 22 | well. 23 | 24 | ## Exercise 25 | 26 | Production deploys: 27 | 28 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/04.js) 29 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/04.js) 30 | 31 | NOTE: React 18 has smoothed out the differences in the UX between `useEffect` 32 | and `useLayoutEffect`. That said, the simple "rule" described still applies! 33 | 34 | There's no exercise for this one because basically you just need to replace 35 | `useEffect` with `useLayoutEffect` and you're good. So you pretty much just need 36 | to experiment with things a bit. 37 | 38 | Before you do that though, compare the finished example with the exercise. 39 | Add/remove messages and you'll find that there's a janky experience with the 40 | exercise version because we're using `useEffect` and there's a gap between the 41 | time that the DOM is visually updated and our code runs. 42 | 43 | Here's the simple rule for when you should use `useLayoutEffect`: If you are 44 | making observable changes to the DOM, then it should happen in 45 | `useLayoutEffect`, otherwise `useEffect`. 46 | 47 | ## 🦉 Feedback 48 | 49 | Fill out 50 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=04%3A%20useLayoutEffect%3A%20auto-scrolling%20textarea&em=). 51 | -------------------------------------------------------------------------------- /src/exercise/05.js: -------------------------------------------------------------------------------- 1 | // useImperativeHandle: scroll to top/bottom 2 | // http://localhost:3000/isolated/exercise/05.js 3 | 4 | import * as React from 'react' 5 | 6 | // 🐨 wrap this in a React.forwardRef and accept `ref` as the second argument 7 | function MessagesDisplay({messages}) { 8 | const containerRef = React.useRef() 9 | React.useLayoutEffect(() => { 10 | scrollToBottom() 11 | }) 12 | 13 | // 💰 you're gonna want this as part of your imperative methods 14 | // function scrollToTop() { 15 | // containerRef.current.scrollTop = 0 16 | // } 17 | function scrollToBottom() { 18 | containerRef.current.scrollTop = containerRef.current.scrollHeight 19 | } 20 | 21 | // 🐨 call useImperativeHandle here with your ref and a callback function 22 | // that returns an object with scrollToTop and scrollToBottom 23 | 24 | return ( 25 |
26 | {messages.map((message, index, array) => ( 27 |
28 | {message.author}: {message.content} 29 | {array.length - 1 === index ? null :
} 30 |
31 | ))} 32 |
33 | ) 34 | } 35 | 36 | function App() { 37 | const messageDisplayRef = React.useRef() 38 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8)) 39 | const addMessage = () => 40 | messages.length < allMessages.length 41 | ? setMessages(allMessages.slice(0, messages.length + 1)) 42 | : null 43 | const removeMessage = () => 44 | messages.length > 0 45 | ? setMessages(allMessages.slice(0, messages.length - 1)) 46 | : null 47 | 48 | const scrollToTop = () => messageDisplayRef.current.scrollToTop() 49 | const scrollToBottom = () => messageDisplayRef.current.scrollToBottom() 50 | 51 | return ( 52 |
53 |
54 | 55 | 56 |
57 |
58 |
59 | 60 |
61 | 62 |
63 | 64 |
65 |
66 | ) 67 | } 68 | 69 | export default App 70 | 71 | const allMessages = [ 72 | `Leia: Aren't you a little short to be a stormtrooper?`, 73 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`, 74 | `Leia: You're who?`, 75 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`, 76 | `Leia: Ben Kenobi is here! Where is he?`, 77 | `Luke: Come on!`, 78 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`, 79 | `Leia: Put that thing away! You're going to get us all killed.`, 80 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`, 81 | `Leia: It could be worse...`, 82 | `Han: It's worse.`, 83 | `Luke: There's something alive in here!`, 84 | `Han: That's your imagination.`, 85 | `Luke: Something just moves past my leg! Look! Did you see that?`, 86 | `Han: What?`, 87 | `Luke: Help!`, 88 | `Han: Luke! Luke! Luke!`, 89 | `Leia: Luke!`, 90 | `Leia: Luke, Luke, grab a hold of this.`, 91 | `Luke: Blast it, will you! My gun's jammed.`, 92 | `Han: Where?`, 93 | `Luke: Anywhere! Oh!!`, 94 | `Han: Luke! Luke!`, 95 | `Leia: Grab him!`, 96 | `Leia: What happened?`, 97 | `Luke: I don't know, it just let go of me and disappeared...`, 98 | `Han: I've got a very bad feeling about this.`, 99 | `Luke: The walls are moving!`, 100 | `Leia: Don't just stand there. Try to brace it with something.`, 101 | `Luke: Wait a minute!`, 102 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`, 103 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]})) 104 | -------------------------------------------------------------------------------- /src/exercise/05.md: -------------------------------------------------------------------------------- 1 | # useImperativeHandle: scroll to top/bottom 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/05.md` 6 | 7 | ## Background 8 | 9 | When we had class components, we could do stuff like this: 10 | 11 | ```javascript 12 | class MyInput extends React.Component { 13 | _inputRef = React.createRef() 14 | focusInput = () => this._inputRef.current.focus() 15 | render() { 16 | return 17 | } 18 | } 19 | 20 | class App extends React.Component { 21 | _myInputRef = React.createRef() 22 | handleClick = () => this._myInputRef.current.focusInput() 23 | render() { 24 | return ( 25 |
26 | 27 | 28 |
29 | ) 30 | } 31 | } 32 | ``` 33 | 34 | The key I want to point out in the example here is that bit above that says: 35 | ``. What this does is give you access to the 36 | component instance. 37 | 38 | With function components, there is no component instance, so this won't work: 39 | 40 | ```javascript 41 | function MyInput() { 42 | const inputRef = React.useRef() 43 | const focusInput = () => inputRef.current.focus() 44 | // where do I put the focusInput method?? 45 | return 46 | } 47 | ``` 48 | 49 | You'll actually get an error if you try to pass a `ref` prop to a function 50 | component. So how do we solve this? Well, React has had this feature called 51 | `forwardRef` for quite a while. So we could do that: 52 | 53 | ```javascript 54 | const MyInput = React.forwardRef(function MyInput(props, ref) { 55 | const inputRef = React.useRef() 56 | ref.current = { 57 | focusInput: () => inputRef.current.focus(), 58 | } 59 | return 60 | }) 61 | ``` 62 | 63 | This actually works, however there are some edge case bugs with this approach 64 | when applied in React's future concurrent mode/suspense feature (also it doesn't 65 | support callback refs). So instead, we'll use the `useImperativeHandle` hook to 66 | do this: 67 | 68 | ```javascript 69 | const MyInput = React.forwardRef(function MyInput(props, ref) { 70 | const inputRef = React.useRef() 71 | React.useImperativeHandle(ref, () => { 72 | return { 73 | focusInput: () => inputRef.current.focus(), 74 | } 75 | }) 76 | return 77 | }) 78 | ``` 79 | 80 | This allows us to expose imperative methods to developers who pass a ref prop to 81 | our component which can be useful when you have something that needs to happen 82 | and is hard to deal with declaratively. 83 | 84 | > NOTE: most of the time you should not need `useImperativeHandle`. Before you 85 | > reach for it, really ask yourself whether there's ANY other way to accomplish 86 | > what you're trying to do. Imperative code can sometimes be really hard to 87 | > follow and it's much better to make your APIs declarative if possible. For 88 | > more on this, read 89 | > [Imperative vs Declarative Programming](https://tylermcginnis.com/imperative-vs-declarative-programming/) 90 | 91 | ## Exercise 92 | 93 | Production deploys: 94 | 95 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/05.js) 96 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/05.js) 97 | 98 | For this exercise, we're going to use the simulated chat from the last exercise, 99 | except we've added scroll to top and scroll to bottom buttons. Your job is to 100 | expose the imperative methods `scrollToTop` and `scrollToBottom` on a ref so the 101 | parent component can call those directly. 102 | 103 | ## 🦉 Feedback 104 | 105 | Fill out 106 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=05%3A%20useImperativeHandle%3A%20scroll%20to%20top%2Fbottom&em=). 107 | -------------------------------------------------------------------------------- /src/exercise/06-devtools-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/src/exercise/06-devtools-after.png -------------------------------------------------------------------------------- /src/exercise/06-devtools-before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/advanced-react-hooks/a038c841ac81f1ddf20963196a90745892748bf3/src/exercise/06-devtools-before.png -------------------------------------------------------------------------------- /src/exercise/06.js: -------------------------------------------------------------------------------- 1 | // useDebugValue: useMedia 2 | // http://localhost:3000/isolated/exercise/06.js 3 | 4 | import * as React from 'react' 5 | 6 | function useMedia(query, initialState = false) { 7 | const [state, setState] = React.useState(initialState) 8 | // 🐨 call React.useDebugValue here. 9 | // 💰 here's the formatted label I use: `\`${query}\` => ${state}` 10 | 11 | React.useEffect(() => { 12 | let mounted = true 13 | const mql = window.matchMedia(query) 14 | function onChange() { 15 | if (!mounted) { 16 | return 17 | } 18 | setState(Boolean(mql.matches)) 19 | } 20 | 21 | mql.addListener(onChange) 22 | setState(mql.matches) 23 | 24 | return () => { 25 | mounted = false 26 | mql.removeListener(onChange) 27 | } 28 | }, [query]) 29 | 30 | return state 31 | } 32 | 33 | function Box() { 34 | const isBig = useMedia('(min-width: 1000px)') 35 | const isMedium = useMedia('(max-width: 999px) and (min-width: 700px)') 36 | const isSmall = useMedia('(max-width: 699px)') 37 | const color = isBig ? 'green' : isMedium ? 'yellow' : isSmall ? 'red' : null 38 | 39 | return
40 | } 41 | 42 | function App() { 43 | return 44 | } 45 | 46 | export default App 47 | -------------------------------------------------------------------------------- /src/exercise/06.md: -------------------------------------------------------------------------------- 1 | # useDebugValue: useMedia 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `src/exercise/06.md` 6 | 7 | ## Background 8 | 9 | [The React DevTools browser extension](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) 10 | is a must-have for any React developer. When you start writing custom hooks, it 11 | can be useful to give them a special label. This is especially useful to 12 | differentiate different usages of the same hook in a given component. 13 | 14 | This is where `useDebugValue` comes in. You use it in a custom hook, and you 15 | call it like so: 16 | 17 | ```javascript 18 | function useCount({initialCount = 0, step = 1} = {}) { 19 | React.useDebugValue({initialCount, step}) 20 | const [count, setCount] = React.useState(initialCount) 21 | const increment = () => setCount(c => c + step) 22 | return [count, increment] 23 | } 24 | ``` 25 | 26 | So now when people use the `useCount` hook, they'll see the `initialCount` and 27 | `step` values for that particular hook. 28 | 29 | ## Exercise 30 | 31 | Production deploys: 32 | 33 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/06.js) 34 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/06.js) 35 | 36 | > Note: useDebugValue values will not show in production, because the production build of useDebugValue does nothing. 37 | 38 | In this exercise, we have a custom `useMedia` hook which uses 39 | `window.matchMedia` to determine whether the user-agent satisfies a given media 40 | query. In our `Box` component, we're using it three times to determine whether 41 | the screen is big, medium, or small and we change the color of the box based on 42 | that. 43 | 44 | Now, take a look at the png files associated with this exercise. You'll notice 45 | that the before doesn't give any useful information for you to know which hook 46 | record references which hook. In the after version, you'll see a really nice 47 | label associated with each hook which makes it obvious which is which. 48 | 49 | If you don't have the browser extension installed, install it now and open the 50 | React tab in the DevTools. Select the `` component in the React tree. 51 | Your job is to use `useDebugValue` to provide a nice label. 52 | 53 | > Note: your hooks may look a tiny bit different from the screenshots thanks to 54 | > the fact that we're using 55 | > [`stop-runaway-react-effects`](https://github.com/kentcdodds/stop-runaway-react-effects). 56 | > Just focus on the label. That should be the same. 57 | 58 | ## Extra Credit 59 | 60 | ### 1. 💯 use the format function 61 | 62 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/06.extra-1.js) 63 | 64 | `useDebugValue` also takes a second argument which is an optional formatter 65 | function, allowing you to do stuff like this if you like: 66 | 67 | ```javascript 68 | const formatCountDebugValue = ({initialCount, step}) => 69 | `init: ${initialCount}; step: ${step}` 70 | 71 | function useCount({initialCount = 0, step = 1} = {}) { 72 | React.useDebugValue({initialCount, step}, formatCountDebugValue) 73 | const [count, setCount] = React.useState(0) 74 | const increment = () => setCount(c => c + step) 75 | return [count, increment] 76 | } 77 | ``` 78 | 79 | This is only really useful for situations where computing the debug value is 80 | computationally expensive (and therefore you only want it calculated when the 81 | DevTools are open). In our case this is not necessary, however, go ahead and 82 | give it a try anyway. 83 | 84 | ## 🦉 Feedback 85 | 86 | Fill out 87 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=06%3A%20useDebugValue%3A%20useMedia&em=). 88 | -------------------------------------------------------------------------------- /src/final/01.extra-1.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // 💯 accept the step as the action 3 | // http://localhost:3000/isolated/final/01.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | const countReducer = (count, change) => count + change 8 | 9 | function Counter({initialCount = 0, step = 1}) { 10 | const [count, changeCount] = React.useReducer(countReducer, initialCount) 11 | const increment = () => changeCount(step) 12 | return 13 | } 14 | 15 | function Usage() { 16 | return 17 | } 18 | 19 | export default Usage 20 | -------------------------------------------------------------------------------- /src/final/01.extra-2.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // 💯 simulate setState with an object 3 | // http://localhost:3000/isolated/final/01.extra-2.js 4 | 5 | import * as React from 'react' 6 | 7 | const countReducer = (state, action) => ({...state, ...action}) 8 | 9 | function Counter({initialCount = 0, step = 1}) { 10 | const [state, setState] = React.useReducer(countReducer, { 11 | count: initialCount, 12 | }) 13 | const {count} = state 14 | const increment = () => setState({count: count + step}) 15 | return 16 | } 17 | 18 | function App() { 19 | return 20 | } 21 | 22 | export default App 23 | -------------------------------------------------------------------------------- /src/final/01.extra-3.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // 💯 simulate setState with an object OR function 3 | // http://localhost:3000/isolated/final/01.extra-3.js 4 | 5 | import * as React from 'react' 6 | 7 | const countReducer = (state, action) => ({ 8 | ...state, 9 | ...(typeof action === 'function' ? action(state) : action), 10 | }) 11 | 12 | function Counter({initialCount = 0, step = 1}) { 13 | const [state, setState] = React.useReducer(countReducer, { 14 | count: initialCount, 15 | }) 16 | const {count} = state 17 | const increment = () => 18 | setState(currentState => ({count: currentState.count + step})) 19 | return 20 | } 21 | 22 | function App() { 23 | return 24 | } 25 | 26 | export default App 27 | -------------------------------------------------------------------------------- /src/final/01.extra-4.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // 💯 traditional dispatch object with a type and switch statement 3 | // http://localhost:3000/isolated/final/01.extra-4.js 4 | 5 | import * as React from 'react' 6 | 7 | function countReducer(state, action) { 8 | const {type, step} = action 9 | switch (type) { 10 | case 'increment': { 11 | return { 12 | ...state, 13 | count: state.count + step, 14 | } 15 | } 16 | default: { 17 | throw new Error(`Unsupported action type: ${type}`) 18 | } 19 | } 20 | } 21 | 22 | function Counter({initialCount = 0, step = 1}) { 23 | const [state, dispatch] = React.useReducer(countReducer, { 24 | count: initialCount, 25 | }) 26 | const {count} = state 27 | const increment = () => dispatch({type: 'increment', step}) 28 | return 29 | } 30 | 31 | function App() { 32 | return 33 | } 34 | 35 | export default App 36 | -------------------------------------------------------------------------------- /src/final/01.js: -------------------------------------------------------------------------------- 1 | // useReducer: simple Counter 2 | // http://localhost:3000/isolated/final/01.js 3 | 4 | import * as React from 'react' 5 | 6 | const countReducer = (state, newState) => newState 7 | 8 | function Counter({initialCount = 0, step = 1}) { 9 | const [count, setCount] = React.useReducer(countReducer, initialCount) 10 | const increment = () => setCount(count + step) 11 | return 12 | } 13 | 14 | function App() { 15 | return 16 | } 17 | 18 | export default App 19 | -------------------------------------------------------------------------------- /src/final/02.extra-1.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // 💯 use useCallback to empower the user to customize memoization 3 | // http://localhost:3000/isolated/final/02.extra-1.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonForm, 9 | PokemonDataView, 10 | PokemonInfoFallback, 11 | PokemonErrorBoundary, 12 | } from '../pokemon' 13 | 14 | function asyncReducer(state, action) { 15 | switch (action.type) { 16 | case 'pending': { 17 | return {status: 'pending', data: null, error: null} 18 | } 19 | case 'resolved': { 20 | return {status: 'resolved', data: action.data, error: null} 21 | } 22 | case 'rejected': { 23 | return {status: 'rejected', data: null, error: action.error} 24 | } 25 | default: { 26 | throw new Error(`Unhandled action type: ${action.type}`) 27 | } 28 | } 29 | } 30 | 31 | function useAsync(asyncCallback, initialState) { 32 | const [state, dispatch] = React.useReducer(asyncReducer, { 33 | status: 'idle', 34 | data: null, 35 | error: null, 36 | ...initialState, 37 | }) 38 | React.useEffect(() => { 39 | const promise = asyncCallback() 40 | if (!promise) { 41 | return 42 | } 43 | dispatch({type: 'pending'}) 44 | promise.then( 45 | data => { 46 | dispatch({type: 'resolved', data}) 47 | }, 48 | error => { 49 | dispatch({type: 'rejected', error}) 50 | }, 51 | ) 52 | }, [asyncCallback]) 53 | return state 54 | } 55 | 56 | function PokemonInfo({pokemonName}) { 57 | const asyncCallback = React.useCallback(() => { 58 | if (!pokemonName) { 59 | return 60 | } 61 | return fetchPokemon(pokemonName) 62 | }, [pokemonName]) 63 | 64 | const state = useAsync(asyncCallback, { 65 | status: pokemonName ? 'pending' : 'idle', 66 | }) 67 | const {data: pokemon, status, error} = state 68 | 69 | switch (status) { 70 | case 'idle': 71 | return Submit a pokemon 72 | case 'pending': 73 | return 74 | case 'rejected': 75 | throw error 76 | case 'resolved': 77 | return 78 | default: 79 | throw new Error('This should be impossible') 80 | } 81 | } 82 | 83 | function App() { 84 | const [pokemonName, setPokemonName] = React.useState('') 85 | 86 | function handleSubmit(newPokemonName) { 87 | setPokemonName(newPokemonName) 88 | } 89 | 90 | function handleReset() { 91 | setPokemonName('') 92 | } 93 | 94 | return ( 95 |
96 | 97 |
98 |
99 | 100 | 101 | 102 |
103 |
104 | ) 105 | } 106 | 107 | function AppWithUnmountCheckbox() { 108 | const [mountApp, setMountApp] = React.useState(true) 109 | return ( 110 |
111 | 119 |
120 | {mountApp ? : null} 121 |
122 | ) 123 | } 124 | 125 | export default AppWithUnmountCheckbox 126 | -------------------------------------------------------------------------------- /src/final/02.extra-2.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // 💯 return a memoized `run` function from useAsync 3 | // http://localhost:3000/isolated/final/02.extra-2.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonForm, 9 | PokemonDataView, 10 | PokemonInfoFallback, 11 | PokemonErrorBoundary, 12 | } from '../pokemon' 13 | 14 | function asyncReducer(state, action) { 15 | switch (action.type) { 16 | case 'pending': { 17 | return {status: 'pending', data: null, error: null} 18 | } 19 | case 'resolved': { 20 | return {status: 'resolved', data: action.data, error: null} 21 | } 22 | case 'rejected': { 23 | return {status: 'rejected', data: null, error: action.error} 24 | } 25 | default: { 26 | throw new Error(`Unhandled action type: ${action.type}`) 27 | } 28 | } 29 | } 30 | 31 | function useAsync(initialState) { 32 | const [state, dispatch] = React.useReducer(asyncReducer, { 33 | status: 'idle', 34 | data: null, 35 | error: null, 36 | ...initialState, 37 | }) 38 | 39 | const {data, error, status} = state 40 | 41 | const run = React.useCallback(promise => { 42 | dispatch({type: 'pending'}) 43 | promise.then( 44 | data => { 45 | dispatch({type: 'resolved', data}) 46 | }, 47 | error => { 48 | dispatch({type: 'rejected', error}) 49 | }, 50 | ) 51 | }, []) 52 | 53 | return { 54 | error, 55 | status, 56 | data, 57 | run, 58 | } 59 | } 60 | 61 | function PokemonInfo({pokemonName}) { 62 | const { 63 | data: pokemon, 64 | status, 65 | error, 66 | run, 67 | } = useAsync({ 68 | status: pokemonName ? 'pending' : 'idle', 69 | }) 70 | 71 | React.useEffect(() => { 72 | if (!pokemonName) { 73 | return 74 | } 75 | const pokemonPromise = fetchPokemon(pokemonName) 76 | run(pokemonPromise) 77 | }, [pokemonName, run]) 78 | 79 | switch (status) { 80 | case 'idle': 81 | return Submit a pokemon 82 | case 'pending': 83 | return 84 | case 'rejected': 85 | throw error 86 | case 'resolved': 87 | return 88 | default: 89 | throw new Error('This should be impossible') 90 | } 91 | } 92 | 93 | function App() { 94 | const [pokemonName, setPokemonName] = React.useState('') 95 | 96 | function handleSubmit(newPokemonName) { 97 | setPokemonName(newPokemonName) 98 | } 99 | 100 | function handleReset() { 101 | setPokemonName('') 102 | } 103 | 104 | return ( 105 |
106 | 107 |
108 |
109 | 110 | 111 | 112 |
113 |
114 | ) 115 | } 116 | 117 | function AppWithUnmountCheckbox() { 118 | const [mountApp, setMountApp] = React.useState(true) 119 | return ( 120 |
121 | 129 |
130 | {mountApp ? : null} 131 |
132 | ) 133 | } 134 | 135 | export default AppWithUnmountCheckbox 136 | -------------------------------------------------------------------------------- /src/final/02.extra-3.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // 💯 make safeDispatch with useCallback, useRef, and useEffect 3 | // http://localhost:3000/isolated/final/02.extra-3.js 4 | 5 | import * as React from 'react' 6 | import { 7 | fetchPokemon, 8 | PokemonForm, 9 | PokemonDataView, 10 | PokemonInfoFallback, 11 | PokemonErrorBoundary, 12 | } from '../pokemon' 13 | 14 | function useSafeDispatch(dispatch) { 15 | const mountedRef = React.useRef(false) 16 | 17 | // to make this even more generic you should use the useLayoutEffect hook to 18 | // make sure that you are correctly setting the mountedRef.current immediately 19 | // after React updates the DOM. Even though this effect does not interact 20 | // with the dom another side effect inside a useLayoutEffect which does 21 | // interact with the dom may depend on the value being set 22 | React.useEffect(() => { 23 | mountedRef.current = true 24 | return () => { 25 | mountedRef.current = false 26 | } 27 | }, []) 28 | 29 | return React.useCallback( 30 | (...args) => (mountedRef.current ? dispatch(...args) : void 0), 31 | [dispatch], 32 | ) 33 | } 34 | 35 | function asyncReducer(state, action) { 36 | switch (action.type) { 37 | case 'pending': { 38 | return {status: 'pending', data: null, error: null} 39 | } 40 | case 'resolved': { 41 | return {status: 'resolved', data: action.data, error: null} 42 | } 43 | case 'rejected': { 44 | return {status: 'rejected', data: null, error: action.error} 45 | } 46 | default: { 47 | throw new Error(`Unhandled action type: ${action.type}`) 48 | } 49 | } 50 | } 51 | 52 | function useAsync(initialState) { 53 | const [state, unsafeDispatch] = React.useReducer(asyncReducer, { 54 | status: 'idle', 55 | data: null, 56 | error: null, 57 | ...initialState, 58 | }) 59 | 60 | const dispatch = useSafeDispatch(unsafeDispatch) 61 | 62 | const {data, error, status} = state 63 | 64 | const run = React.useCallback( 65 | promise => { 66 | dispatch({type: 'pending'}) 67 | promise.then( 68 | data => { 69 | dispatch({type: 'resolved', data}) 70 | }, 71 | error => { 72 | dispatch({type: 'rejected', error}) 73 | }, 74 | ) 75 | }, 76 | [dispatch], 77 | ) 78 | 79 | return { 80 | error, 81 | status, 82 | data, 83 | run, 84 | } 85 | } 86 | 87 | function PokemonInfo({pokemonName}) { 88 | const { 89 | data: pokemon, 90 | status, 91 | error, 92 | run, 93 | } = useAsync({ 94 | status: pokemonName ? 'pending' : 'idle', 95 | }) 96 | 97 | React.useEffect(() => { 98 | if (!pokemonName) { 99 | return 100 | } 101 | run(fetchPokemon(pokemonName)) 102 | }, [pokemonName, run]) 103 | 104 | switch (status) { 105 | case 'idle': 106 | return Submit a pokemon 107 | case 'pending': 108 | return 109 | case 'rejected': 110 | throw error 111 | case 'resolved': 112 | return 113 | default: 114 | throw new Error('This should be impossible') 115 | } 116 | } 117 | 118 | function App() { 119 | const [pokemonName, setPokemonName] = React.useState('') 120 | 121 | function handleSubmit(newPokemonName) { 122 | setPokemonName(newPokemonName) 123 | } 124 | 125 | function handleReset() { 126 | setPokemonName('') 127 | } 128 | 129 | return ( 130 |
131 | 132 |
133 |
134 | 135 | 136 | 137 |
138 |
139 | ) 140 | } 141 | 142 | function AppWithUnmountCheckbox() { 143 | const [mountApp, setMountApp] = React.useState(true) 144 | return ( 145 |
146 | 154 |
155 | {mountApp ? : null} 156 |
157 | ) 158 | } 159 | 160 | export default AppWithUnmountCheckbox 161 | -------------------------------------------------------------------------------- /src/final/02.js: -------------------------------------------------------------------------------- 1 | // useCallback: custom hooks 2 | // http://localhost:3000/isolated/final/02.js 3 | 4 | import * as React from 'react' 5 | import { 6 | fetchPokemon, 7 | PokemonForm, 8 | PokemonDataView, 9 | PokemonInfoFallback, 10 | PokemonErrorBoundary, 11 | } from '../pokemon' 12 | 13 | function asyncReducer(state, action) { 14 | switch (action.type) { 15 | case 'pending': { 16 | return {status: 'pending', data: null, error: null} 17 | } 18 | case 'resolved': { 19 | return {status: 'resolved', data: action.data, error: null} 20 | } 21 | case 'rejected': { 22 | return {status: 'rejected', data: null, error: action.error} 23 | } 24 | default: { 25 | throw new Error(`Unhandled action type: ${action.type}`) 26 | } 27 | } 28 | } 29 | 30 | function useAsync(asyncCallback, initialState, dependencies) { 31 | const [state, dispatch] = React.useReducer(asyncReducer, { 32 | status: 'idle', 33 | data: null, 34 | error: null, 35 | ...initialState, 36 | }) 37 | 38 | React.useEffect(() => { 39 | const promise = asyncCallback() 40 | if (!promise) { 41 | return 42 | } 43 | dispatch({type: 'pending'}) 44 | promise.then( 45 | data => { 46 | dispatch({type: 'resolved', data}) 47 | }, 48 | error => { 49 | dispatch({type: 'rejected', error}) 50 | }, 51 | ) 52 | // too bad the eslint plugin can't statically analyze this :-( 53 | // eslint-disable-next-line react-hooks/exhaustive-deps 54 | }, dependencies) 55 | 56 | return state 57 | } 58 | 59 | function PokemonInfo({pokemonName}) { 60 | const state = useAsync( 61 | () => { 62 | if (!pokemonName) { 63 | return 64 | } 65 | return fetchPokemon(pokemonName) 66 | }, 67 | {status: pokemonName ? 'pending' : 'idle'}, 68 | [pokemonName], 69 | ) 70 | 71 | const {data: pokemon, status, error} = state 72 | 73 | switch (status) { 74 | case 'idle': 75 | return Submit a pokemon 76 | case 'pending': 77 | return 78 | case 'rejected': 79 | throw error 80 | case 'resolved': 81 | return 82 | default: 83 | throw new Error('This should be impossible') 84 | } 85 | } 86 | 87 | function App() { 88 | const [pokemonName, setPokemonName] = React.useState('') 89 | 90 | function handleSubmit(newPokemonName) { 91 | setPokemonName(newPokemonName) 92 | } 93 | 94 | function handleReset() { 95 | setPokemonName('') 96 | } 97 | 98 | return ( 99 |
100 | 101 |
102 |
103 | 104 | 105 | 106 |
107 |
108 | ) 109 | } 110 | 111 | function AppWithUnmountCheckbox() { 112 | const [mountApp, setMountApp] = React.useState(true) 113 | return ( 114 |
115 | 123 |
124 | {mountApp ? : null} 125 |
126 | ) 127 | } 128 | 129 | export default AppWithUnmountCheckbox 130 | -------------------------------------------------------------------------------- /src/final/03.extra-1.js: -------------------------------------------------------------------------------- 1 | // useContext: simple Counter 2 | // 💯 create a consumer hook 3 | // http://localhost:3000/isolated/final/03.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | const CountContext = React.createContext() 8 | 9 | function CountProvider(props) { 10 | const [count, setCount] = React.useState(0) 11 | const value = [count, setCount] 12 | return 13 | } 14 | 15 | function useCount() { 16 | const context = React.useContext(CountContext) 17 | if (!context) { 18 | throw new Error('useCount must be used within a CountProvider') 19 | } 20 | return context 21 | } 22 | 23 | function CountDisplay() { 24 | const [count] = useCount() 25 | return
{`The current count is ${count}`}
26 | } 27 | 28 | function Counter() { 29 | const [, setCount] = useCount() 30 | const increment = () => setCount(c => c + 1) 31 | return 32 | } 33 | 34 | function App() { 35 | return ( 36 |
37 | 38 | 39 | 40 | 41 |
42 | ) 43 | } 44 | 45 | export default App 46 | -------------------------------------------------------------------------------- /src/final/03.extra-2.js: -------------------------------------------------------------------------------- 1 | // useContext: Caching response data in context 2 | // 💯 caching in a context provider (final) 3 | // http://localhost:3000/isolated/final/03.extra-2.js 4 | 5 | // you can edit this here and look at the isolated page or you can copy/paste 6 | // this in the regular exercise file. 7 | 8 | import * as React from 'react' 9 | import {useAsync} from '../utils' 10 | import { 11 | fetchPokemon, 12 | PokemonForm, 13 | PokemonDataView, 14 | PokemonInfoFallback, 15 | PokemonErrorBoundary, 16 | } from '../pokemon' 17 | 18 | const PokemonCacheContext = React.createContext() 19 | 20 | function pokemonCacheReducer(state, action) { 21 | switch (action.type) { 22 | case 'ADD_POKEMON': { 23 | return {...state, [action.pokemonName]: action.pokemonData} 24 | } 25 | default: { 26 | throw new Error(`Unhandled action type: ${action.type}`) 27 | } 28 | } 29 | } 30 | 31 | function PokemonCacheProvider(props) { 32 | const [cache, dispatch] = React.useReducer(pokemonCacheReducer, {}) 33 | return 34 | } 35 | 36 | function usePokemonCache() { 37 | const context = React.useContext(PokemonCacheContext) 38 | if (!context) { 39 | throw new Error( 40 | 'usePokemonCache must be used within a PokemonCacheProvider', 41 | ) 42 | } 43 | return context 44 | } 45 | 46 | function PokemonInfo({pokemonName: externalPokemonName}) { 47 | const [cache, dispatch] = usePokemonCache() 48 | 49 | const pokemonName = externalPokemonName?.toLowerCase() 50 | const {data: pokemon, status, error, run, setData} = useAsync({ 51 | status: pokemonName ? 'pending' : 'idle', 52 | }) 53 | 54 | React.useEffect(() => { 55 | if (!pokemonName) { 56 | return 57 | } else if (cache[pokemonName]) { 58 | setData(cache[pokemonName]) 59 | } else { 60 | run( 61 | fetchPokemon(pokemonName).then(pokemonData => { 62 | dispatch({type: 'ADD_POKEMON', pokemonName, pokemonData}) 63 | return pokemonData 64 | }), 65 | ) 66 | } 67 | }, [cache, dispatch, pokemonName, run, setData]) 68 | 69 | if (status === 'idle') { 70 | return 'Submit a pokemon' 71 | } else if (status === 'pending') { 72 | return 73 | } else if (status === 'rejected') { 74 | throw error 75 | } else if (status === 'resolved') { 76 | return 77 | } 78 | 79 | throw new Error('This should be impossible') 80 | } 81 | 82 | function PreviousPokemon({onSelect}) { 83 | const [cache] = usePokemonCache() 84 | return ( 85 |
86 | Previous Pokemon 87 |
    88 | {Object.keys(cache).map(pokemonName => ( 89 |
  • 90 | 96 |
  • 97 | ))} 98 |
99 |
100 | ) 101 | } 102 | 103 | function PokemonSection({onSelect, pokemonName}) { 104 | return ( 105 | 106 |
107 | 108 |
109 | onSelect('')} 111 | resetKeys={[pokemonName]} 112 | > 113 | 114 | 115 |
116 |
117 |
118 | ) 119 | } 120 | 121 | function App() { 122 | const [pokemonName, setPokemonName] = React.useState(null) 123 | 124 | function handleSubmit(newPokemonName) { 125 | setPokemonName(newPokemonName) 126 | } 127 | 128 | function handleSelect(newPokemonName) { 129 | setPokemonName(newPokemonName) 130 | } 131 | 132 | return ( 133 |
134 | 135 |
136 | 137 |
138 | ) 139 | } 140 | 141 | export default App 142 | -------------------------------------------------------------------------------- /src/final/03.js: -------------------------------------------------------------------------------- 1 | // useContext: simple Counter 2 | // http://localhost:3000/isolated/final/03.js 3 | 4 | import * as React from 'react' 5 | 6 | const CountContext = React.createContext() 7 | 8 | function CountProvider(props) { 9 | const [count, setCount] = React.useState(0) 10 | const value = [count, setCount] 11 | // could also do it like this: 12 | // const value = React.useState(0) 13 | return 14 | } 15 | 16 | function CountDisplay() { 17 | const [count] = React.useContext(CountContext) 18 | return
{`The current count is ${count}`}
19 | } 20 | 21 | function Counter() { 22 | const [, setCount] = React.useContext(CountContext) 23 | const increment = () => setCount(c => c + 1) 24 | return 25 | } 26 | 27 | function App() { 28 | return ( 29 |
30 | 31 | 32 | 33 | 34 |
35 | ) 36 | } 37 | 38 | export default App 39 | -------------------------------------------------------------------------------- /src/final/04.js: -------------------------------------------------------------------------------- 1 | // useLayoutEffect: auto-scrolling textarea 2 | // http://localhost:3000/isolated/final/04.js 3 | 4 | import * as React from 'react' 5 | 6 | function MessagesDisplay({messages}) { 7 | const containerRef = React.useRef() 8 | React.useLayoutEffect(() => { 9 | containerRef.current.scrollTop = containerRef.current.scrollHeight 10 | }) 11 | 12 | return ( 13 |
14 | {messages.map((message, index, array) => ( 15 |
16 | {message.author}: {message.content} 17 | {array.length - 1 === index ? null :
} 18 |
19 | ))} 20 |
21 | ) 22 | } 23 | 24 | // this is to simulate major computation/big rendering tree/etc. 25 | function sleep(time = 0) { 26 | const wakeUpTime = Date.now() + time 27 | while (Date.now() < wakeUpTime) {} 28 | } 29 | 30 | function SlooooowSibling() { 31 | // try this with useLayoutEffect as well to see 32 | // how it impacts interactivity of the page before updates. 33 | React.useEffect(() => { 34 | // increase this number to see a more stark difference 35 | sleep(300) 36 | }) 37 | return null 38 | } 39 | 40 | function App() { 41 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8)) 42 | const addMessage = () => 43 | messages.length < allMessages.length 44 | ? setMessages(allMessages.slice(0, messages.length + 1)) 45 | : null 46 | const removeMessage = () => 47 | messages.length > 0 48 | ? setMessages(allMessages.slice(0, messages.length - 1)) 49 | : null 50 | 51 | return ( 52 |
53 |
54 | 55 | 56 |
57 |
58 | 59 | 60 |
61 | ) 62 | } 63 | 64 | export default App 65 | 66 | const allMessages = [ 67 | `Leia: Aren't you a little short to be a stormtrooper?`, 68 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`, 69 | `Leia: You're who?`, 70 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`, 71 | `Leia: Ben Kenobi is here! Where is he?`, 72 | `Luke: Come on!`, 73 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`, 74 | `Leia: Put that thing away! You're going to get us all killed.`, 75 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`, 76 | `Leia: It could be worse...`, 77 | `Han: It's worse.`, 78 | `Luke: There's something alive in here!`, 79 | `Han: That's your imagination.`, 80 | `Luke: Something just moves past my leg! Look! Did you see that?`, 81 | `Han: What?`, 82 | `Luke: Help!`, 83 | `Han: Luke! Luke! Luke!`, 84 | `Leia: Luke!`, 85 | `Leia: Luke, Luke, grab a hold of this.`, 86 | `Luke: Blast it, will you! My gun's jammed.`, 87 | `Han: Where?`, 88 | `Luke: Anywhere! Oh!!`, 89 | `Han: Luke! Luke!`, 90 | `Leia: Grab him!`, 91 | `Leia: What happened?`, 92 | `Luke: I don't know, it just let go of me and disappeared...`, 93 | `Han: I've got a very bad feeling about this.`, 94 | `Luke: The walls are moving!`, 95 | `Leia: Don't just stand there. Try to brace it with something.`, 96 | `Luke: Wait a minute!`, 97 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`, 98 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]})) 99 | -------------------------------------------------------------------------------- /src/final/05.js: -------------------------------------------------------------------------------- 1 | // useImperativeHandle: scroll to top/bottom 2 | // http://localhost:3000/isolated/final/05.js 3 | 4 | import * as React from 'react' 5 | 6 | const MessagesDisplay = React.forwardRef(function MessagesDisplay( 7 | {messages}, 8 | ref, 9 | ) { 10 | const containerRef = React.useRef() 11 | React.useLayoutEffect(() => { 12 | scrollToBottom() 13 | }) 14 | function scrollToTop() { 15 | containerRef.current.scrollTop = 0 16 | } 17 | function scrollToBottom() { 18 | containerRef.current.scrollTop = containerRef.current.scrollHeight 19 | } 20 | React.useImperativeHandle(ref, () => ({ 21 | scrollToTop, 22 | scrollToBottom, 23 | })) 24 | 25 | return ( 26 |
27 | {messages.map((message, index, array) => ( 28 |
29 | {message.author}: {message.content} 30 | {array.length - 1 === index ? null :
} 31 |
32 | ))} 33 |
34 | ) 35 | }) 36 | 37 | function App() { 38 | const messageDisplayRef = React.useRef() 39 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8)) 40 | const addMessage = () => 41 | messages.length < allMessages.length 42 | ? setMessages(allMessages.slice(0, messages.length + 1)) 43 | : null 44 | const removeMessage = () => 45 | messages.length > 0 46 | ? setMessages(allMessages.slice(0, messages.length - 1)) 47 | : null 48 | 49 | const scrollToTop = () => messageDisplayRef.current.scrollToTop() 50 | const scrollToBottom = () => messageDisplayRef.current.scrollToBottom() 51 | 52 | return ( 53 |
54 |
55 | 56 | 57 |
58 |
59 |
60 | 61 |
62 | 63 |
64 | 65 |
66 |
67 | ) 68 | } 69 | 70 | export default App 71 | 72 | const allMessages = [ 73 | `Leia: Aren't you a little short to be a stormtrooper?`, 74 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`, 75 | `Leia: You're who?`, 76 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`, 77 | `Leia: Ben Kenobi is here! Where is he?`, 78 | `Luke: Come on!`, 79 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`, 80 | `Leia: Put that thing away! You're going to get us all killed.`, 81 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`, 82 | `Leia: It could be worse...`, 83 | `Han: It's worse.`, 84 | `Luke: There's something alive in here!`, 85 | `Han: That's your imagination.`, 86 | `Luke: Something just moves past my leg! Look! Did you see that?`, 87 | `Han: What?`, 88 | `Luke: Help!`, 89 | `Han: Luke! Luke! Luke!`, 90 | `Leia: Luke!`, 91 | `Leia: Luke, Luke, grab a hold of this.`, 92 | `Luke: Blast it, will you! My gun's jammed.`, 93 | `Han: Where?`, 94 | `Luke: Anywhere! Oh!!`, 95 | `Han: Luke! Luke!`, 96 | `Leia: Grab him!`, 97 | `Leia: What happened?`, 98 | `Luke: I don't know, it just let go of me and disappeared...`, 99 | `Han: I've got a very bad feeling about this.`, 100 | `Luke: The walls are moving!`, 101 | `Leia: Don't just stand there. Try to brace it with something.`, 102 | `Luke: Wait a minute!`, 103 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`, 104 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]})) 105 | -------------------------------------------------------------------------------- /src/final/06.extra-1.js: -------------------------------------------------------------------------------- 1 | // useDebugValue: useMedia 2 | // 💯 use the format function 3 | // http://localhost:3000/isolated/final/06.extra-1.js 4 | 5 | import * as React from 'react' 6 | 7 | const formatDebugValue = ({query, state}) => `\`${query}\` => ${state}` 8 | 9 | function useMedia(query, initialState = false) { 10 | const [state, setState] = React.useState(initialState) 11 | React.useDebugValue({query, state}, formatDebugValue) 12 | 13 | React.useEffect(() => { 14 | let mounted = true 15 | const mql = window.matchMedia(query) 16 | function onChange() { 17 | if (!mounted) { 18 | return 19 | } 20 | setState(Boolean(mql.matches)) 21 | } 22 | 23 | mql.addListener(onChange) 24 | setState(mql.matches) 25 | 26 | return () => { 27 | mounted = false 28 | mql.removeListener(onChange) 29 | } 30 | }, [query]) 31 | 32 | return state 33 | } 34 | 35 | function Box() { 36 | const isBig = useMedia('(min-width: 1000px)') 37 | const isMedium = useMedia('(max-width: 999px) and (min-width: 700px)') 38 | const isSmall = useMedia('(max-width: 699px)') 39 | const color = isBig ? 'green' : isMedium ? 'yellow' : isSmall ? 'red' : null 40 | 41 | return
42 | } 43 | 44 | function App() { 45 | return 46 | } 47 | 48 | export default App 49 | -------------------------------------------------------------------------------- /src/final/06.js: -------------------------------------------------------------------------------- 1 | // useDebugValue: useMedia 2 | // http://localhost:3000/isolated/final/06.js 3 | 4 | import * as React from 'react' 5 | 6 | function useMedia(query, initialState = false) { 7 | const [state, setState] = React.useState(initialState) 8 | React.useDebugValue(`\`${query}\` => ${state}`) 9 | 10 | React.useEffect(() => { 11 | let mounted = true 12 | const mql = window.matchMedia(query) 13 | function onChange() { 14 | if (!mounted) { 15 | return 16 | } 17 | setState(Boolean(mql.matches)) 18 | } 19 | 20 | mql.addListener(onChange) 21 | setState(mql.matches) 22 | 23 | return () => { 24 | mounted = false 25 | mql.removeListener(onChange) 26 | } 27 | }, [query]) 28 | 29 | return state 30 | } 31 | 32 | function Box() { 33 | const isBig = useMedia('(min-width: 1000px)') 34 | const isMedium = useMedia('(max-width: 999px) and (min-width: 700px)') 35 | const isSmall = useMedia('(max-width: 699px)') 36 | const color = isBig ? 'green' : isMedium ? 'yellow' : isSmall ? 'red' : null 37 | 38 | return
39 | } 40 | 41 | function App() { 42 | return 43 | } 44 | 45 | export default App 46 | -------------------------------------------------------------------------------- /src/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://graphql-pokemon2.vercel.app/ 32 | method: 'POST', 33 | headers: { 34 | 'content-type': 'application/json;charset=UTF-8', 35 | delay: delay, 36 | }, 37 | body: JSON.stringify({ 38 | query: pokemonQuery, 39 | variables: {name: name.toLowerCase()}, 40 | }), 41 | }) 42 | .then(async response => { 43 | const {data} = await response.json() 44 | if (response.ok) { 45 | const pokemon = data?.pokemon 46 | if (pokemon) { 47 | pokemon.fetchedAt = formatDate(new Date()) 48 | return pokemon 49 | } else { 50 | return Promise.reject(new Error(`No pokemon with the name "${name}"`)) 51 | } 52 | } else { 53 | // handle the graphql errors 54 | const error = { 55 | message: data?.errors?.map(e => e.message).join('\n'), 56 | } 57 | return Promise.reject(error) 58 | } 59 | }) 60 | } 61 | 62 | function PokemonInfoFallback({name}) { 63 | const initialName = React.useRef(name).current 64 | const fallbackPokemonData = { 65 | name: initialName, 66 | number: 'XXX', 67 | image: '/img/pokemon/fallback-pokemon.jpg', 68 | attacks: { 69 | special: [ 70 | {name: 'Loading Attack 1', type: 'Type', damage: 'XX'}, 71 | {name: 'Loading Attack 2', type: 'Type', damage: 'XX'}, 72 | ], 73 | }, 74 | fetchedAt: 'loading...', 75 | } 76 | return 77 | } 78 | 79 | function PokemonDataView({pokemon}) { 80 | return ( 81 |
82 |
83 | {pokemon.name} 84 |
85 |
86 |

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

90 |
91 |
92 |
    93 | {pokemon.attacks.special.map(attack => ( 94 |
  • 95 | :{' '} 96 | 97 | {attack.damage} ({attack.type}) 98 | 99 |
  • 100 | ))} 101 |
102 |
103 | {pokemon.fetchedAt} 104 |
105 | ) 106 | } 107 | 108 | function PokemonForm({ 109 | pokemonName: externalPokemonName, 110 | initialPokemonName = externalPokemonName || '', 111 | onSubmit, 112 | }) { 113 | const [pokemonName, setPokemonName] = React.useState(initialPokemonName) 114 | 115 | // this is generally not a great idea. We're synchronizing state when it is 116 | // normally better to derive it https://kentcdodds.com/blog/dont-sync-state-derive-it 117 | // however, we're doing things this way to make it easier for the exercises 118 | // to not have to worry about the logic for this PokemonForm component. 119 | React.useEffect(() => { 120 | // note that because it's a string value, if the externalPokemonName 121 | // is the same as the one we're managing, this will not trigger a re-render 122 | if (typeof externalPokemonName === 'string') { 123 | setPokemonName(externalPokemonName) 124 | } 125 | }, [externalPokemonName]) 126 | 127 | function handleChange(e) { 128 | setPokemonName(e.target.value) 129 | } 130 | 131 | function handleSubmit(e) { 132 | e.preventDefault() 133 | onSubmit(pokemonName) 134 | } 135 | 136 | function handleSelect(newPokemonName) { 137 | setPokemonName(newPokemonName) 138 | onSubmit(newPokemonName) 139 | } 140 | 141 | return ( 142 |
143 | 144 | 145 | Try{' '} 146 | 153 | {', '} 154 | 161 | {', or '} 162 | 169 | 170 |
171 | 179 | 182 |
183 |
184 | ) 185 | } 186 | 187 | function ErrorFallback({error, resetErrorBoundary}) { 188 | return ( 189 |
190 | There was an error:{' '} 191 |
{error.message}
192 | 193 |
194 | ) 195 | } 196 | 197 | function PokemonErrorBoundary(props) { 198 | return 199 | } 200 | 201 | export { 202 | PokemonInfoFallback, 203 | PokemonForm, 204 | PokemonDataView, 205 | fetchPokemon, 206 | PokemonErrorBoundary, 207 | } 208 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import '@kentcdodds/react-workshop-app/setup-tests' 2 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* For exercise 2 and 3, we're handling errors with an error boundary */ 2 | body[class*='2'] :not(.render-container) > iframe, 3 | body[class*='2'] > iframe, 4 | body[class*='3'] :not(.render-container) > iframe, 5 | body[class*='3'] > iframe { 6 | display: none; 7 | } 8 | 9 | .pokemon-info-app a { 10 | color: #cc0000; 11 | } 12 | 13 | .pokemon-info-app a:focus, 14 | .pokemon-info-app a:hover, 15 | .pokemon-info-app a:active { 16 | color: #8a0000; 17 | } 18 | 19 | .pokemon-info-app input { 20 | line-height: 2; 21 | font-size: 16px; 22 | box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); 23 | border: none; 24 | border-radius: 2px; 25 | padding-left: 10px; 26 | padding-right: 10px; 27 | background-color: #eee; 28 | } 29 | 30 | .pokemon-info-app button { 31 | font-size: 1rem; 32 | font-family: inherit; 33 | border: 1px solid #ff0000; 34 | background-color: #cc0000; 35 | cursor: pointer; 36 | padding: 8px 10px; 37 | color: #eee; 38 | border-radius: 3px; 39 | } 40 | 41 | .pokemon-info-app button:disabled { 42 | border-color: #dc9494; 43 | background-color: #f16161; 44 | cursor: unset; 45 | } 46 | 47 | .pokemon-info-app button:hover:not(:disabled), 48 | .pokemon-info-app button:active:not(:disabled), 49 | .pokemon-info-app button:focus:not(:disabled) { 50 | border-color: #cc0000; 51 | background-color: #8a0000; 52 | } 53 | 54 | .pokemon-info-app .totally-centered { 55 | width: 100%; 56 | height: 100%; 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | } 61 | 62 | .pokemon-info-app { 63 | max-width: 500px; 64 | margin: auto; 65 | } 66 | [class*='_isolated'] .pokemon-info-app { 67 | margin-top: 50px; 68 | } 69 | 70 | .pokemon-form { 71 | display: flex; 72 | flex-direction: column; 73 | align-items: center; 74 | } 75 | 76 | .pokemon-form input { 77 | margin-top: 10px; 78 | margin-right: 10px; 79 | } 80 | 81 | .pokemon-info { 82 | height: 400px; 83 | width: 300px; 84 | margin: auto; 85 | overflow: auto; 86 | background-color: #eee; 87 | border-radius: 4px; 88 | padding: 10px; 89 | position: relative; 90 | } 91 | 92 | .pokemon-info.pokemon-loading { 93 | opacity: 0.6; 94 | transition: opacity 0s; 95 | /* note: the transition delay is the same as the busyDelayMs config */ 96 | transition-delay: 0.4s; 97 | } 98 | 99 | .pokemon-info h2 { 100 | font-weight: bold; 101 | text-align: center; 102 | margin-top: 0.3em; 103 | } 104 | 105 | .pokemon-info img { 106 | max-width: 100%; 107 | max-height: 200px; 108 | } 109 | 110 | .pokemon-info .pokemon-info__img-wrapper { 111 | text-align: center; 112 | margin-top: 20px; 113 | } 114 | 115 | .pokemon-info .pokemon-info__fetch-time { 116 | position: absolute; 117 | top: 6px; 118 | right: 10px; 119 | } 120 | 121 | .pokemon-info-app button.invisible-button { 122 | border: none; 123 | padding: inherit; 124 | font-size: inherit; 125 | font-family: inherit; 126 | cursor: pointer; 127 | font-weight: inherit; 128 | background-color: transparent; 129 | color: #000; 130 | } 131 | .pokemon-info-app button.invisible-button:hover, 132 | .pokemon-info-app button.invisible-button:active, 133 | .pokemon-info-app button.invisible-button:focus { 134 | border: none; 135 | background-color: transparent; 136 | } 137 | 138 | .messaging-app { 139 | max-width: 350px; 140 | margin: auto; 141 | } 142 | 143 | [class*='_isolated'] .messaging-app { 144 | margin-top: 50px; 145 | } 146 | 147 | .messaging-app [role='log'] { 148 | margin: auto; 149 | height: 300px; 150 | overflow-y: scroll; 151 | width: 300px; 152 | outline: 1px solid black; 153 | padding: 30px 10px; 154 | } 155 | 156 | .messaging-app [role='log'] hr { 157 | margin-top: 8px; 158 | margin-bottom: 8px; 159 | } 160 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | function useSafeDispatch(dispatch) { 4 | const mounted = React.useRef(false) 5 | 6 | React.useLayoutEffect(() => { 7 | mounted.current = true 8 | return () => { 9 | mounted.current = false 10 | } 11 | }, []) 12 | 13 | return React.useCallback( 14 | (...args) => (mounted.current ? dispatch(...args) : void 0), 15 | [dispatch], 16 | ) 17 | } 18 | 19 | function asyncReducer(state, action) { 20 | switch (action.type) { 21 | case 'pending': { 22 | return {status: 'pending', data: null, error: null} 23 | } 24 | case 'resolved': { 25 | return {status: 'resolved', data: action.data, error: null} 26 | } 27 | case 'rejected': { 28 | return {status: 'rejected', data: null, error: action.error} 29 | } 30 | default: { 31 | throw new Error(`Unhandled action type: ${action.type}`) 32 | } 33 | } 34 | } 35 | 36 | function useAsync(initialState) { 37 | const [state, unsafeDispatch] = React.useReducer(asyncReducer, { 38 | status: 'idle', 39 | data: null, 40 | error: null, 41 | ...initialState, 42 | }) 43 | 44 | const dispatch = useSafeDispatch(unsafeDispatch) 45 | 46 | const {data, error, status} = state 47 | 48 | const run = React.useCallback( 49 | promise => { 50 | dispatch({type: 'pending'}) 51 | promise.then( 52 | data => { 53 | dispatch({type: 'resolved', data}) 54 | }, 55 | error => { 56 | dispatch({type: 'rejected', error}) 57 | }, 58 | ) 59 | }, 60 | [dispatch], 61 | ) 62 | 63 | const setData = React.useCallback( 64 | data => dispatch({type: 'resolved', data}), 65 | [dispatch], 66 | ) 67 | const setError = React.useCallback( 68 | error => dispatch({type: 'rejected', error}), 69 | [dispatch], 70 | ) 71 | 72 | return { 73 | setData, 74 | setError, 75 | error, 76 | status, 77 | data, 78 | run, 79 | } 80 | } 81 | 82 | export {useAsync} 83 | --------------------------------------------------------------------------------