├── .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 |
3 |
4 | Learn the more advanced React hooks and different patterns to enable great
5 | developer APIs for custom hooks.
6 |
7 |
8 | We’ll look at some of the more advanced hooks and ways they can be used to
9 | optimize your components and custom hooks. We’ll also look at several
10 | patterns you can follow to make custom hooks that provide great APIs for
11 | developers to be productive building applications.
12 |
13 |
14 |
15 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | [![Build Status][build-badge]][build]
26 | [![All Contributors][all-contributors-badge]](#contributors)
27 | [![GPL 3.0 License][license-badge]][license]
28 | [![Code of Conduct][coc-badge]][coc]
29 | [![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 | [](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 |
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 | You need to enable JavaScript to run this app.
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 {count}
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 |
40 | Name:
41 |
42 | You typed: {name}
43 | >
44 | )
45 | }
46 | ```
47 |
48 | One important thing to note here is that the reducer (called `nameReducer`
49 | above) is called with two arguments:
50 |
51 | 1. the current state
52 | 2. whatever it is that the dispatch function (called `setName` above) is called
53 | with. This is often called an "action."
54 |
55 | ## Exercise
56 |
57 | Production deploys:
58 |
59 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/01.js)
60 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/01.js)
61 |
62 | We're going to start off as simple as possible with a ` ` component.
63 | `useReducer` is absolutely overkill for a counter component like ours, but for
64 | now, just focus on making things work with `useReducer`.
65 |
66 | 📜 Here are two really helpful blog posts comparing `useState` and `useReducer`:
67 |
68 | - [Should I useState or useReducer?](https://kentcdodds.com/blog/should-i-usestate-or-usereducer)
69 | - [How to implement useState with useReducer](https://kentcdodds.com/blog/how-to-implement-usestate-with-usereducer)
70 |
71 | ## Extra Credit
72 |
73 | ### 1. 💯 accept the step as the action
74 |
75 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-1.js)
76 |
77 | I want to change things a bit to have this API:
78 |
79 | ```javascript
80 | const [count, changeCount] = React.useReducer(countReducer, initialCount)
81 | const increment = () => changeCount(step)
82 | ```
83 |
84 | How would you need to change your reducer to make this work?
85 |
86 | This one is just to show that you can pass anything as the action.
87 |
88 | ### 2. 💯 simulate setState with an object
89 |
90 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-2.js)
91 |
92 | Remember `this.setState` from class components? If not, lucky you 😉. Either
93 | way, let's see if you can figure out how to make the state updater (`dispatch`
94 | function) behave in a similar way by changing our `state` to an object
95 | (`{count: 0}`) and then calling the state updater with an object which merges
96 | with the current state.
97 |
98 | So here's how I want things to look now:
99 |
100 | ```javascript
101 | const [state, setState] = React.useReducer(countReducer, {
102 | count: initialCount,
103 | })
104 | const {count} = state
105 | const increment = () => setState({count: count + step})
106 | ```
107 |
108 | How would you need to change the reducer to make this work?
109 |
110 | ### 3. 💯 simulate setState with an object OR function
111 |
112 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-3.js)
113 |
114 | `this.setState` from class components can also accept a function. So let's add
115 | support for that with our simulated `setState` function. See if you can figure
116 | out how to make your reducer support both the object as in the last extra credit
117 | as well as a function callback:
118 |
119 | ```javascript
120 | const [state, setState] = React.useReducer(countReducer, {
121 | count: initialCount,
122 | })
123 | const {count} = state
124 | const increment = () =>
125 | setState(currentState => ({count: currentState.count + step}))
126 | ```
127 |
128 | ### 4. 💯 traditional dispatch object with a type and switch statement
129 |
130 | [Production deploy](https://advanced-react-hooks.netlify.com/isolated/final/01.extra-4.js)
131 |
132 | Ok, now we can finally see what most people do conventionally (mostly thanks to
133 | redux). Update your reducer so I can do this:
134 |
135 | ```javascript
136 | const [state, dispatch] = React.useReducer(countReducer, {
137 | count: initialCount,
138 | })
139 | const {count} = state
140 | const increment = () => dispatch({type: 'INCREMENT', step})
141 | ```
142 |
143 | ## 🦉 Other notes
144 |
145 | ### lazy initialization
146 |
147 | This one's not an extra credit, but _sometimes_ lazy initialization can be
148 | useful, so here's how we'd do that with our original hook App:
149 |
150 | ```javascript
151 | function init(initialStateFromProps) {
152 | return {
153 | pokemon: null,
154 | loading: false,
155 | error: null,
156 | }
157 | }
158 |
159 | // ...
160 |
161 | const [state, dispatch] = React.useReducer(reducer, props.initialState, init)
162 | ```
163 |
164 | So, if you pass a third function argument to `useReducer`, it passes the second
165 | argument to that function and uses the return value for the initial state.
166 |
167 | This could be useful if our `init` function read into localStorage or something
168 | else that we wouldn't want happening every re-render.
169 |
170 | ### The full `useReducer` API
171 |
172 | If you're into TypeScript, here's some type definitions for `useReducer`:
173 |
174 | > Thanks to [Trey's blog post](https://levelup.gitconnected.com/db1858d1fb9c)
175 |
176 | > Please don't spend too much time reading through this by the way!
177 |
178 | ```typescript
179 | type Dispatch = (value: A) => void
180 | type Reducer = (prevState: S, action: A) => S
181 | type ReducerState> = R extends Reducer
182 | ? S
183 | : never
184 | type ReducerAction> = R extends Reducer<
185 | any,
186 | infer A
187 | >
188 | ? A
189 | : never
190 |
191 | function useReducer, I>(
192 | reducer: R,
193 | initializerArg: I & ReducerState,
194 | initializer: (arg: I & ReducerState) => ReducerState,
195 | ): [ReducerState, Dispatch>]
196 |
197 | function useReducer, I>(
198 | reducer: R,
199 | initializerArg: I,
200 | initializer: (arg: I) => ReducerState,
201 | ): [ReducerState, Dispatch>]
202 |
203 | function useReducer>(
204 | reducer: R,
205 | initialState: ReducerState,
206 | initializer?: undefined,
207 | ): [ReducerState, Dispatch>]
208 | ```
209 |
210 | `useReducer` is pretty versatile. The key takeaway here is that while
211 | conventions are useful, understanding the API and its capabilities is more
212 | important.
213 |
214 | ## 🦉 Feedback
215 |
216 | Fill out
217 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=01%3A%20useReducer%3A%20simple%20Counter&em=).
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 |
120 | )
121 | }
122 |
123 | function AppWithUnmountCheckbox() {
124 | const [mountApp, setMountApp] = React.useState(true)
125 | return (
126 |
127 |
128 | setMountApp(e.target.checked)}
132 | />{' '}
133 | Mount Component
134 |
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 | onSelect(pokemonName)}
83 | >
84 | {pokemonName}
85 |
86 |
87 | ))}
88 |
89 |
90 | )
91 | }
92 |
93 | function PokemonSection({onSelect, pokemonName}) {
94 | // 🐨 wrap this in the PokemonCacheProvider so the PreviousPokemon
95 | // and PokemonInfo components have access to that context.
96 | return (
97 |
98 |
99 |
100 |
onSelect('')}
102 | resetKeys={[pokemonName]}
103 | >
104 |
105 |
106 |
107 |
108 | )
109 | }
110 |
111 | function App() {
112 | const [pokemonName, setPokemonName] = React.useState(null)
113 |
114 | function handleSubmit(newPokemonName) {
115 | setPokemonName(newPokemonName)
116 | }
117 |
118 | function handleSelect(newPokemonName) {
119 | setPokemonName(newPokemonName)
120 | }
121 |
122 | return (
123 |
128 | )
129 | }
130 |
131 | export default App
132 |
--------------------------------------------------------------------------------
/src/exercise/03.js:
--------------------------------------------------------------------------------
1 | // useContext: simple Counter
2 | // http://localhost:3000/isolated/exercise/03.js
3 |
4 | import * as React from 'react'
5 |
6 | // 🐨 create your CountContext here with React.createContext
7 |
8 | // 🐨 create a CountProvider component here that does this:
9 | // 🐨 get the count state and setCount updater with React.useState
10 | // 🐨 create a `value` array with count and setCount
11 | // 🐨 return your context provider with the value assigned to that array and forward all the other props
12 | // 💰 more specifically, we need the children prop forwarded to the context provider
13 |
14 | function CountDisplay() {
15 | // 🐨 get the count from useContext with the CountContext
16 | const count = 0
17 | return {`The current count is ${count}`}
18 | }
19 |
20 | function Counter() {
21 | // 🐨 get the setCount from useContext with the CountContext
22 | const setCount = () => {}
23 | const increment = () => setCount(c => c + 1)
24 | return Increment count
25 | }
26 |
27 | function App() {
28 | return (
29 |
30 | {/*
31 | 🐨 wrap these two components in the CountProvider so they can access
32 | the CountContext value
33 | */}
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | export default App
41 |
--------------------------------------------------------------------------------
/src/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 | add message
56 | remove message
57 |
58 |
59 |
60 |
61 |
62 | )
63 | }
64 |
65 | export default App
66 |
67 | const allMessages = [
68 | `Leia: Aren't you a little short to be a stormtrooper?`,
69 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`,
70 | `Leia: You're who?`,
71 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`,
72 | `Leia: Ben Kenobi is here! Where is he?`,
73 | `Luke: Come on!`,
74 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`,
75 | `Leia: Put that thing away! You're going to get us all killed.`,
76 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`,
77 | `Leia: It could be worse...`,
78 | `Han: It's worse.`,
79 | `Luke: There's something alive in here!`,
80 | `Han: That's your imagination.`,
81 | `Luke: Something just 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 | add message
55 | remove message
56 |
57 |
58 |
59 | scroll to top
60 |
61 |
62 |
63 | scroll to bottom
64 |
65 |
66 | )
67 | }
68 |
69 | export default App
70 |
71 | const allMessages = [
72 | `Leia: Aren't you a little short to be a stormtrooper?`,
73 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`,
74 | `Leia: You're who?`,
75 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`,
76 | `Leia: Ben Kenobi is here! Where is he?`,
77 | `Luke: Come on!`,
78 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`,
79 | `Leia: Put that thing away! You're going to get us all killed.`,
80 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`,
81 | `Leia: It could be worse...`,
82 | `Han: It's worse.`,
83 | `Luke: There's something alive in here!`,
84 | `Han: That's your imagination.`,
85 | `Luke: Something just moves past my leg! Look! Did you see that?`,
86 | `Han: What?`,
87 | `Luke: Help!`,
88 | `Han: Luke! Luke! Luke!`,
89 | `Leia: Luke!`,
90 | `Leia: Luke, Luke, grab a hold of this.`,
91 | `Luke: Blast it, will you! My gun's jammed.`,
92 | `Han: Where?`,
93 | `Luke: Anywhere! Oh!!`,
94 | `Han: Luke! Luke!`,
95 | `Leia: Grab him!`,
96 | `Leia: What happened?`,
97 | `Luke: I don't know, it just let go of me and disappeared...`,
98 | `Han: I've got a very bad feeling about this.`,
99 | `Luke: The walls are moving!`,
100 | `Leia: Don't just stand there. Try to brace it with something.`,
101 | `Luke: Wait a minute!`,
102 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`,
103 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]}))
104 |
--------------------------------------------------------------------------------
/src/exercise/05.md:
--------------------------------------------------------------------------------
1 | # useImperativeHandle: scroll to top/bottom
2 |
3 | ## 📝 Your Notes
4 |
5 | Elaborate on your learnings here in `src/exercise/05.md`
6 |
7 | ## Background
8 |
9 | When we had class components, we could do stuff like this:
10 |
11 | ```javascript
12 | class MyInput extends React.Component {
13 | _inputRef = React.createRef()
14 | focusInput = () => this._inputRef.current.focus()
15 | render() {
16 | return
17 | }
18 | }
19 |
20 | class App extends React.Component {
21 | _myInputRef = React.createRef()
22 | handleClick = () => this._myInputRef.current.focusInput()
23 | render() {
24 | return (
25 |
26 | Focus on the input
27 |
28 |
29 | )
30 | }
31 | }
32 | ```
33 |
34 | The key I want to point out in the example here is that bit above that says:
35 | ` `. What this does is give you access to the
36 | component instance.
37 |
38 | With function components, there is no component instance, so this won't work:
39 |
40 | ```javascript
41 | function MyInput() {
42 | const inputRef = React.useRef()
43 | const focusInput = () => inputRef.current.focus()
44 | // where do I put the focusInput method??
45 | return
46 | }
47 | ```
48 |
49 | You'll actually get an error if you try to pass a `ref` prop to a function
50 | component. So how do we solve this? Well, React has had this feature called
51 | `forwardRef` for quite a while. So we could do that:
52 |
53 | ```javascript
54 | const MyInput = React.forwardRef(function MyInput(props, ref) {
55 | const inputRef = React.useRef()
56 | ref.current = {
57 | focusInput: () => inputRef.current.focus(),
58 | }
59 | return
60 | })
61 | ```
62 |
63 | This actually works, however there are some edge case bugs with this approach
64 | when applied in React's future concurrent mode/suspense feature (also it doesn't
65 | support callback refs). So instead, we'll use the `useImperativeHandle` hook to
66 | do this:
67 |
68 | ```javascript
69 | const MyInput = React.forwardRef(function MyInput(props, ref) {
70 | const inputRef = React.useRef()
71 | React.useImperativeHandle(ref, () => {
72 | return {
73 | focusInput: () => inputRef.current.focus(),
74 | }
75 | })
76 | return
77 | })
78 | ```
79 |
80 | This allows us to expose imperative methods to developers who pass a ref prop to
81 | our component which can be useful when you have something that needs to happen
82 | and is hard to deal with declaratively.
83 |
84 | > NOTE: most of the time you should not need `useImperativeHandle`. Before you
85 | > reach for it, really ask yourself whether there's ANY other way to accomplish
86 | > what you're trying to do. Imperative code can sometimes be really hard to
87 | > follow and it's much better to make your APIs declarative if possible. For
88 | > more on this, read
89 | > [Imperative vs Declarative Programming](https://tylermcginnis.com/imperative-vs-declarative-programming/)
90 |
91 | ## Exercise
92 |
93 | Production deploys:
94 |
95 | - [Exercise](https://advanced-react-hooks.netlify.com/isolated/exercise/05.js)
96 | - [Final](https://advanced-react-hooks.netlify.com/isolated/final/05.js)
97 |
98 | For this exercise, we're going to use the simulated chat from the last exercise,
99 | except we've added scroll to top and scroll to bottom buttons. Your job is to
100 | expose the imperative methods `scrollToTop` and `scrollToBottom` on a ref so the
101 | parent component can call those directly.
102 |
103 | ## 🦉 Feedback
104 |
105 | Fill out
106 | [the feedback form](https://ws.kcd.im/?ws=Advanced%20React%20Hooks%20%F0%9F%94%A5&e=05%3A%20useImperativeHandle%3A%20scroll%20to%20top%2Fbottom&em=).
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 {count}
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 {count}
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 {count}
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 {count}
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 {count}
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 |
104 | )
105 | }
106 |
107 | function AppWithUnmountCheckbox() {
108 | const [mountApp, setMountApp] = React.useState(true)
109 | return (
110 |
111 |
112 | setMountApp(e.target.checked)}
116 | />{' '}
117 | Mount Component
118 |
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 |
114 | )
115 | }
116 |
117 | function AppWithUnmountCheckbox() {
118 | const [mountApp, setMountApp] = React.useState(true)
119 | return (
120 |
121 |
122 | setMountApp(e.target.checked)}
126 | />{' '}
127 | Mount Component
128 |
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 |
139 | )
140 | }
141 |
142 | function AppWithUnmountCheckbox() {
143 | const [mountApp, setMountApp] = React.useState(true)
144 | return (
145 |
146 |
147 | setMountApp(e.target.checked)}
151 | />{' '}
152 | Mount Component
153 |
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 |
108 | )
109 | }
110 |
111 | function AppWithUnmountCheckbox() {
112 | const [mountApp, setMountApp] = React.useState(true)
113 | return (
114 |
115 |
116 | setMountApp(e.target.checked)}
120 | />{' '}
121 | Mount Component
122 |
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 Increment count
32 | }
33 |
34 | function App() {
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | export default App
46 |
--------------------------------------------------------------------------------
/src/final/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 | onSelect(pokemonName)}
93 | >
94 | {pokemonName}
95 |
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 |
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 Increment count
25 | }
26 |
27 | function App() {
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 | )
36 | }
37 |
38 | export default App
39 |
--------------------------------------------------------------------------------
/src/final/04.js:
--------------------------------------------------------------------------------
1 | // useLayoutEffect: auto-scrolling textarea
2 | // http://localhost:3000/isolated/final/04.js
3 |
4 | import * as React from 'react'
5 |
6 | function MessagesDisplay({messages}) {
7 | const containerRef = React.useRef()
8 | React.useLayoutEffect(() => {
9 | containerRef.current.scrollTop = containerRef.current.scrollHeight
10 | })
11 |
12 | return (
13 |
14 | {messages.map((message, index, array) => (
15 |
16 | {message.author} : {message.content}
17 | {array.length - 1 === index ? null :
}
18 |
19 | ))}
20 |
21 | )
22 | }
23 |
24 | // this is to simulate major computation/big rendering tree/etc.
25 | function sleep(time = 0) {
26 | const wakeUpTime = Date.now() + time
27 | while (Date.now() < wakeUpTime) {}
28 | }
29 |
30 | function SlooooowSibling() {
31 | // try this with useLayoutEffect as well to see
32 | // how it impacts interactivity of the page before updates.
33 | React.useEffect(() => {
34 | // increase this number to see a more stark difference
35 | sleep(300)
36 | })
37 | return null
38 | }
39 |
40 | function App() {
41 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8))
42 | const addMessage = () =>
43 | messages.length < allMessages.length
44 | ? setMessages(allMessages.slice(0, messages.length + 1))
45 | : null
46 | const removeMessage = () =>
47 | messages.length > 0
48 | ? setMessages(allMessages.slice(0, messages.length - 1))
49 | : null
50 |
51 | return (
52 |
53 |
54 | add message
55 | remove message
56 |
57 |
58 |
59 |
60 |
61 | )
62 | }
63 |
64 | export default App
65 |
66 | const allMessages = [
67 | `Leia: Aren't you a little short to be a stormtrooper?`,
68 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`,
69 | `Leia: You're who?`,
70 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`,
71 | `Leia: Ben Kenobi is here! Where is he?`,
72 | `Luke: Come on!`,
73 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`,
74 | `Leia: Put that thing away! You're going to get us all killed.`,
75 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`,
76 | `Leia: It could be worse...`,
77 | `Han: It's worse.`,
78 | `Luke: There's something alive in here!`,
79 | `Han: That's your imagination.`,
80 | `Luke: Something just moves past my leg! Look! Did you see that?`,
81 | `Han: What?`,
82 | `Luke: Help!`,
83 | `Han: Luke! Luke! Luke!`,
84 | `Leia: Luke!`,
85 | `Leia: Luke, Luke, grab a hold of this.`,
86 | `Luke: Blast it, will you! My gun's jammed.`,
87 | `Han: Where?`,
88 | `Luke: Anywhere! Oh!!`,
89 | `Han: Luke! Luke!`,
90 | `Leia: Grab him!`,
91 | `Leia: What happened?`,
92 | `Luke: I don't know, it just let go of me and disappeared...`,
93 | `Han: I've got a very bad feeling about this.`,
94 | `Luke: The walls are moving!`,
95 | `Leia: Don't just stand there. Try to brace it with something.`,
96 | `Luke: Wait a minute!`,
97 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`,
98 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]}))
99 |
--------------------------------------------------------------------------------
/src/final/05.js:
--------------------------------------------------------------------------------
1 | // useImperativeHandle: scroll to top/bottom
2 | // http://localhost:3000/isolated/final/05.js
3 |
4 | import * as React from 'react'
5 |
6 | const MessagesDisplay = React.forwardRef(function MessagesDisplay(
7 | {messages},
8 | ref,
9 | ) {
10 | const containerRef = React.useRef()
11 | React.useLayoutEffect(() => {
12 | scrollToBottom()
13 | })
14 | function scrollToTop() {
15 | containerRef.current.scrollTop = 0
16 | }
17 | function scrollToBottom() {
18 | containerRef.current.scrollTop = containerRef.current.scrollHeight
19 | }
20 | React.useImperativeHandle(ref, () => ({
21 | scrollToTop,
22 | scrollToBottom,
23 | }))
24 |
25 | return (
26 |
27 | {messages.map((message, index, array) => (
28 |
29 | {message.author} : {message.content}
30 | {array.length - 1 === index ? null :
}
31 |
32 | ))}
33 |
34 | )
35 | })
36 |
37 | function App() {
38 | const messageDisplayRef = React.useRef()
39 | const [messages, setMessages] = React.useState(allMessages.slice(0, 8))
40 | const addMessage = () =>
41 | messages.length < allMessages.length
42 | ? setMessages(allMessages.slice(0, messages.length + 1))
43 | : null
44 | const removeMessage = () =>
45 | messages.length > 0
46 | ? setMessages(allMessages.slice(0, messages.length - 1))
47 | : null
48 |
49 | const scrollToTop = () => messageDisplayRef.current.scrollToTop()
50 | const scrollToBottom = () => messageDisplayRef.current.scrollToBottom()
51 |
52 | return (
53 |
54 |
55 | add message
56 | remove message
57 |
58 |
59 |
60 | scroll to top
61 |
62 |
63 |
64 | scroll to bottom
65 |
66 |
67 | )
68 | }
69 |
70 | export default App
71 |
72 | const allMessages = [
73 | `Leia: Aren't you a little short to be a stormtrooper?`,
74 | `Luke: What? Oh... the uniform. I'm Luke Skywalker. I'm here to rescue you.`,
75 | `Leia: You're who?`,
76 | `Luke: I'm here to rescue you. I've got your R2 unit. I'm here with Ben Kenobi.`,
77 | `Leia: Ben Kenobi is here! Where is he?`,
78 | `Luke: Come on!`,
79 | `Luke: Will you forget it? I already tried it. It's magnetically sealed!`,
80 | `Leia: Put that thing away! You're going to get us all killed.`,
81 | `Han: Absolutely, Your Worship. Look, I had everything under control until you led us down here. You know, it's not going to take them long to figure out what happened to us.`,
82 | `Leia: It could be worse...`,
83 | `Han: It's worse.`,
84 | `Luke: There's something alive in here!`,
85 | `Han: That's your imagination.`,
86 | `Luke: Something just moves past my leg! Look! Did you see that?`,
87 | `Han: What?`,
88 | `Luke: Help!`,
89 | `Han: Luke! Luke! Luke!`,
90 | `Leia: Luke!`,
91 | `Leia: Luke, Luke, grab a hold of this.`,
92 | `Luke: Blast it, will you! My gun's jammed.`,
93 | `Han: Where?`,
94 | `Luke: Anywhere! Oh!!`,
95 | `Han: Luke! Luke!`,
96 | `Leia: Grab him!`,
97 | `Leia: What happened?`,
98 | `Luke: I don't know, it just let go of me and disappeared...`,
99 | `Han: I've got a very bad feeling about this.`,
100 | `Luke: The walls are moving!`,
101 | `Leia: Don't just stand there. Try to brace it with something.`,
102 | `Luke: Wait a minute!`,
103 | `Luke: Threepio! Come in Threepio! Threepio! Where could he be?`,
104 | ].map((m, i) => ({id: i, author: m.split(': ')[0], content: m.split(': ')[1]}))
105 |
--------------------------------------------------------------------------------
/src/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 |
84 |
85 |
86 |
87 | {pokemon.name}
88 | {pokemon.number}
89 |
90 |
91 |
92 |
93 | {pokemon.attacks.special.map(attack => (
94 |
95 | {attack.name} :{' '}
96 |
97 | {attack.damage} ({attack.type})
98 |
99 |
100 | ))}
101 |
102 |
103 |
{pokemon.fetchedAt}
104 |
105 | )
106 | }
107 |
108 | function PokemonForm({
109 | pokemonName: externalPokemonName,
110 | initialPokemonName = externalPokemonName || '',
111 | onSubmit,
112 | }) {
113 | const [pokemonName, setPokemonName] = React.useState(initialPokemonName)
114 |
115 | // this is generally not a great idea. We're synchronizing state when it is
116 | // normally better to derive it https://kentcdodds.com/blog/dont-sync-state-derive-it
117 | // however, we're doing things this way to make it easier for the exercises
118 | // to not have to worry about the logic for this PokemonForm component.
119 | React.useEffect(() => {
120 | // note that because it's a string value, if the externalPokemonName
121 | // is the same as the one we're managing, this will not trigger a re-render
122 | if (typeof externalPokemonName === 'string') {
123 | setPokemonName(externalPokemonName)
124 | }
125 | }, [externalPokemonName])
126 |
127 | function handleChange(e) {
128 | setPokemonName(e.target.value)
129 | }
130 |
131 | function handleSubmit(e) {
132 | e.preventDefault()
133 | onSubmit(pokemonName)
134 | }
135 |
136 | function handleSelect(newPokemonName) {
137 | setPokemonName(newPokemonName)
138 | onSubmit(newPokemonName)
139 | }
140 |
141 | return (
142 |
184 | )
185 | }
186 |
187 | function ErrorFallback({error, resetErrorBoundary}) {
188 | return (
189 |
190 | There was an error:{' '}
191 |
{error.message}
192 |
Try again
193 |
194 | )
195 | }
196 |
197 | function PokemonErrorBoundary(props) {
198 | return
199 | }
200 |
201 | export {
202 | PokemonInfoFallback,
203 | PokemonForm,
204 | PokemonDataView,
205 | fetchPokemon,
206 | PokemonErrorBoundary,
207 | }
208 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------