├── .all-contributorsrc ├── .env ├── .eslintignore ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── OUTLINE.md ├── README.md ├── appveyor.yml ├── jsconfig.json ├── package.json ├── public ├── _headers ├── _redirects ├── antic-slab.woff2 ├── img │ ├── pokeball.png │ ├── pokemon-cafe.jpg │ ├── pokemon │ │ ├── bulbasaur.jpg │ │ ├── charizard.jpg │ │ ├── ditto.jpg │ │ ├── fallback-pokemon.jpg │ │ ├── mew.jpg │ │ ├── mewtwo.jpg │ │ └── pikachu.jpg │ ├── pokemongo.jpg │ └── squirtle-toy.jpg ├── index.html ├── manifest.json └── serve.json ├── sandbox.config.json ├── scripts ├── autofill-feedback-email.js ├── setup.js └── workshop-setup.js ├── src ├── .eslintrc ├── @types │ └── global.d.ts ├── __tests__ │ └── 01.js ├── app.js ├── examples │ ├── fetch-approaches │ │ ├── fetch-on-render.js │ │ ├── fetch-then-render.js │ │ ├── lazy │ │ │ ├── pokemon-info-fetch-on-render.js │ │ │ ├── pokemon-info-fetch-then-render.js │ │ │ ├── pokemon-info-render-as-you-fetch.data.js │ │ │ └── pokemon-info-render-as-you-fetch.js │ │ └── render-as-you-fetch.js │ └── preload-image.js ├── exercises-final │ ├── 01-extra.1.js │ ├── 01-extra.2.js │ ├── 01-extra.3.js │ ├── 01.js │ ├── 02-extra.1.js │ ├── 02.js │ ├── 03-extra.1.js │ ├── 03-extra.2.js │ ├── 03.js │ ├── 04.js │ ├── 05-extra.1.js │ ├── 05.js │ ├── 06.js │ ├── 07-extra.1.js │ └── 07.js ├── exercises │ ├── 01.js │ ├── 01.md │ ├── 02.js │ ├── 02.md │ ├── 03.js │ ├── 03.md │ ├── 04.js │ ├── 04.md │ ├── 05.js │ ├── 05.md │ ├── 06.js │ ├── 06.md │ ├── 07.js │ └── 07.md ├── fetch-pokemon.js ├── hacks │ ├── fetch.js │ ├── index.js │ ├── pokemon.json │ ├── transactions.json │ └── users.json ├── index.js ├── load-exercises.js ├── setupTests.js ├── styles.css ├── suspense-list │ ├── app.module.css │ ├── img.js │ ├── left-nav.js │ ├── left-nav.module.css │ ├── main-content.js │ ├── main-content.module.css │ ├── nav-bar.js │ ├── nav-bar.module.css │ ├── right-nav.js │ ├── right-nav.module.css │ ├── spinner.js │ ├── spinner.module.css │ └── style-overrides.css └── utils.js └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "concurrent-react", 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 | "repoHost": "https://github.com", 25 | "contributorsPerLine": 7 26 | } 27 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | build 4 | scripts/workshop-setup.js 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | build 4 | .idea/ 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | build 4 | scripts/workshop-setup.js 5 | src/babel.js 6 | src/react-dom.development.js 7 | src/react.development.js 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": false, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always" 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: stable 4 | install: echo "Installation happens in the setup script" 5 | cache: 6 | directories: 7 | - node_modules 8 | notifications: 9 | email: false 10 | branches: 11 | only: 12 | - master 13 | script: 14 | - npm run setup 15 | after_success: 16 | - npx codecov 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of 9 | experience, nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, and other contributions that are 42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at kent@doddsfamily.us. All complaints 59 | will be reviewed and investigated and will result in a response that is deemed 60 | necessary and appropriate to the circumstances. The project team is obligated to 61 | maintain confidentiality with regard to the reporter of an incident. Further 62 | details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 71 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /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 `master` 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/concurrent-react.git 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/master master 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 `master` 25 | > branch to use the upstream master branch whenever you run `git pull`. Then you 26 | > can make all of your pull request branches based on this `master` branch. 27 | > Whenever you want to update your version of `master`, do a regular `git pull`. 28 | 29 | ## Committing and Pushing changes 30 | 31 | Please make sure to run the tests before you commit your changes. You can run 32 | `npm run test` and press `u` which will update any snapshots that need updating. 33 | Make sure to include those changes (if they exist) in your commit. 34 | 35 | ## Help needed 36 | 37 | Please checkout the [the open issues][issues] 38 | 39 | Also, please watch the repo and respond to questions/bug reports/feature 40 | requests! Thanks! 41 | 42 | [egghead]: 43 | https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github 44 | [issues]: https://github.com/kentcdodds/concurrent-react/issues 45 | -------------------------------------------------------------------------------- /OUTLINE.md: -------------------------------------------------------------------------------- 1 | # Concurrent React 2 | 3 | > Concurrent React Workshop 🔀 4 | 5 | 👋 I'm Kent C. Dodds 6 | 7 | - 🏡 Utah 8 | - 👩 👧 👦 👦 👦 🐕 9 | - 🏢 kentcdodds.com 10 | - 🐦/🐙 @kentcdodds 11 | - 🏆 testingjavascript.com 12 | - 🥚 kcd.im/egghead 13 | - 🥋 kcd.im/fem 14 | - 💌 kcd.im/news 15 | - 📝 kcd.im/blog 16 | - 📺 kcd.im/devtips 17 | - 💻 kcd.im/coding 18 | - 📽 kcd.im/youtube 19 | - 🎙 kcd.im/3-mins 20 | - ❓ kcd.im/ama 21 | 22 | # What this workshop is 23 | 24 | 1. Exercises to prepare your brain to learn 25 | 2. Instruction for you to ask questions 26 | 27 | # What this workshop is not 28 | 29 | - Solo 30 | - Lecture 31 | - One day (do it all again next week) 32 | 33 | # Logistics 34 | 35 | ## Schedule 36 | 37 | - 😴 Logistics 38 | - 🏋 Simple Data-fetching 39 | - 😴 10 Minutes 40 | - 🏋 Render as you fetch 41 | - 🏋 useTransition for improved loading states 42 | - 😴 30 Minutes 43 | - 🏋 Suspense Image 44 | - 🏋 Cache resources 45 | - 😴 10 Minutes 46 | - 🏋 Suspense with a custom hook 47 | - 🏋 Coordinate Suspending components with SuspenseList 48 | - ❓ Q&A 49 | 50 | ## Scripts 51 | 52 | - `npm run start` 53 | - `npm run test` (not ready yet). 54 | 55 | ## Asking Questions 56 | 57 | Please do ask! Interrupt me. If you have an unrelated question, please ask on 58 | [my AMA](https://kcd.im/ama). 59 | 60 | ## Zoom 61 | 62 | - Help us make this more human by keeping your video on if possible 63 | - Keep microphone muted unless speaking 64 | - Breakout rooms 65 | 66 | ## Exercises 67 | 68 | - `src/exercises/0x.md`: Background, Exercise Instructions, Extra Credit 69 | - `src/exercises/0x.js`: Exercise with Emoji helpers 70 | - `src/__tests__/0x.js`: Tests 71 | - `src/exercises-final/0x.js`: Final version 72 | 73 | > NOTE: Some of the extra credit have tests that are specific to their 74 | > implementation because the implementation is significantly different and your 75 | > work needs to be checked differently. 76 | 77 | ## Emoji 78 | 79 | - **Kody the Koala Bear** 🐨 "Do this" 80 | - **Marty the Money Bag** 💰 "Here's a hint" 81 | - **Hannah the Hundred** 💯 "Extra Credit" 82 | - **Olivia the Owl** 🦉 "Pro-tip" 83 | - **Dominic the Document** 📜 "Docs links" 84 | - **Berry the Bomb** 💣 "Remove this code" 85 | - **Peter the Product Manager** 👨‍💼 "Story time" 86 | - **Alfred the Alert** 🚨 "Extra helpful in test errors" 87 | 88 | ## Disclaimers 89 | 90 | 1. React Concurrent Mode is experimental 91 | 2. I've never shipped Concurrent Mode to production, and you shouldn't either 92 | (yet) 93 | 3. React Suspense is a particularly primitive API and we're still working out 94 | good abstractions for it 95 | 4. I've been informed that the API for suspending _will change_ before the 96 | stable release (don't worry though, the concepts are solid) 97 | 5. I will probably say "I don't know" as a response to your questions sometimes 98 | 99 | ## Workshop Feedback 100 | 101 | Each exercise has an Elaboration and Feedback link. Please fill that out after 102 | the exercise and instruction. 103 | 104 | At the end of the workshop, please go to this URL to give overall feedback. 105 | Thank you! https://kcd.im/rs-ws-feedback 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Concurrent React 2 | 3 | > Improving UX with a faster, more predictable app. 4 | 5 | > A NEW version of this project is available at: 6 | > https://github.com/kentcdodds/react-suspense 7 | 8 | 👋 hi there! My name is [Kent C. Dodds](https://kentcdodds.com)! This is a 9 | workshop repo to teach you the fundamentals of React's (EXPERIMENTAL) 10 | [concurrent mode](https://reactjs.org/concurrent). This feature enables React to 11 | make your app faster out of the box and it comes along with a few features that 12 | you can use to improve your app's user experience (most notably the concept of 13 | "Suspense"). 14 | 15 | [![Build Status][build-badge]][build] 16 | [![AppVeyor Build Status][win-build-badge]][win-build] 17 | [![Code Coverage][coverage-badge]][coverage] 18 | [![GPL 3.0 License][license-badge]][license] 19 | [![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) 20 | [![PRs Welcome][prs-badge]][prs] [![Code of Conduct][coc-badge]][coc] 21 | 22 | ## ⚠️ Warning ⚠️ 23 | 24 | This workshop material deals with **EXPERIMENTAL** features in React. Please do 25 | not copy/paste any of the code you find here into a production application and 26 | expect it to work. Even when the features are released they may not work the 27 | same as demonstrated in this workshop material. 28 | 29 | That said, the concepts in this workshop will very likely be applicable when 30 | these features are stable, so enjoy the workshop! 31 | 32 | ## Pre-Workshop Instructions/Requirements 33 | 34 | In order for us to maximize our efforts during the workshop, please do the 35 | following: 36 | 37 | - [ ] Setup the project (follow the setup instructions below) (~5 minutes) 38 | - [ ] Install and setup [Zoom](https://zoom.us) on the computer you will be 39 | using (~5 minutes) 40 | - [ ] Install the React DevTools 41 | ([Chrome](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) 42 | (recommended), 43 | [Firefox](https://addons.mozilla.org/en-US/firefox/addon/react-devtools/)) 44 | - [ ] Watch 45 | [Use Zoom for KCD Workshops](https://egghead.io/lessons/egghead-use-zoom-for-kcd-workshops) 46 | (~8 minutes). 47 | - [ ] Watch 48 | [Setup and Logistics for KCD Workshops](https://egghead.io/lessons/egghead-setup-and-logistics-for-kcd-workshops) 49 | (~24 minutes). Please do NOT skip this step. 50 | - [ ] Watch Dan Abramov's talk 51 | [Beyond React 16 | JSConf Iceland 2018](https://www.youtube.com/watch?v=nLF0n9SACd4) 52 | (33 minutes) 53 | - [ ] Go through my 54 | [Learn React Hooks Workshop](https://kentcdodds.com/workshops/hooks), or 55 | have the equivalent basic experience of using hooks. You should be 56 | experienced with `useState`, `useEffect`, and `useRef`. 57 | - [ ] Go through my 58 | [Advanced React Hooks Workshop](https://kentcdodds.com/workshops/advanced-react-hooks), 59 | or have the equivalent experience. You should be experienced with 60 | `useContext` and `useReducer` (experience with `useMemo` and `useCallback` 61 | is a bonus). 62 | 63 | The more prepared you are for the workshop, the better it will go for you. 64 | 65 | ## Workshop Outline 66 | 67 | Here are the concepts we'll be covering: 68 | 69 | - Opting into React Concurrent Mode 70 | - Thinking in Suspense 71 | - The fundamentals of "suspending" 72 | - Structuring `` components with fallbacks 73 | - Using `useTransition` 74 | - Refactor an existing async interaction to suspense 75 | - The difference between the three data-fetching approaches: 76 | - Fetch-on-Render (not using Suspense) 77 | - Fetch-Then-Render (not using Suspense) 78 | - Render-as-You-Fetch (using Suspense) 79 | - Using `` to coordinate multiple suspending components 80 | 81 | ## System Requirements 82 | 83 | - [git][git] v2 or greater 84 | - [NodeJS][node] v8 or greater 85 | - [yarn][yarn] v1 or greater (or [npm][npm] v6 or greater) 86 | 87 | All of these must be available in your `PATH`. To verify things are set up 88 | properly, you can run this: 89 | 90 | ```shell 91 | git --version 92 | node --version 93 | yarn --version # or npm --version 94 | ``` 95 | 96 | If you have trouble with any of these, learn more about the PATH environment 97 | variable and how to fix it here for [windows][win-path] or 98 | [mac/linux][mac-path]. 99 | 100 | ## Setup 101 | 102 | For many of my workshops, you should be able to run them 103 | [entirely in the browser](https://codesandbox.io/s/github/kentcdodds/concurrent-react). 104 | However for this one, I recommend you work through the workshop on your own 105 | computer. 106 | 107 | To do so, please follow these instructions. 108 | 109 | After you've made sure to have the correct things (and versions) installed (as 110 | indicated above), you should be able to just run a few commands to get set up: 111 | 112 | ``` 113 | git clone https://github.com/kentcdodds/concurrent-react.git 114 | cd concurrent-react 115 | npm run setup --silent 116 | ``` 117 | 118 | This may take a few minutes. **It will ask you for your email.** This is 119 | optional and just automatically adds your email to the links in the project to 120 | make filling out some forms easier If you get any errors, please read through 121 | them and see if you can find out what the problem is. You may also want to look 122 | at [Troubleshooting](#troubleshooting). If you can't work it out on your own 123 | then please [file an issue][issue] and provide _all_ the output from the 124 | commands you ran (even if it's a lot). 125 | 126 | ## Running the app 127 | 128 | To get the app up and running (and really see if it worked), run: 129 | 130 | ```shell 131 | npm start 132 | ``` 133 | 134 | This should start up your browser. If you're familiar, this is a standard 135 | [react-scripts](https://github.com/facebook/create-react-app) application. 136 | 137 | You can also open 138 | [the deployment of the app on Netlify](https://concurrent-react.netlify.com/). 139 | 140 | ## Running the tests 141 | 142 | ```shell 143 | npm test 144 | ``` 145 | 146 | This will start [Jest](http://facebook.github.io/jest) in watch mode. Read the 147 | output and play around with it. 148 | 149 | **Your goal will be to go into each test, swap the final version for the 150 | exercise version in the import, and make the tests pass** 151 | 152 | ## Helpful Emoji 🐨 💰 💯 🦉 📜 💣 🚨 153 | 154 | Each exercise has comments in it to help you get through the exercise. These fun 155 | emoji characters are here to help you. 156 | 157 | - **Kody the Koala Bear** 🐨 will tell you when there's something specific you 158 | should do 159 | - **Marty the Money Bag** 💰 will give you specific tips (and sometimes code) 160 | along the way 161 | - **Hannah the Hundred** 💯 will give you extra challenges you can do if you 162 | finish the exercises early. 163 | - **Olivia the Owl** 🦉 will give you useful tidbits/best practice notes and a 164 | link for elaboration and feedback. 165 | - **Dominic the Document** 📜 will give you links to useful documentation 166 | - **Berry the Bomb** 💣 will be hanging around anywhere you need to blow stuff 167 | up (delete code) 168 | - **Peter the Product Manager** 👨‍💼 helps us know what our users want 169 | - **Alfred the Alert** 🚨 will occasionally show up in the test failures with 170 | potential explanations for why the tests are failing. 171 | 172 | ## Troubleshooting 173 | 174 |
175 | 176 | "npm run setup" command not working 177 | 178 | Here's what the setup script does. If it fails, try doing each of these things 179 | individually yourself: 180 | 181 | ``` 182 | # verify your environment will work with the project 183 | node ./scripts/verify 184 | 185 | # install dependencies 186 | npm install 187 | 188 | # verify the project is ready to run 189 | npm run build 190 | npm run test:coverage 191 | npm run lint 192 | 193 | # automatically fill in your email for the feedback links. 194 | node ./scripts/autofill-feedback-email.js 195 | ``` 196 | 197 | If any of those scripts fail, please try to work out what went wrong by the 198 | error message you get. If you still can't work it out, feel free to [open an 199 | issue][issue] with _all_ the output from that script. I will try to help if I 200 | can. 201 | 202 |
203 | 204 | ## Contributors 205 | 206 | Thanks goes to these wonderful people 207 | ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 |
Kent C. Dodds
Kent C. Dodds

💻 📖 🚇 ⚠️
217 | 218 | 219 | 220 | 221 | 222 | 223 | This project follows the 224 | [all-contributors](https://github.com/kentcdodds/all-contributors) 225 | specification. Contributions of any kind welcome! 226 | 227 | ## License 228 | 229 | This material is available for private, non-commercial use under the 230 | [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html). If you 231 | would like to use this material to conduct your own workshop, please contact me 232 | at kent@doddsfamily.us 233 | 234 | [npm]: https://www.npmjs.com/ 235 | [node]: https://nodejs.org 236 | [git]: https://git-scm.com/ 237 | [yarn]: https://yarnpkg.com/ 238 | [build-badge]: 239 | https://img.shields.io/travis/com/kentcdodds/concurrent-react.svg?style=flat-square&logo=travis 240 | [build]: https://travis-ci.com/kentcdodds/concurrent-react 241 | [license-badge]: 242 | https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square 243 | [license]: 244 | https://github.com/kentcdodds/concurrent-react/blob/master/README.md#license 245 | [prs-badge]: 246 | https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 247 | [prs]: http://makeapullrequest.com 248 | [coc-badge]: 249 | https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 250 | [coc]: 251 | https://github.com/kentcdodds/concurrent-react/blob/master/CODE_OF_CONDUCT.md 252 | [github-watch-badge]: 253 | https://img.shields.io/github/watchers/kentcdodds/concurrent-react.svg?style=social 254 | [github-watch]: https://github.com/kentcdodds/concurrent-react/watchers 255 | [github-star-badge]: 256 | https://img.shields.io/github/stars/kentcdodds/concurrent-react.svg?style=social 257 | [github-star]: https://github.com/kentcdodds/concurrent-react/stargazers 258 | [twitter]: 259 | https://twitter.com/intent/tweet?text=Check%20out%20concurrent-react%20by%20@kentcdodds%20https://github.com/kentcdodds/concurrent-react%20%F0%9F%91%8D 260 | [twitter-badge]: 261 | https://img.shields.io/twitter/url/https/github.com/kentcdodds/concurrent-react.svg?style=social 262 | [emojis]: https://github.com/kentcdodds/all-contributors#emoji-key 263 | [all-contributors]: https://github.com/kentcdodds/all-contributors 264 | [win-path]: 265 | https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/ 266 | [mac-path]: http://stackoverflow.com/a/24322978/971592 267 | [issue]: https://github.com/kentcdodds/concurrent-react/issues/new 268 | [win-build-badge]: 269 | https://img.shields.io/appveyor/ci/kentcdodds/concurrent-react.svg?style=flat-square&logo=appveyor 270 | [win-build]: https://ci.appveyor.com/project/kentcdodds/concurrent-react 271 | [coverage-badge]: 272 | https://img.shields.io/codecov/c/github/kentcdodds/concurrent-react.svg?style=flat-square 273 | [coverage]: https://codecov.io/github/kentcdodds/concurrent-react 274 | [watchman]: https://facebook.github.io/watchman/docs/install.html 275 | 276 | 277 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - node_version: 'stable' 4 | 5 | branches: 6 | only: 7 | - master 8 | 9 | install: 10 | - ps: Install-Product node $env:node_version 11 | 12 | test_script: 13 | - npm run setup 14 | 15 | cache: 16 | - ./node_modules -> package.json 17 | 18 | build: off 19 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src" 4 | }, 5 | "include": ["src"] 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "concurrent-react", 3 | "title": "Concurrent React 🔀", 4 | "version": "1.0.0", 5 | "description": "Concurrent React Workshop", 6 | "keywords": [], 7 | "homepage": "https://learn-concurrent-react.netlify.com/", 8 | "license": "GPL-3.0-only", 9 | "main": "src/index.js", 10 | "engines": { 11 | "node": ">=8", 12 | "npm": ">=6", 13 | "yarn": ">=1" 14 | }, 15 | "dependencies": { 16 | "@testing-library/jest-dom": "^4.2.4", 17 | "@testing-library/react": "^9.3.2", 18 | "chalk": "^3.0.0", 19 | "glob": "^7.1.6", 20 | "history": "^4.10.1", 21 | "normalize.css": "^8.0.1", 22 | "preval.macro": "^3.0.0", 23 | "react": "0.0.0-experimental-b53ea6ca0", 24 | "react-dom": "0.0.0-experimental-b53ea6ca0", 25 | "react-error-boundary": "^1.2.5", 26 | "react-icons": "^3.8.0", 27 | "react-router-dom": "^5.1.2", 28 | "stop-runaway-react-effects": "^1.2.1" 29 | }, 30 | "devDependencies": { 31 | "cross-spawn": "^7.0.1", 32 | "eslint": "^6.6.0", 33 | "husky": "^3.1.0", 34 | "inquirer": "^7.0.0", 35 | "is-ci": "^2.0.0", 36 | "npm-run-all": "^4.1.5", 37 | "prettier": "^1.19.1", 38 | "react-scripts": "^3.2.0", 39 | "replace-in-file": "^4.2.0", 40 | "serve": "^11.2.0" 41 | }, 42 | "scripts": { 43 | "serve": "serve -s build", 44 | "start": "react-scripts start", 45 | "build": "react-scripts build", 46 | "build:profile": "react-scripts build --profile", 47 | "test": "react-scripts test --env=jsdom", 48 | "test:coverage": "npm run test -- --watchAll=false --coverage", 49 | "setup": "node ./scripts/setup && npm run validate && node ./scripts/autofill-feedback-email.js", 50 | "lint": "eslint .", 51 | "validate": "npm-run-all --parallel build test:coverage lint" 52 | }, 53 | "husky": { 54 | "hooks": { 55 | "pre-commit": "npm run validate" 56 | } 57 | }, 58 | "jest": { 59 | "collectCoverageFrom": [ 60 | "src/exercises-final/**/*.js" 61 | ] 62 | }, 63 | "eslintConfig": { 64 | "extends": "react-app" 65 | }, 66 | "browserslist": { 67 | "development": [ 68 | "last 2 chrome versions", 69 | "last 2 firefox versions", 70 | "last 2 edge versions" 71 | ], 72 | "production": [ 73 | ">1%", 74 | "last 4 versions", 75 | "Firefox ESR", 76 | "not ie < 11" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /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/antic-slab.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/antic-slab.woff2 -------------------------------------------------------------------------------- /public/img/pokeball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokeball.png -------------------------------------------------------------------------------- /public/img/pokemon-cafe.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokemon-cafe.jpg -------------------------------------------------------------------------------- /public/img/pokemon/bulbasaur.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokemon/bulbasaur.jpg -------------------------------------------------------------------------------- /public/img/pokemon/charizard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokemon/charizard.jpg -------------------------------------------------------------------------------- /public/img/pokemon/ditto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokemon/ditto.jpg -------------------------------------------------------------------------------- /public/img/pokemon/fallback-pokemon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokemon/fallback-pokemon.jpg -------------------------------------------------------------------------------- /public/img/pokemon/mew.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokemon/mew.jpg -------------------------------------------------------------------------------- /public/img/pokemon/mewtwo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokemon/mewtwo.jpg -------------------------------------------------------------------------------- /public/img/pokemon/pikachu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokemon/pikachu.jpg -------------------------------------------------------------------------------- /public/img/pokemongo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/pokemongo.jpg -------------------------------------------------------------------------------- /public/img/squirtle-toy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/concurrent-react/cfdcee875fdab85c80adf264a3af51b5e541b824/public/img/squirtle-toy.jpg -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | Concurrent React 🔀 13 | 68 | 69 | 70 | 71 | 74 |
75 | 76 | 77 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /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 | "infiniteLoopProtection": true, 3 | "hardReloadOnChange": false, 4 | "view": "browser" 5 | } 6 | -------------------------------------------------------------------------------- /scripts/autofill-feedback-email.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | const path = require('path') 3 | const inquirer = require('inquirer') 4 | const replace = require('replace-in-file') 5 | const isCI = require('is-ci') 6 | const spawn = require('cross-spawn') 7 | 8 | if (isCI) { 9 | console.log(`Not running autofill feedback as we're on CI`) 10 | } else { 11 | const prompt = inquirer.prompt([ 12 | { 13 | name: 'email', 14 | message: `what's your email address?`, 15 | validate(val) { 16 | if (!val) { 17 | // they don't want to do this... 18 | return true 19 | } else if (!val.includes('@')) { 20 | return 'email requires an @ sign...' 21 | } 22 | return true 23 | }, 24 | }, 25 | ]) 26 | const timeoutId = setTimeout(() => { 27 | console.log( 28 | `\n\nprompt timed out. No worries. Run \`node ./scripts/autofill-feedback-email.js\` if you'd like to try again`, 29 | ) 30 | prompt.ui.close() 31 | }, 15000) 32 | 33 | prompt.then(({email} = {}) => { 34 | clearTimeout(timeoutId) 35 | if (!email) { 36 | console.log(`Not autofilling email because none was provided`) 37 | return 38 | } 39 | const options = { 40 | files: [path.join(__dirname, '..', 'src/**/*.js')], 41 | from: /&em=\r?\n/, 42 | to: `&em=${email}\n`, 43 | } 44 | 45 | replace(options).then( 46 | changedFiles => { 47 | console.log(`Updated ${changedFiles.length} with the email ${email}`) 48 | console.log( 49 | 'committing changes for you so your jest watch mode works nicely', 50 | ) 51 | spawn.sync('git', ['commit', '-am', 'email autofill', '--no-verify'], { 52 | stdio: 'inherit', 53 | }) 54 | }, 55 | error => { 56 | console.error('Failed to update files') 57 | console.error(error.stack) 58 | }, 59 | ) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var pkg = require(path.join(process.cwd(), 'package.json')) 3 | 4 | // if you install it then this should be require('workshop-setup') 5 | // but that... doesn't really make sense. 6 | require('./workshop-setup') 7 | .setup(pkg.engines) 8 | .then( 9 | () => { 10 | console.log(`💯 You're all set up! 👏`) 11 | }, 12 | error => { 13 | console.error(`🚨 There was a problem:`) 14 | console.error(error) 15 | console.error( 16 | `\nIf you would like to just ignore this error, then feel free to do so and install dependencies as you normally would in "${process.cwd()}". Just know that things may not work properly if you do...`, 17 | ) 18 | }, 19 | ) 20 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-func-assign": "off", 4 | "import/no-webpack-loader-syntax": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | -------------------------------------------------------------------------------- /src/__tests__/01.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | // import chalk from 'chalk' 3 | import {render} from '@testing-library/react' 4 | import Usage from '../exercises-final/01' 5 | // import Usage from '../exercises/01' 6 | 7 | test('loads the tile component asynchronously', async () => { 8 | render() 9 | 10 | // try { 11 | // expect(queryByDisplayValue(tilted)).not.toBeInTheDocument() 12 | // } catch (error) { 13 | // // 14 | // // 15 | // // 16 | // // these comment lines are just here to keep the next line out of the codeframe 17 | // // so it doesn't confuse people when they see the error message twice. 18 | // error.message = `🚨 ${chalk.red( 19 | // 'The tilt component must be loaded asynchronously via React.lazy and React.Suspense', 20 | // )}` 21 | 22 | // throw error 23 | // } 24 | }) 25 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | BrowserRouter as Router, 4 | Switch, 5 | Route, 6 | Link, 7 | useParams, 8 | } from 'react-router-dom' 9 | import {createBrowserHistory} from 'history' 10 | import preval from 'preval.macro' 11 | import pkg from '../package.json' 12 | 13 | const {title: projectTitle} = pkg 14 | 15 | if (!projectTitle) { 16 | throw new Error('The package.json must have a title!') 17 | } 18 | 19 | const exerciseInfo = preval`module.exports = require('./load-exercises')` 20 | 21 | for (const infoKey in exerciseInfo) { 22 | const info = exerciseInfo[infoKey] 23 | info.exercise.Component = React.lazy(() => import(`./exercises/${infoKey}`)) 24 | info.final.Component = React.lazy(() => 25 | import(`./exercises-final/${infoKey}`), 26 | ) 27 | } 28 | 29 | const history = createBrowserHistory() 30 | function handleAnchorClick(event) { 31 | if (event.metaKey || event.shiftKey) { 32 | return 33 | } 34 | event.preventDefault() 35 | history.push(event.target.getAttribute('href')) 36 | } 37 | 38 | function ComponentContainer({label, ...props}) { 39 | return ( 40 |
41 |

{label}

42 |
53 |
54 | ) 55 | } 56 | 57 | function ExtraCreditLinks({exerciseId}) { 58 | const {extraCreditTitles} = exerciseInfo[exerciseId] 59 | if (!extraCreditTitles) { 60 | return null 61 | } 62 | 63 | return ( 64 |
65 | {`Extra Credits: `} 66 | {Object.entries(extraCreditTitles).map(([id, title], index, array) => ( 67 | 68 | 72 | {title} 73 | 74 | {array.length - 1 === index ? null : ' | '} 75 | 76 | ))} 77 |
78 | ) 79 | } 80 | 81 | function ExerciseContainer() { 82 | let {exerciseId} = useParams() 83 | const { 84 | title, 85 | exercise, 86 | final, 87 | exercise: {Component: Exercise}, 88 | final: {Component: Final}, 89 | } = exerciseInfo[exerciseId] 90 | return ( 91 |
101 |

{title}

102 | 105 | Exercise 106 | 107 | } 108 | > 109 | 110 | 111 | 114 | Final 115 | 116 | } 117 | > 118 | 119 | 120 | 121 | 122 |
123 | ) 124 | } 125 | 126 | function NavigationFooter({exerciseId, type}) { 127 | const current = exerciseInfo[exerciseId] 128 | let suffix = '' 129 | let info = current.final 130 | if (type === 'exercise') { 131 | suffix = '/exercise' 132 | info = current.exercise 133 | } else if (type === 'final') { 134 | suffix = '/final' 135 | } 136 | return ( 137 |
144 |
145 | {info.previous ? ( 146 | 147 | {exerciseInfo[info.previous].title}{' '} 148 | 149 | 👈 150 | 151 | 152 | ) : null} 153 |
154 |
155 | Home 156 |
157 |
158 | {info.next ? ( 159 | 160 | 161 | 👉 162 | {' '} 163 | {exerciseInfo[info.next].title} 164 | 165 | ) : null} 166 |
167 |
168 | ) 169 | } 170 | 171 | function Home() { 172 | return ( 173 |
181 |

{projectTitle}

182 |
183 | {Object.entries(exerciseInfo).map( 184 | ([filename, {title, final, exercise}]) => { 185 | return ( 186 |
187 | {filename} 188 | {'. '} 189 | {title}{' '} 190 | 191 | 192 | (exercise) 193 | {' '} 194 | 195 | (final) 196 | 197 | 198 |
199 | ) 200 | }, 201 | )} 202 |
203 |
204 | ) 205 | } 206 | 207 | function NotFound() { 208 | return ( 209 |
217 |
218 | Sorry... nothing here. To open one of the exercises, go to{' '} 219 | {`/exerciseId`}, for example:{' '} 220 | 221 | {`/01`} 222 | 223 |
224 |
225 | ) 226 | } 227 | 228 | function Routes() { 229 | return ( 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | ) 244 | } 245 | 246 | // cache 247 | const lazyComps = {final: {}, exercise: {}, examples: {}} 248 | 249 | function useIsolatedComponent({pathname}) { 250 | const isIsolated = pathname.startsWith('/isolated') 251 | const isFinal = pathname.includes('/exercises-final/') 252 | const isExercise = pathname.includes('/exercises/') 253 | const isExample = pathname.includes('/examples/') 254 | const moduleName = isIsolated 255 | ? pathname.split(/\/isolated\/.*?\//).slice(-1)[0] 256 | : null 257 | const IsolatedComponent = React.useMemo(() => { 258 | if (!moduleName) { 259 | return null 260 | } 261 | if (isFinal) { 262 | return (lazyComps.final[moduleName] = 263 | lazyComps.final[moduleName] || 264 | React.lazy(() => import(`./exercises-final/${moduleName}`))) 265 | } else if (isExercise) { 266 | return (lazyComps.exercise[moduleName] = 267 | lazyComps.exercise[moduleName] || 268 | React.lazy(() => import(`./exercises/${moduleName}`))) 269 | } else if (isExample) { 270 | return (lazyComps.examples[moduleName] = 271 | lazyComps.examples[moduleName] || 272 | React.lazy(() => import(`./examples/${moduleName}`))) 273 | } 274 | }, [isExample, isExercise, isFinal, moduleName]) 275 | return moduleName ? IsolatedComponent : null 276 | } 277 | 278 | function useExerciseTitle({pathname}) { 279 | const isIsolated = pathname.startsWith('/isolated') 280 | const isFinal = pathname.includes('/exercises-final/') 281 | const isExercise = pathname.includes('/exercises/') 282 | const exerciseName = isIsolated 283 | ? pathname.split(/\/isolated\/.*?\//).slice(-1)[0] 284 | : pathname.split('/').slice(-1)[0] 285 | 286 | React.useEffect(() => { 287 | document.title = [ 288 | projectTitle, 289 | exerciseName, 290 | isExercise ? 'Exercise' : null, 291 | isFinal ? 'Final' : null, 292 | ] 293 | .filter(Boolean) 294 | .join(' | ') 295 | }, [exerciseName, isExercise, isFinal]) 296 | } 297 | 298 | function useLocationBodyClassName({pathname}) { 299 | const className = pathname.replace(/\//g, '_') 300 | React.useEffect(() => { 301 | document.body.classList.add(className) 302 | return () => document.body.classList.remove(className) 303 | }, [className]) 304 | } 305 | 306 | // The reason we don't put the Isolated components as regular routes 307 | // and do all this complex stuff instead is so the React DevTools component 308 | // tree is as small as possible to make it easier for people to figure 309 | // out what is relevant to the example. 310 | function MainApp() { 311 | const [location, setLocation] = React.useState(history.location) 312 | React.useEffect(() => history.listen(l => setLocation(l)), []) 313 | useExerciseTitle(location) 314 | useLocationBodyClassName(location) 315 | 316 | const IsolatedComponent = useIsolatedComponent(location) 317 | 318 | return ( 319 | 322 | Loading... 323 |
324 | } 325 | > 326 | {IsolatedComponent ? ( 327 |
328 |
329 | 330 |
331 |
332 | ) : ( 333 | 334 | )} 335 |
336 | ) 337 | } 338 | 339 | export default MainApp 340 | -------------------------------------------------------------------------------- /src/examples/fetch-approaches/fetch-on-render.js: -------------------------------------------------------------------------------- 1 | // Fetch on render 👎 2 | 3 | // http://localhost:3000/isolated/examples/fetch-approaches/fetch-on-render 4 | 5 | import React from 'react' 6 | import {PokemonForm} from '../../utils' 7 | 8 | const PokemonInfo = React.lazy(() => 9 | import('./lazy/pokemon-info-fetch-on-render'), 10 | ) 11 | 12 | window.fetch.restoreOriginalFetch() 13 | 14 | function App() { 15 | const [pokemonName, setPokemonName] = React.useState(null) 16 | 17 | function handleSubmit(newPokemonName) { 18 | setPokemonName(newPokemonName) 19 | } 20 | 21 | return ( 22 |
23 | 24 |
25 |
26 | {pokemonName ? ( 27 | 28 | ) : ( 29 | 'Submit a pokemon' 30 | )} 31 |
32 |
33 | ) 34 | } 35 | 36 | export default App 37 | -------------------------------------------------------------------------------- /src/examples/fetch-approaches/fetch-then-render.js: -------------------------------------------------------------------------------- 1 | // Fetch then render 👎 2 | 3 | // http://localhost:3000/isolated/examples/fetch-approaches/fetch-then-render 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../../fetch-pokemon' 7 | import {PokemonForm, PokemonInfoFallback} from '../../utils' 8 | 9 | const PokemonInfo = React.lazy(() => 10 | import('./lazy/pokemon-info-fetch-then-render'), 11 | ) 12 | 13 | window.fetch.restoreOriginalFetch() 14 | 15 | function usePokemon(pokemonName) { 16 | const [state, setState] = React.useReducer((s, a) => ({...s, ...a}), { 17 | pokemon: null, 18 | error: null, 19 | status: 'pending', 20 | }) 21 | 22 | React.useEffect(() => { 23 | if (!pokemonName) { 24 | return 25 | } 26 | let current = true 27 | setState({status: 'pending'}) 28 | fetchPokemon(pokemonName).then( 29 | p => { 30 | if (current) setState({pokemon: p, status: 'success'}) 31 | }, 32 | e => { 33 | if (current) setState({error: e, status: 'error'}) 34 | }, 35 | ) 36 | return () => (current = false) 37 | }, [pokemonName]) 38 | 39 | return state 40 | } 41 | 42 | function App() { 43 | const [pokemonName, setPokemonName] = React.useState(null) 44 | const {pokemon, error, status} = usePokemon(pokemonName) 45 | 46 | function handleSubmit(newPokemonName) { 47 | setPokemonName(newPokemonName) 48 | } 49 | 50 | return ( 51 |
52 | 53 |
54 |
55 | {pokemonName ? ( 56 | status === 'pending' ? ( 57 | 58 | ) : status === 'error' ? ( 59 |
60 | There was an error. 61 |
{error.message}
62 |
63 | ) : status === 'success' ? ( 64 | 65 | ) : null 66 | ) : ( 67 | 'Submit a pokemon' 68 | )} 69 |
70 |
71 | ) 72 | } 73 | 74 | export default App 75 | -------------------------------------------------------------------------------- /src/examples/fetch-approaches/lazy/pokemon-info-fetch-on-render.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import fetchPokemon from '../../../fetch-pokemon' 3 | import {PokemonInfoFallback, PokemonDataView} from '../../../utils' 4 | 5 | function PokemonInfo({pokemonName}) { 6 | const [state, setState] = React.useReducer((s, a) => ({...s, ...a}), { 7 | pokemon: null, 8 | error: null, 9 | status: 'pending', 10 | }) 11 | 12 | const {pokemon, error, status} = state 13 | 14 | React.useEffect(() => { 15 | let current = true 16 | setState({status: 'pending'}) 17 | fetchPokemon(pokemonName).then( 18 | p => { 19 | if (current) setState({pokemon: p, status: 'success'}) 20 | }, 21 | e => { 22 | if (current) setState({error: e, status: 'error'}) 23 | }, 24 | ) 25 | return () => (current = false) 26 | }, [pokemonName]) 27 | 28 | if (status === 'pending') { 29 | return 30 | } 31 | 32 | if (status === 'error') { 33 | return ( 34 |
35 | There was an error. 36 |
{error.message}
37 |
38 | ) 39 | } 40 | 41 | if (status === 'success') { 42 | return ( 43 |
44 |
45 | {pokemon.name} 46 |
47 | 48 |
49 | ) 50 | } 51 | } 52 | 53 | export default PokemonInfo 54 | -------------------------------------------------------------------------------- /src/examples/fetch-approaches/lazy/pokemon-info-fetch-then-render.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {PokemonDataView} from '../../../utils' 3 | 4 | function PokemonInfo({pokemon}) { 5 | return ( 6 |
7 |
8 | {pokemon.name} 9 |
10 | 11 |
12 | ) 13 | } 14 | 15 | export default PokemonInfo 16 | -------------------------------------------------------------------------------- /src/examples/fetch-approaches/lazy/pokemon-info-render-as-you-fetch.data.js: -------------------------------------------------------------------------------- 1 | import {createResource, preloadImage} from '../../../utils' 2 | import fetchPokemon, {getImageUrlForPokemon} from '../../../fetch-pokemon' 3 | 4 | function createPokemonResource(pokemonName) { 5 | const lowerName = pokemonName 6 | const data = createResource(() => fetchPokemon(lowerName)) 7 | const image = createResource(() => 8 | preloadImage(getImageUrlForPokemon(lowerName)), 9 | ) 10 | return {data, image} 11 | } 12 | 13 | export default createPokemonResource 14 | -------------------------------------------------------------------------------- /src/examples/fetch-approaches/lazy/pokemon-info-render-as-you-fetch.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {PokemonDataView} from '../../../utils' 3 | 4 | function PokemonInfo({pokemonResource}) { 5 | const pokemon = pokemonResource.data.read() 6 | return ( 7 |
8 |
9 | {pokemon.name} 10 |
11 | 12 |
13 | ) 14 | } 15 | 16 | export default PokemonInfo 17 | -------------------------------------------------------------------------------- /src/examples/fetch-approaches/render-as-you-fetch.js: -------------------------------------------------------------------------------- 1 | // Render as you Fetch 👍 2 | 3 | // http://localhost:3000/isolated/examples/fetch-approaches/render-as-you-fetch 4 | 5 | import React from 'react' 6 | import {ErrorBoundary, PokemonInfoFallback, PokemonForm} from '../../utils' 7 | import createPokemonInfoResource from './lazy/pokemon-info-render-as-you-fetch.data' 8 | 9 | const PokemonInfo = React.lazy(() => 10 | import('./lazy/pokemon-info-render-as-you-fetch'), 11 | ) 12 | 13 | window.fetch.restoreOriginalFetch() 14 | 15 | const SUSPENSE_CONFIG = { 16 | timeoutMs: 4000, 17 | busyDelayMs: 300, // this time is slightly shorter than our css transition delay 18 | busyMinDurationMs: 700, 19 | } 20 | 21 | const pokemonResourceCache = {} 22 | 23 | function getPokemonResource(name) { 24 | const lowerName = name.toLowerCase() 25 | let resource = pokemonResourceCache[lowerName] 26 | if (!resource) { 27 | resource = createPokemonInfoResource(lowerName) 28 | pokemonResourceCache[lowerName] = resource 29 | } 30 | return resource 31 | } 32 | 33 | function App() { 34 | const [pokemonName, setPokemonName] = React.useState('') 35 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 36 | const [pokemonResource, setPokemonResource] = React.useState(null) 37 | 38 | function handleSubmit(newPokemonName) { 39 | setPokemonName(newPokemonName) 40 | startTransition(() => { 41 | setPokemonResource(getPokemonResource(newPokemonName)) 42 | }) 43 | } 44 | 45 | return ( 46 |
47 | 48 |
49 |
50 | {pokemonResource ? ( 51 | 52 | } 54 | > 55 | 56 | 57 | 58 | ) : ( 59 | 'Submit a pokemon' 60 | )} 61 |
62 |
63 | ) 64 | } 65 | 66 | export default App 67 | -------------------------------------------------------------------------------- /src/examples/preload-image.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {getImageUrlForPokemon} from '../fetch-pokemon' 3 | 4 | // http://localhost:3000/isolated/examples/preload-image 5 | 6 | const bulbasaurImageUrl = getImageUrlForPokemon('bulbasaur') 7 | const dittoImageUrl = getImageUrlForPokemon('ditto') 8 | 9 | const preloadImage = url => (document.createElement('img').src = url) 10 | const preloadBulbasaur = () => preloadImage(bulbasaurImageUrl) 11 | const preloadDitto = () => preloadImage(dittoImageUrl) 12 | 13 | function PreloadImageExample() { 14 | const [showImages, setShowImages] = React.useState(false) 15 | 16 | return ( 17 |
18 | 19 |
20 |
21 |
22 | 23 |
24 | {showImages ? ( 25 | Bulbasaur 26 | ) : null} 27 |
28 |
29 |
30 |
31 | 32 |
33 | {showImages ? Ditto : null} 34 |
35 |
36 |
37 |
38 | ) 39 | } 40 | 41 | export default PreloadImageExample 42 | -------------------------------------------------------------------------------- /src/exercises-final/01-extra.1.js: -------------------------------------------------------------------------------- 1 | // Simple Data-fetching 2 | // 💯 add error handling with an Error Boundary 3 | 4 | // http://localhost:3000/isolated/exercises-final/01-extra.1 5 | 6 | import React from 'react' 7 | import fetchPokemon from '../fetch-pokemon' 8 | import {ErrorBoundary, PokemonDataView} from '../utils' 9 | 10 | // By default, all fetches are mocked so we can control the time easily. 11 | // You can adjust the fetch time with this: 12 | // window.FETCH_TIME = 3000 13 | // If you want to make an actual network call for the pokemon 14 | // then uncomment the following line 15 | // window.fetch.restoreOriginalFetch() 16 | // Note that by doing this, the FETCH_TIME will no longer be considered 17 | // and if you want to slow things down you should use the Network tab 18 | // in your developer tools to throttle your network to something like "Slow 3G" 19 | 20 | let pokemon 21 | let pokemonError 22 | let pokemonPromise = fetchPokemon('pikachu').then( 23 | p => (pokemon = p), 24 | e => (pokemonError = e), 25 | ) 26 | 27 | function PokemonInfo() { 28 | if (pokemonError) { 29 | throw pokemonError 30 | } 31 | if (!pokemon) { 32 | throw pokemonPromise 33 | } 34 | return ( 35 |
36 |
37 | {pokemon.name} 38 |
39 | 40 |
41 | ) 42 | } 43 | 44 | function App() { 45 | return ( 46 |
47 | 48 | Loading Pokemon...
}> 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export default App 57 | -------------------------------------------------------------------------------- /src/exercises-final/01-extra.2.js: -------------------------------------------------------------------------------- 1 | // Simple Data-fetching 2 | // 💯 make more generic createResource 3 | 4 | // http://localhost:3000/isolated/exercises-final/01-extra.2 5 | 6 | import React from 'react' 7 | import fetchPokemon from '../fetch-pokemon' 8 | import {ErrorBoundary, PokemonDataView} from '../utils' 9 | 10 | // By default, all fetches are mocked so we can control the time easily. 11 | // You can adjust the fetch time with this: 12 | // window.FETCH_TIME = 3000 13 | // If you want to make an actual network call for the pokemon 14 | // then uncomment the following line 15 | // window.fetch.restoreOriginalFetch() 16 | // Note that by doing this, the FETCH_TIME will no longer be considered 17 | // and if you want to slow things down you should use the Network tab 18 | // in your developer tools to throttle your network to something like "Slow 3G" 19 | 20 | let pokemonResource = createResource(() => fetchPokemon('pikachu')) 21 | 22 | function createResource(asyncFn) { 23 | let status = 'pending' 24 | let result 25 | let promise = asyncFn().then( 26 | r => { 27 | status = 'success' 28 | result = r 29 | }, 30 | e => { 31 | status = 'error' 32 | result = e 33 | }, 34 | ) 35 | return { 36 | read() { 37 | if (status === 'pending') throw promise 38 | if (status === 'error') throw result 39 | if (status === 'success') return result 40 | throw new Error('This should be impossible') 41 | }, 42 | } 43 | } 44 | 45 | function Pokemon() { 46 | const pokemon = pokemonResource.read() 47 | return ( 48 |
49 |
50 | {pokemon.name} 51 |
52 | 53 |
54 | ) 55 | } 56 | 57 | function App() { 58 | return ( 59 |
60 | 61 | Loading Pokemon...
}> 62 | 63 | 64 | 65 | 66 | ) 67 | } 68 | 69 | export default App 70 | -------------------------------------------------------------------------------- /src/exercises-final/01-extra.3.js: -------------------------------------------------------------------------------- 1 | // Simple Data-fetching 2 | // 💯 Use utils 3 | 4 | // http://localhost:3000/isolated/exercises-final/01-extra.3 5 | 6 | import React from 'react' 7 | import fetchPokemon from '../fetch-pokemon' 8 | import { 9 | ErrorBoundary, 10 | PokemonInfoFallback, 11 | createResource, 12 | PokemonDataView, 13 | } from '../utils' 14 | 15 | // By default, all fetches are mocked so we can control the time easily. 16 | // You can adjust the fetch time with this: 17 | // window.FETCH_TIME = 3000 18 | // If you want to make an actual network call for the pokemon 19 | // then uncomment the following line 20 | // window.fetch.restoreOriginalFetch() 21 | // Note that by doing this, the FETCH_TIME will no longer be considered 22 | // and if you want to slow things down you should use the Network tab 23 | // in your developer tools to throttle your network to something like "Slow 3G" 24 | 25 | let pokemonResource = createResource(() => fetchPokemon('pikachu')) 26 | 27 | function Pokemon() { 28 | const pokemon = pokemonResource.read() 29 | return ( 30 |
31 |
32 | {pokemon.name} 33 |
34 | 35 |
36 | ) 37 | } 38 | 39 | function App() { 40 | return ( 41 |
42 | 43 | }> 44 | 45 | 46 | 47 |
48 | ) 49 | } 50 | 51 | export default App 52 | -------------------------------------------------------------------------------- /src/exercises-final/01.js: -------------------------------------------------------------------------------- 1 | // Simple Data-fetching 2 | 3 | // http://localhost:3000/isolated/exercises-final/01 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../fetch-pokemon' 7 | import {PokemonDataView} from '../utils' 8 | 9 | // By default, all fetches are mocked so we can control the time easily. 10 | // You can adjust the fetch time with this: 11 | // window.FETCH_TIME = 3000 12 | // If you want to make an actual network call for the pokemon 13 | // then uncomment the following line 14 | // window.fetch.restoreOriginalFetch() 15 | // Note that by doing this, the FETCH_TIME will no longer be considered 16 | // and if you want to slow things down you should use the Network tab 17 | // in your developer tools to throttle your network to something like "Slow 3G" 18 | 19 | let pokemon 20 | let pokemonPromise = fetchPokemon('pikachu').then(p => (pokemon = p)) 21 | 22 | function PokemonInfo() { 23 | if (!pokemon) { 24 | throw pokemonPromise 25 | } 26 | return ( 27 |
28 |
29 | {pokemon.name} 30 |
31 | 32 |
33 | ) 34 | } 35 | 36 | function App() { 37 | return ( 38 |
39 | Loading Pokemon...
}> 40 | 41 | 42 | 43 | ) 44 | } 45 | 46 | export default App 47 | -------------------------------------------------------------------------------- /src/exercises-final/02-extra.1.js: -------------------------------------------------------------------------------- 1 | // Refactor useEffect to Suspense 2 | // 💯 Suspense and Error Boundary positioning 3 | 4 | // http://localhost:3000/isolated/exercises-final/02-extra.1 5 | 6 | import React from 'react' 7 | import fetchPokemon from '../fetch-pokemon' 8 | import { 9 | ErrorBoundary, 10 | createResource, 11 | PokemonInfoFallback, 12 | PokemonForm, 13 | PokemonDataView, 14 | } from '../utils' 15 | 16 | // By default, all fetches are mocked so we can control the time easily. 17 | // You can adjust the fetch time with this: 18 | // window.FETCH_TIME = 3000 19 | // If you want to make an actual network call for the pokemon 20 | // then uncomment the following line 21 | // window.fetch.restoreOriginalFetch() 22 | // Note that by doing this, the FETCH_TIME will no longer be considered 23 | // and if you want to slow things down you should use the Network tab 24 | // in your developer tools to throttle your network to something like "Slow 3G" 25 | 26 | function PokemonInfo({pokemonResource}) { 27 | const pokemon = pokemonResource.read() 28 | return ( 29 |
30 |
31 | {pokemon.name} 32 |
33 | 34 |
35 | ) 36 | } 37 | 38 | function createPokemonResource(pokemonName) { 39 | return createResource(() => fetchPokemon(pokemonName)) 40 | } 41 | 42 | function App() { 43 | const [pokemonName, setPokemonName] = React.useState(null) 44 | const [pokemonResource, setPokemonResource] = React.useState(null) 45 | 46 | function handleSubmit(newPokemonName) { 47 | setPokemonName(newPokemonName) 48 | setPokemonResource(createPokemonResource(newPokemonName)) 49 | } 50 | 51 | return ( 52 |
53 | 54 |
55 | 58 | 59 |
60 | } 61 | > 62 |
63 | {pokemonResource ? ( 64 | 65 | 66 | 67 | ) : ( 68 | 'Submit a pokemon' 69 | )} 70 |
71 | 72 | 73 | ) 74 | } 75 | 76 | export default App 77 | -------------------------------------------------------------------------------- /src/exercises-final/02.js: -------------------------------------------------------------------------------- 1 | // Refactor useEffect to Suspense 2 | 3 | // http://localhost:3000/isolated/exercises-final/02 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../fetch-pokemon' 7 | import { 8 | ErrorBoundary, 9 | createResource, 10 | PokemonInfoFallback, 11 | PokemonForm, 12 | PokemonDataView, 13 | } from '../utils' 14 | 15 | // By default, all fetches are mocked so we can control the time easily. 16 | // You can adjust the fetch time with this: 17 | // window.FETCH_TIME = 3000 18 | // If you want to make an actual network call for the pokemon 19 | // then uncomment the following line 20 | // window.fetch.restoreOriginalFetch() 21 | // Note that by doing this, the FETCH_TIME will no longer be considered 22 | // and if you want to slow things down you should use the Network tab 23 | // in your developer tools to throttle your network to something like "Slow 3G" 24 | 25 | function PokemonInfo({pokemonResource}) { 26 | const pokemon = pokemonResource.read() 27 | return ( 28 |
29 |
30 | {pokemon.name} 31 |
32 | 33 |
34 | ) 35 | } 36 | 37 | function createPokemonResource(pokemonName) { 38 | return createResource(() => fetchPokemon(pokemonName)) 39 | } 40 | 41 | function App() { 42 | const [pokemonName, setPokemonName] = React.useState(null) 43 | const [pokemonResource, setPokemonResource] = React.useState(null) 44 | 45 | function handleSubmit(newPokemonName) { 46 | setPokemonName(newPokemonName) 47 | setPokemonResource(createPokemonResource(newPokemonName)) 48 | } 49 | 50 | return ( 51 |
52 | 53 |
54 |
55 | {pokemonResource ? ( 56 | 57 | } 59 | > 60 | 61 | 62 | 63 | ) : ( 64 | 'Submit a pokemon' 65 | )} 66 |
67 |
68 | ) 69 | } 70 | 71 | export default App 72 | -------------------------------------------------------------------------------- /src/exercises-final/03-extra.1.js: -------------------------------------------------------------------------------- 1 | // useTransition for improved loading states 2 | // 💯 use css transitions 3 | 4 | // http://localhost:3000/isolated/exercises-final/03-extra.1 5 | 6 | import React from 'react' 7 | import fetchPokemon from '../fetch-pokemon' 8 | import { 9 | ErrorBoundary, 10 | createResource, 11 | PokemonInfoFallback, 12 | PokemonForm, 13 | PokemonDataView, 14 | } from '../utils' 15 | 16 | // By default, all fetches are mocked so we can control the time easily. 17 | // You can adjust the fetch time with this: 18 | // window.FETCH_TIME = 3000 19 | // If you want to make an actual network call for the pokemon 20 | // then uncomment the following line 21 | // window.fetch.restoreOriginalFetch() 22 | // Note that by doing this, the FETCH_TIME will no longer be considered 23 | // and if you want to slow things down you should use the Network tab 24 | // in your developer tools to throttle your network to something like "Slow 3G" 25 | 26 | function PokemonInfo({pokemonResource}) { 27 | const pokemon = pokemonResource.read() 28 | return ( 29 |
30 |
31 | {pokemon.name} 32 |
33 | 34 |
35 | ) 36 | } 37 | 38 | // shows busy indicator, and it stays for 500ms 39 | // window.FETCH_TIME = 450 40 | 41 | // shows busy indicator, then suspense fallback 42 | // window.FETCH_TIME = 5000 43 | 44 | // never shows busy indicator 45 | // window.FETCH_TIME = 200 46 | 47 | const SUSPENSE_CONFIG = {timeoutMs: 4000} 48 | 49 | function createPokemonResource(pokemonName) { 50 | return createResource(() => fetchPokemon(pokemonName)) 51 | } 52 | 53 | function App() { 54 | const [pokemonName, setPokemonName] = React.useState('') 55 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 56 | const [pokemonResource, setPokemonResource] = React.useState(null) 57 | 58 | function handleSubmit(newPokemonName) { 59 | setPokemonName(newPokemonName) 60 | startTransition(() => { 61 | setPokemonResource(createPokemonResource(newPokemonName)) 62 | }) 63 | } 64 | 65 | return ( 66 |
67 | 68 |
69 |
70 | {pokemonResource ? ( 71 | 72 | } 74 | > 75 | 76 | 77 | 78 | ) : ( 79 | 'Submit a pokemon' 80 | )} 81 |
82 |
83 | ) 84 | } 85 | 86 | export default App 87 | -------------------------------------------------------------------------------- /src/exercises-final/03-extra.2.js: -------------------------------------------------------------------------------- 1 | // useTransition for improved loading states 2 | // 💯 avoid flash of loading content 3 | 4 | // http://localhost:3000/isolated/exercises-final/03-extra.2 5 | 6 | import React from 'react' 7 | import fetchPokemon from '../fetch-pokemon' 8 | import { 9 | ErrorBoundary, 10 | createResource, 11 | PokemonInfoFallback, 12 | PokemonForm, 13 | PokemonDataView, 14 | } from '../utils' 15 | 16 | // By default, all fetches are mocked so we can control the time easily. 17 | // You can adjust the fetch time with this: 18 | // window.FETCH_TIME = 3000 19 | // If you want to make an actual network call for the pokemon 20 | // then uncomment the following line 21 | // window.fetch.restoreOriginalFetch() 22 | // Note that by doing this, the FETCH_TIME will no longer be considered 23 | // and if you want to slow things down you should use the Network tab 24 | // in your developer tools to throttle your network to something like "Slow 3G" 25 | 26 | function PokemonInfo({pokemonResource}) { 27 | const pokemon = pokemonResource.read() 28 | return ( 29 |
30 |
31 | {pokemon.name} 32 |
33 | 34 |
35 | ) 36 | } 37 | 38 | const SUSPENSE_CONFIG = { 39 | timeoutMs: 4000, 40 | busyDelayMs: 300, // this time is slightly shorter than our css transition delay 41 | busyMinDurationMs: 700, 42 | } 43 | 44 | function createPokemonResource(pokemonName) { 45 | // fetchPokemon takes an optional second argument called "delay" which 46 | // allows you to arbitrarily delay the fetch request by a given number 47 | // of milliseconds. For example: 48 | // fetchPokemon(pokemonName, 400) 49 | // would delay it to at least take 400 milliseconds 50 | return createResource(() => fetchPokemon(pokemonName)) 51 | } 52 | 53 | function App() { 54 | const [pokemonName, setPokemonName] = React.useState('') 55 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 56 | const [pokemonResource, setPokemonResource] = React.useState(null) 57 | 58 | function handleSubmit(newPokemonName) { 59 | setPokemonName(newPokemonName) 60 | startTransition(() => { 61 | setPokemonResource(createPokemonResource(newPokemonName)) 62 | }) 63 | } 64 | 65 | return ( 66 |
67 | 68 |
69 |
70 | {pokemonResource ? ( 71 | 72 | } 74 | > 75 | 76 | 77 | 78 | ) : ( 79 | 'Submit a pokemon' 80 | )} 81 |
82 |
83 | ) 84 | } 85 | 86 | export default App 87 | -------------------------------------------------------------------------------- /src/exercises-final/03.js: -------------------------------------------------------------------------------- 1 | // useTransition for improved loading states 2 | 3 | // http://localhost:3000/isolated/exercises-final/03 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../fetch-pokemon' 7 | import { 8 | ErrorBoundary, 9 | createResource, 10 | PokemonInfoFallback, 11 | PokemonForm, 12 | PokemonDataView, 13 | } from '../utils' 14 | 15 | // By default, all fetches are mocked so we can control the time easily. 16 | // You can adjust the fetch time with this: 17 | // window.FETCH_TIME = 3000 18 | // If you want to make an actual network call for the pokemon 19 | // then uncomment the following line 20 | // window.fetch.restoreOriginalFetch() 21 | // Note that by doing this, the FETCH_TIME will no longer be considered 22 | // and if you want to slow things down you should use the Network tab 23 | // in your developer tools to throttle your network to something like "Slow 3G" 24 | 25 | function PokemonInfo({pokemonResource}) { 26 | const pokemon = pokemonResource.read() 27 | return ( 28 |
29 |
30 | {pokemon.name} 31 |
32 | 33 |
34 | ) 35 | } 36 | 37 | // try a few of these fetch times: 38 | // shows busy indicator 39 | // window.FETCH_TIME = 450 40 | 41 | // shows busy indicator, then suspense fallback 42 | // window.FETCH_TIME = 5000 43 | 44 | // shows busy indicator for a split second 45 | // 💯 this is what the extra credit improves 46 | // window.FETCH_TIME = 200 47 | 48 | const SUSPENSE_CONFIG = {timeoutMs: 4000} 49 | 50 | function createPokemonResource(pokemonName) { 51 | return createResource(() => fetchPokemon(pokemonName)) 52 | } 53 | 54 | function App() { 55 | const [pokemonName, setPokemonName] = React.useState(null) 56 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 57 | const [pokemonResource, setPokemonResource] = React.useState(null) 58 | 59 | function handleSubmit(newPokemonName) { 60 | setPokemonName(newPokemonName) 61 | startTransition(() => { 62 | setPokemonResource(createPokemonResource(newPokemonName)) 63 | }) 64 | } 65 | 66 | return ( 67 |
68 | 69 |
70 |
71 | {pokemonResource ? ( 72 | 73 | } 75 | > 76 | 77 | 78 | 79 | ) : ( 80 | 'Submit a pokemon' 81 | )} 82 |
83 |
84 | ) 85 | } 86 | 87 | export default App 88 | -------------------------------------------------------------------------------- /src/exercises-final/04.js: -------------------------------------------------------------------------------- 1 | // Cache resources 2 | 3 | // http://localhost:3000/isolated/exercises-final/04 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../fetch-pokemon' 7 | import { 8 | ErrorBoundary, 9 | createResource, 10 | PokemonInfoFallback, 11 | PokemonForm, 12 | PokemonDataView, 13 | } from '../utils' 14 | 15 | // By default, all fetches are mocked so we can control the time easily. 16 | // You can adjust the fetch time with this: 17 | // window.FETCH_TIME = 3000 18 | // If you want to make an actual network call for the pokemon 19 | // then uncomment the following line 20 | // window.fetch.restoreOriginalFetch() 21 | // Note that by doing this, the FETCH_TIME will no longer be considered 22 | // and if you want to slow things down you should use the Network tab 23 | // in your developer tools to throttle your network to something like "Slow 3G" 24 | 25 | function PokemonInfo({pokemonResource}) { 26 | const pokemon = pokemonResource.read() 27 | return ( 28 |
29 |
30 | {pokemon.name} 31 |
32 | 33 |
34 | ) 35 | } 36 | 37 | const SUSPENSE_CONFIG = { 38 | timeoutMs: 4000, 39 | busyDelayMs: 300, // this time is slightly shorter than our css transition delay 40 | busyMinDurationMs: 700, 41 | } 42 | 43 | const pokemonResourceCache = {} 44 | 45 | function getPokemonResource(name) { 46 | const lowerName = name.toLowerCase() 47 | let resource = pokemonResourceCache[lowerName] 48 | if (!resource) { 49 | resource = createPokemonResource(lowerName) 50 | pokemonResourceCache[lowerName] = resource 51 | } 52 | return resource 53 | } 54 | 55 | function createPokemonResource(pokemonName) { 56 | return createResource(() => fetchPokemon(pokemonName)) 57 | } 58 | 59 | function App() { 60 | const [pokemonName, setPokemonName] = React.useState('') 61 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 62 | const [pokemonResource, setPokemonResource] = React.useState(null) 63 | 64 | function handleSubmit(newPokemonName) { 65 | setPokemonName(newPokemonName) 66 | startTransition(() => { 67 | setPokemonResource(getPokemonResource(newPokemonName)) 68 | }) 69 | } 70 | 71 | return ( 72 |
73 | 74 |
75 |
76 | {pokemonResource ? ( 77 | 78 | } 80 | > 81 | 82 | 83 | 84 | ) : ( 85 | 'Submit a pokemon' 86 | )} 87 |
88 |
89 | ) 90 | } 91 | 92 | export default App 93 | -------------------------------------------------------------------------------- /src/exercises-final/05-extra.1.js: -------------------------------------------------------------------------------- 1 | // Suspense Image 2 | // 💯 avoid waterfall 3 | 4 | // http://localhost:3000/isolated/exercises-final/05-extra.1 5 | 6 | import React from 'react' 7 | import fetchPokemon, {getImageUrlForPokemon} from '../fetch-pokemon' 8 | import { 9 | ErrorBoundary, 10 | createResource, 11 | PokemonInfoFallback, 12 | PokemonForm, 13 | PokemonDataView, 14 | } from '../utils' 15 | 16 | // By default, all fetches are mocked so we can control the time easily. 17 | // You can adjust the fetch time with this: 18 | // window.FETCH_TIME = 3000 19 | // If you want to make an actual network call for the pokemon 20 | // then uncomment the following line 21 | // window.fetch.restoreOriginalFetch() 22 | // Note that by doing this, the FETCH_TIME will no longer be considered 23 | // and if you want to slow things down you should use the Network tab 24 | // in your developer tools to throttle your network to something like "Slow 3G" 25 | 26 | // 🦉 On this one, make sure that you uncheck the "Disable cache" checkbox. 27 | // We're relying on that cache for this approach to work! 28 | 29 | function preloadImage(src) { 30 | return new Promise(resolve => { 31 | const img = document.createElement('img') 32 | img.src = src 33 | img.onload = () => resolve(src) 34 | }) 35 | } 36 | 37 | function PokemonInfo({pokemonResource}) { 38 | const pokemon = pokemonResource.data.read() 39 | return ( 40 |
41 |
42 | {pokemon.name} 43 |
44 | 45 |
46 | ) 47 | } 48 | 49 | const SUSPENSE_CONFIG = { 50 | timeoutMs: 4000, 51 | busyDelayMs: 300, // this time is slightly shorter than our css transition delay 52 | busyMinDurationMs: 700, 53 | } 54 | 55 | const pokemonResourceCache = {} 56 | 57 | function getPokemonResource(name) { 58 | const lowerName = name.toLowerCase() 59 | let resource = pokemonResourceCache[lowerName] 60 | if (!resource) { 61 | resource = createPokemonResource(lowerName) 62 | pokemonResourceCache[lowerName] = resource 63 | } 64 | return resource 65 | } 66 | 67 | function createPokemonResource(pokemonName) { 68 | const lowerName = pokemonName 69 | const data = createResource(() => fetchPokemon(lowerName)) 70 | const image = createResource(() => 71 | preloadImage(getImageUrlForPokemon(lowerName)), 72 | ) 73 | return {data, image} 74 | } 75 | 76 | function App() { 77 | const [pokemonName, setPokemonName] = React.useState('') 78 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 79 | const [pokemonResource, setPokemonResource] = React.useState(null) 80 | 81 | function handleSubmit(newPokemonName) { 82 | setPokemonName(newPokemonName) 83 | startTransition(() => { 84 | setPokemonResource(getPokemonResource(newPokemonName)) 85 | }) 86 | } 87 | 88 | return ( 89 |
90 | 91 |
92 |
93 | {pokemonResource ? ( 94 | 95 | } 97 | > 98 | 99 | 100 | 101 | ) : ( 102 | 'Submit a pokemon' 103 | )} 104 |
105 |
106 | ) 107 | } 108 | 109 | export default App 110 | -------------------------------------------------------------------------------- /src/exercises-final/05.js: -------------------------------------------------------------------------------- 1 | // Suspense Image 2 | 3 | // http://localhost:3000/isolated/exercises-final/05 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../fetch-pokemon' 7 | import { 8 | ErrorBoundary, 9 | createResource, 10 | PokemonInfoFallback, 11 | PokemonForm, 12 | PokemonDataView, 13 | } from '../utils' 14 | 15 | // By default, all fetches are mocked so we can control the time easily. 16 | // You can adjust the fetch time with this: 17 | // window.FETCH_TIME = 3000 18 | // If you want to make an actual network call for the pokemon 19 | // then uncomment the following line 20 | // window.fetch.restoreOriginalFetch() 21 | // Note that by doing this, the FETCH_TIME will no longer be considered 22 | // and if you want to slow things down you should use the Network tab 23 | // in your developer tools to throttle your network to something like "Slow 3G" 24 | 25 | // 🦉 On this one, make sure that you uncheck the "Disable cache" checkbox. 26 | // We're relying on that cache for this approach to work! 27 | 28 | function preloadImage(src) { 29 | return new Promise(resolve => { 30 | const img = document.createElement('img') 31 | img.src = src 32 | img.onload = () => resolve(src) 33 | }) 34 | } 35 | 36 | const imgSrcResourceCache = {} 37 | 38 | function Img({src, alt, ...props}) { 39 | let imgSrcResource = imgSrcResourceCache[src] 40 | if (!imgSrcResource) { 41 | imgSrcResource = createResource(() => preloadImage(src)) 42 | imgSrcResourceCache[src] = imgSrcResource 43 | } 44 | return {alt} 45 | } 46 | 47 | function PokemonInfo({pokemonResource}) { 48 | const pokemon = pokemonResource.read() 49 | return ( 50 |
51 |
52 | {pokemon.name} 53 |
54 | 55 |
56 | ) 57 | } 58 | 59 | const SUSPENSE_CONFIG = { 60 | timeoutMs: 4000, 61 | busyDelayMs: 300, // this time is slightly shorter than our css transition delay 62 | busyMinDurationMs: 700, 63 | } 64 | 65 | const pokemonResourceCache = {} 66 | 67 | function getPokemonResource(name) { 68 | const lowerName = name.toLowerCase() 69 | let resource = pokemonResourceCache[lowerName] 70 | if (!resource) { 71 | resource = createPokemonResource(lowerName) 72 | pokemonResourceCache[lowerName] = resource 73 | } 74 | return resource 75 | } 76 | 77 | function createPokemonResource(pokemonName) { 78 | return createResource(() => fetchPokemon(pokemonName)) 79 | } 80 | 81 | function App() { 82 | const [pokemonName, setPokemonName] = React.useState('') 83 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 84 | const [pokemonResource, setPokemonResource] = React.useState(null) 85 | 86 | function handleSubmit(newPokemonName) { 87 | setPokemonName(newPokemonName) 88 | startTransition(() => { 89 | setPokemonResource(getPokemonResource(newPokemonName)) 90 | }) 91 | } 92 | 93 | return ( 94 |
95 | 96 |
97 |
98 | {pokemonResource ? ( 99 | 100 | } 102 | > 103 | 104 | 105 | 106 | ) : ( 107 | 'Submit a pokemon' 108 | )} 109 |
110 |
111 | ) 112 | } 113 | 114 | export default App 115 | -------------------------------------------------------------------------------- /src/exercises-final/06.js: -------------------------------------------------------------------------------- 1 | // Suspense with a custom hook 2 | 3 | // http://localhost:3000/isolated/exercises-final/06 4 | 5 | import React from 'react' 6 | import fetchPokemon, {getImageUrlForPokemon} from '../fetch-pokemon' 7 | import { 8 | ErrorBoundary, 9 | createResource, 10 | preloadImage, 11 | PokemonInfoFallback, 12 | PokemonForm, 13 | PokemonDataView, 14 | } from '../utils' 15 | 16 | // By default, all fetches are mocked so we can control the time easily. 17 | // You can adjust the fetch time with this: 18 | // window.FETCH_TIME = 3000 19 | // If you want to make an actual network call for the pokemon 20 | // then uncomment the following line 21 | // window.fetch.restoreOriginalFetch() 22 | // Note that by doing this, the FETCH_TIME will no longer be considered 23 | // and if you want to slow things down you should use the Network tab 24 | // in your developer tools to throttle your network to something like "Slow 3G" 25 | 26 | function PokemonInfo({pokemonResource}) { 27 | const pokemon = pokemonResource.data.read() 28 | return ( 29 |
30 |
31 | {pokemon.name} 32 |
33 | 34 |
35 | ) 36 | } 37 | 38 | const SUSPENSE_CONFIG = { 39 | timeoutMs: 4000, 40 | busyDelayMs: 300, // this time is slightly shorter than our css transition delay 41 | busyMinDurationMs: 700, 42 | } 43 | 44 | const pokemonResourceCache = {} 45 | 46 | function getPokemonResource(name) { 47 | const lowerName = name.toLowerCase() 48 | let resource = pokemonResourceCache[lowerName] 49 | if (!resource) { 50 | resource = createPokemonResource(lowerName) 51 | pokemonResourceCache[lowerName] = resource 52 | } 53 | return resource 54 | } 55 | 56 | function createPokemonResource(pokemonName) { 57 | const lowerName = pokemonName 58 | const data = createResource(() => fetchPokemon(lowerName)) 59 | const image = createResource(() => 60 | preloadImage(getImageUrlForPokemon(lowerName)), 61 | ) 62 | return {data, image} 63 | } 64 | 65 | function usePokemonResource(pokemonName) { 66 | const [pokemonResource, setPokemonResource] = React.useState(null) 67 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 68 | 69 | React.useLayoutEffect(() => { 70 | if (!pokemonName) { 71 | return 72 | } 73 | startTransition(() => { 74 | setPokemonResource(getPokemonResource(pokemonName)) 75 | }) 76 | 77 | // ESLint wants me to add startTransition to the dependency list. I'm 78 | // excluding it like we are because of a known bug which will be fixed 79 | // before the stable release of Concurrent React: 80 | // https://github.com/facebook/react/issues/17273 81 | // eslint-disable-next-line react-hooks/exhaustive-deps 82 | }, [pokemonName]) 83 | 84 | return [pokemonResource, isPending] 85 | } 86 | 87 | function App() { 88 | const [pokemonName, setPokemonName] = React.useState('') 89 | 90 | const [pokemonResource, isPending] = usePokemonResource(pokemonName) 91 | 92 | function handleSubmit(newPokemonName) { 93 | setPokemonName(newPokemonName) 94 | } 95 | 96 | return ( 97 |
98 | 99 |
100 |
101 | {pokemonResource ? ( 102 | 103 | } 105 | > 106 | 107 | 108 | 109 | ) : ( 110 | 'Submit a pokemon' 111 | )} 112 |
113 |
114 | ) 115 | } 116 | 117 | export default App 118 | -------------------------------------------------------------------------------- /src/exercises-final/07-extra.1.js: -------------------------------------------------------------------------------- 1 | // Coordinate Suspending components with SuspenseList 2 | // 💯 preload modules 3 | 4 | // http://localhost:3000/isolated/exercises-final/07-extra.1 5 | 6 | import React from 'react' 7 | import '../suspense-list/style-overrides.css' 8 | import * as cn from '../suspense-list/app.module.css' 9 | import Spinner from '../suspense-list/spinner' 10 | import {createResource, ErrorBoundary, PokemonForm} from '../utils' 11 | import {fetchUser} from '../fetch-pokemon' 12 | 13 | // 💰 this delay function just allows us to make a promise take longer to resolve 14 | // so we can easily play around with the loading time of our code. 15 | const delay = time => promiseResult => 16 | new Promise(resolve => setTimeout(() => resolve(promiseResult), time)) 17 | 18 | function preloadableLazy(dynamicImport) { 19 | let promise 20 | function load() { 21 | if (!promise) { 22 | promise = dynamicImport() 23 | } 24 | return promise 25 | } 26 | const Comp = React.lazy(load) 27 | Comp.preload = load 28 | return Comp 29 | } 30 | 31 | const NavBar = preloadableLazy(() => 32 | import('../suspense-list/nav-bar').then(delay(500)), 33 | ) 34 | const LeftNav = preloadableLazy(() => 35 | import('../suspense-list/left-nav').then(delay(2000)), 36 | ) 37 | const MainContent = preloadableLazy(() => 38 | import('../suspense-list/main-content').then(delay(1500)), 39 | ) 40 | const RightNav = preloadableLazy(() => 41 | import('../suspense-list/right-nav').then(delay(1000)), 42 | ) 43 | 44 | const fallback = ( 45 |
46 | 47 |
48 | ) 49 | 50 | const SUSPENSE_CONFIG = {timeoutMs: 4000} 51 | 52 | function App() { 53 | const [startTransition] = React.useTransition(SUSPENSE_CONFIG) 54 | const [pokemonResource, setPokemonResource] = React.useState(null) 55 | 56 | function handleSubmit(pokemonName) { 57 | startTransition(() => { 58 | setPokemonResource(createResource(() => fetchUser(pokemonName))) 59 | NavBar.preload() 60 | LeftNav.preload() 61 | MainContent.preload() 62 | RightNav.preload() 63 | }) 64 | } 65 | 66 | if (!pokemonResource) { 67 | return ( 68 |
69 | 70 |
71 | ) 72 | } 73 | 74 | return ( 75 |
76 | 77 | 78 | 79 | 80 | 81 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
96 |
97 |
98 |
99 | ) 100 | } 101 | 102 | export default App 103 | -------------------------------------------------------------------------------- /src/exercises-final/07.js: -------------------------------------------------------------------------------- 1 | // Coordinate Suspending components with SuspenseList 2 | 3 | // http://localhost:3000/isolated/exercises-final/07 4 | 5 | import React from 'react' 6 | import '../suspense-list/style-overrides.css' 7 | import * as cn from '../suspense-list/app.module.css' 8 | import Spinner from '../suspense-list/spinner' 9 | import {createResource, ErrorBoundary, PokemonForm} from '../utils' 10 | import {fetchUser} from '../fetch-pokemon' 11 | 12 | const delay = time => promiseResult => 13 | new Promise(resolve => setTimeout(() => resolve(promiseResult), time)) 14 | 15 | const NavBar = React.lazy(() => 16 | import('../suspense-list/nav-bar').then(delay(500)), 17 | ) 18 | const LeftNav = React.lazy(() => 19 | import('../suspense-list/left-nav').then(delay(2000)), 20 | ) 21 | const MainContent = React.lazy(() => 22 | import('../suspense-list/main-content').then(delay(1500)), 23 | ) 24 | const RightNav = React.lazy(() => 25 | import('../suspense-list/right-nav').then(delay(1000)), 26 | ) 27 | 28 | const fallback = ( 29 |
30 | 31 |
32 | ) 33 | 34 | const SUSPENSE_CONFIG = {timeoutMs: 4000} 35 | 36 | function App() { 37 | const [startTransition] = React.useTransition(SUSPENSE_CONFIG) 38 | const [pokemonResource, setPokemonResource] = React.useState(null) 39 | 40 | function handleSubmit(pokemonName) { 41 | startTransition(() => { 42 | setPokemonResource(createResource(() => fetchUser(pokemonName))) 43 | }) 44 | } 45 | 46 | if (!pokemonResource) { 47 | return ( 48 |
49 | 50 |
51 | ) 52 | } 53 | 54 | return ( 55 |
56 | 57 | 58 | 59 | 60 | 61 |
62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
76 |
77 |
78 |
79 | ) 80 | } 81 | 82 | export default App 83 | -------------------------------------------------------------------------------- /src/exercises/01.js: -------------------------------------------------------------------------------- 1 | // Simple Data-fetching 2 | 3 | // http://localhost:3000/isolated/exercises/01 4 | 5 | import React from 'react' 6 | import {PokemonDataView} from '../utils' 7 | // 🐨 you'll need to import the fetchPokemon function 8 | // 💰 here you go: 9 | // import fetchPokemon from '../fetch-pokemon' 10 | // 💰 use it like this: fetchPokemon(pokemonName).then(handleSuccess, handleFailure) 11 | 12 | // you'll also need the ErrorBoundary component from utils 13 | // 💰 here you go: 14 | // import {ErrorBoundary} from '../utils' 15 | // 💰 use it like this: 16 | 17 | // By default, all fetches are mocked so we can control the time easily. 18 | // You can adjust the fetch time with this: 19 | // window.FETCH_TIME = 3000 20 | // If you want to make an actual network call for the pokemon 21 | // then uncomment the following line 22 | // window.fetch.restoreOriginalFetch() 23 | // Note that by doing this, the FETCH_TIME will no longer be considered 24 | // and if you want to slow things down you should use the Network tab 25 | // in your developer tools to throttle your network to something like "Slow 3G" 26 | 27 | // 🐨 create the following mutable variable references (using let): 28 | // pokemon, pokemonError, pokemonPromise 29 | 30 | // 💣 delete this now... 31 | const pokemon = { 32 | name: 'TODO', 33 | number: 'TODO', 34 | attacks: { 35 | special: [{name: 'TODO', type: 'TODO', damage: 'TODO'}], 36 | }, 37 | fetchedAt: 'TODO', 38 | } 39 | 40 | // We don't need the app to be mounted to know that we want to fetch the pokemon 41 | // named "pikachu" so we can go ahead and do that right here. 42 | // 🐨 assign the pokemonPromise variable to a call to fetchPokemon('pikachu') 43 | 44 | // 🐨 when the promise resolves, set the pokemon variable to the resolved value 45 | // 🐨 if the promise fails, set the pokemonError variable to the error 46 | 47 | function PokemonInfo() { 48 | // 🐨 if pokemonError is defined, then throw it here 49 | // 🐨 if there's no pokemon yet, then throw the pokemonPromise 50 | // 💰 (no, for real. Like: `throw pokemonPromise`) 51 | 52 | // if the code gets it this far, then the pokemon variable is defined and 53 | // rendering can continue! 54 | return ( 55 |
56 |
57 | {pokemon.name} 58 |
59 | 60 |
61 | ) 62 | } 63 | 64 | function App() { 65 | return ( 66 |
67 | {/* 68 | 🐨 Wrap the PokemonInfo component with a React.Suspense component with a fallback 69 | 🐨 Then wrap all that with an to catch errors 70 | 💰 I wrote the ErrorBoundary for you. You can take a look at it in the utils file if you want 71 | */} 72 | 73 |
74 | ) 75 | } 76 | 77 | /* 78 | 🦉 Elaboration & Feedback 79 | After the instruction, copy the URL below into your browser and fill out the form: 80 | http://ws.kcd.im/?ws=Concurrent%20React&e=Simple%20Data-fetching&em= 81 | */ 82 | 83 | //////////////////////////////////////////////////////////////////// 84 | // // 85 | // Don't make changes below here. // 86 | // But do look at it to see how your code is intended to be used. // 87 | // // 88 | //////////////////////////////////////////////////////////////////// 89 | 90 | export default App 91 | -------------------------------------------------------------------------------- /src/exercises/01.md: -------------------------------------------------------------------------------- 1 | # Simple Data-fetching 2 | 3 | > 🦉 Make sure Kent shows you how to enable Concurrent Mode. It's pretty simple, 4 | > so there's no exercise, but don't let him forget to show you! Oh, and he wrote 5 | > a blog post about it too: 6 | > https://kentcdodds.com/blog/how-to-enable-react-concurrent-mode 7 | 8 | ## Background 9 | 10 | We're going to start off as simple as possible, but it's still going to feel a 11 | little different from anything you've probably done with async data loading 12 | before, so buckle up. 13 | 14 | First, there's the Suspense API. Here's the basic idea: 15 | 16 | ```javascript 17 | function Component() { 18 | if (data) { 19 | return
{data.message}
20 | } 21 | throw promise 22 | // React with catch this, find the closest "Suspense" component 23 | // and "suspend" everything from there down from rendering until the 24 | // promise resolves. 25 | // 🚨 THIS "API" IS LIKELY TO CHANGE 26 | } 27 | 28 | ReactDOM.createRoot(rootEl).render( 29 | loading...}> 30 | 31 | , 32 | ) 33 | ``` 34 | 35 | That's the idea. Where the `data` and `promise` values are coming from all 36 | depends on how you implement things, and that's what we're going to do in this 37 | exercise. 38 | 39 | Imagine when your app loads, you need some data before you can show anything 40 | useful. Typically we want to put the data loading requirements right in the 41 | component that requires the data, via something like this: 42 | 43 | ```javascript 44 | React.useEffect(() => { 45 | let current = true 46 | setState({status: 'pending'}) 47 | doAsyncThing().then( 48 | p => { 49 | if (current) setState({pokemon: p, status: 'success'}) 50 | }, 51 | e => { 52 | if (current) setState({error: e, status: 'error'}) 53 | }, 54 | ) 55 | return () => (current = false) 56 | }, [pokemonName]) 57 | 58 | // render stuff based on the state 59 | ``` 60 | 61 | However, for "bootstrap" type data, we can start that request before we even 62 | render the app. The best approaches to using Suspense involve kicking off the 63 | request for the data as soon as you have the information you need for the 64 | request. This is called the "Render as you fetch" approach. We'll get into that 65 | a little bit more in future exercises, but keep that in mind. 66 | 67 | 📜 Here are some docs for you if you need them: 68 | 69 | - [``](https://reactjs.org/docs/concurrent-mode-reference.html#suspense) 70 | 71 | ### Promises 72 | 73 | Through all of this you should have a firm understanding of promises. Here's a 74 | quick example though: 75 | 76 | ```javascript 77 | const handleSuccess = result => console.log(result) 78 | const handleFailure = error => console.error(error) 79 | 80 | const myPromise = someAsyncFunction().then(handleSuccess, handleFailure) 81 | ``` 82 | 83 | ## Exercise 84 | 85 | In this exercise, we have a page that's specifically for the pokemon named 86 | Pikachu and we want to load Pikachu's data as soon as the app starts. You're 87 | going to use an `ErrorBoundary` that I've built for you (as relevant as they are 88 | to this topic, the concept of `Error Boundaries` long pre-dates Suspense, so we 89 | won't be getting into it for this workshop). You'll also be using the 90 | `React.Suspense` API. 91 | 92 | ## Extra Credit 93 | 94 | ### 💯 add error handling with an Error Boundary 95 | 96 | What happens if you mistype `pikachu` and instead try to request `pikacha`? This 97 | will result in an error and we need to handle this. 98 | 99 | In React, the way we handle component errors is with an `ErrorBoundary`. 100 | 101 | > 📜 Read up on 102 | > [Error Boundaries](https://reactjs.org/docs/error-boundaries.html) if you 103 | > haven't used them much before. 104 | 105 | We've got an `ErrorBoundary` component all built-out for you and you can import 106 | it from the `utils` file (It's where we're getting the `PokemonDataView` 107 | component right now). 108 | 109 | So you'll wrap your component in an ErrorBoundary for handling that error. But 110 | then you need to turn your promise's error into an error the ErrorBoundary can 111 | handle. 112 | 113 | For this extra credit, think of the error as similar to the pokemon data. You'll 114 | need a handler to get access to the error object, and then instead of _using_ it 115 | in your JSX, you can simply _throw_ it in your render method: 116 | 117 | ```javascript 118 | function Example() { 119 | if (error) { 120 | throw error 121 | } 122 | // ... etc 123 | } 124 | ``` 125 | 126 | Give that a try! 127 | 128 | ### 💯 make more generic createResource 129 | 130 | This is also a JavaScript refactor, but in this case we want to make a generic 131 | "resource factory" which has the following API: 132 | 133 | ```javascript 134 | const resource = createResource(() => someAsyncThing()) 135 | 136 | function MyComponent() { 137 | const myData = resource.read() 138 | // render myData stuff 139 | } 140 | ``` 141 | 142 | Try to refactor your code a bit to have a resource factory we can use for all 143 | our async needs. 144 | 145 | ### 💯 Use utils 146 | 147 | There are a bunch of utilities in the `src/utils.js` file that we'll be using a 148 | bunch during this workshop. Refactor your code to use those so you're familiar 149 | with them. Two you can use for this one are: `PokemonInfoFallback` and 150 | `createResource`! 151 | -------------------------------------------------------------------------------- /src/exercises/02.js: -------------------------------------------------------------------------------- 1 | // Render as you fetch 2 | 3 | // http://localhost:3000/isolated/exercises/02 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../fetch-pokemon' 7 | import { 8 | // ErrorBoundary, 9 | // createResource, 10 | PokemonInfoFallback, 11 | PokemonForm, 12 | PokemonDataView, 13 | } from '../utils' 14 | 15 | // By default, all fetches are mocked so we can control the time easily. 16 | // You can adjust the fetch time with this: 17 | // window.FETCH_TIME = 3000 18 | // If you want to make an actual network call for the pokemon 19 | // then uncomment the following line 20 | // window.fetch.restoreOriginalFetch() 21 | // Note that by doing this, the FETCH_TIME will no longer be considered 22 | // and if you want to slow things down you should use the Network tab 23 | // in your developer tools to throttle your network to something like "Slow 3G" 24 | 25 | // 🐨 Your goal is to refactor this traditional useEffect-style async 26 | // interaction to suspense with resources. Enjoy! 27 | 28 | function PokemonInfo({pokemonName}) { 29 | // 💣 you're pretty much going to delete all this stuff except for the one 30 | // place where 🐨 appears 31 | const [state, setState] = React.useReducer((s, a) => ({...s, ...a}), { 32 | pokemon: null, 33 | error: null, 34 | status: 'pending', 35 | }) 36 | 37 | const {pokemon, error, status} = state 38 | 39 | React.useEffect(() => { 40 | let current = true 41 | setState({status: 'pending'}) 42 | fetchPokemon(pokemonName).then( 43 | p => { 44 | if (current) setState({pokemon: p, status: 'success'}) 45 | }, 46 | e => { 47 | if (current) setState({error: e, status: 'error'}) 48 | }, 49 | ) 50 | return () => (current = false) 51 | }, [pokemonName]) 52 | 53 | // 💰 This will be the fallback prop of 54 | if (status === 'pending') { 55 | return 56 | } 57 | 58 | // 💰 This is the same thing the ErrorBoundary renders 59 | if (status === 'error') { 60 | return ( 61 |
62 | There was an error. 63 |
{error.message}
64 |
65 | ) 66 | } 67 | 68 | // 💰 this is the part that will suspend 69 | if (status === 'success') { 70 | // 🐨 instead of accpeting the pokemonName as a prop to this component 71 | // you'll accept a pokemonResource. 72 | // 💰 you'll get the pokemon from: pokemonResource.read() 73 | // 🐨 This will be the return value of this component. You wont need it 74 | // to be in this if statement anymore thought! 75 | return ( 76 |
77 |
78 | {pokemon.name} 79 |
80 | 81 |
82 | ) 83 | } 84 | } 85 | 86 | function App() { 87 | const [pokemonName, setPokemonName] = React.useState(null) 88 | // 🐨 add a useState here to keep track of the current pokemonResource 89 | 90 | function handleSubmit(newPokemonName) { 91 | setPokemonName(newPokemonName) 92 | // 🐨 set the pokemon resource right here 93 | } 94 | 95 | return ( 96 |
97 | 98 |
99 |
100 | {pokemonName ? ( // 🐨 instead of pokemonName, use pokemonResource here 101 | // 🐨 wrap PokemonInfo in an ErrorBoundary and React.Suspense component 102 | // to manage the error and loading states that PokemonInfo was managing 103 | // before your changes. 104 | 105 | ) : ( 106 | 'Submit a pokemon' 107 | )} 108 |
109 |
110 | ) 111 | } 112 | 113 | /* 114 | 🦉 Elaboration & Feedback 115 | After the instruction, copy the URL below into your browser and fill out the form: 116 | http://ws.kcd.im/?ws=Concurrent%20React&e=Refactor%20from%20useEffect&em= 117 | */ 118 | 119 | //////////////////////////////////////////////////////////////////// 120 | // // 121 | // Don't make changes below here. // 122 | // But do look at it to see how your code is intended to be used. // 123 | // // 124 | //////////////////////////////////////////////////////////////////// 125 | 126 | export default App 127 | -------------------------------------------------------------------------------- /src/exercises/02.md: -------------------------------------------------------------------------------- 1 | # Render as you fetch 2 | 3 | ## Background 4 | 5 | This one's a bit of a mind bender, but here's the ultimate goal we're looking 6 | for: https://twitter.com/kentcdodds/status/1191922859762843649 7 | 8 | We wont get the whole way there in this exercise, but we'll get a bunch of the 9 | way there. Then in exercise 04 we can finish it up. So fast ⚡ 10 | 11 | The idea here is that: get the data as soon as you have the information you need 12 | for the data. This sounds obvious, but if you think about it, how often do you 13 | have a component that requests data once it's been mounted. There's a few 14 | milliseconds between the time you click "go" and the time that component is 15 | mounted... Unless that component's code is lazy-loaded. In which case, there's a 16 | lot more time involved and your users are hanging around waiting while they 17 | could be making requests for the data they need. 18 | 19 | That's the entire idea behind "Render as you fetch." 20 | 21 | The information often involves a user's 22 | 23 | ## Exercise 24 | 25 | In this one, we now have a form that allows us to choose a pokemon by any name. 26 | As soon as the user hits "submit", we pass the `pokemonName` to our 27 | `PokemonInfo` component which makes the request to get the pokemon data (using 28 | `useEffect`). 29 | 30 | For the exercise, you need to refactor this from `useEffect` to Suspense. You'll 31 | need to add the `ErrorBoundary` and `Suspense` components to the `PokemonInfo` 32 | component, and you'll pass the pokemon resource to `PokemonInfoView` which will 33 | call `.read()` on the resource. The initial `.read()` call will trigger the 34 | component to suspend and display the fallback state. When the promise resolves, 35 | React will re-render our components and we'll be able to display the pokemon. 36 | 37 | > The real important parts of the render-as-you-fetch approach comes in the 38 | > extra credit, but changing things to this will help a lot to get us going. 39 | 40 | ## Extra Credit 41 | 42 | So far, we've benefitted from an API standpoint. I think the Suspense solution 43 | is simpler than the `useEffect` version. However, we've not gotten the "Render 44 | as you fetch" benefit when it comes to asynchronously loading the code we need. 45 | 46 | These extra credit allow you to compare the two approaches. 47 | 48 | 🦉 For both of these, we're calling `window.fetch.restoreOriginalFetch()` at the 49 | top of our file so our fetch requests actually hit the network so we can see 50 | them in the network tab. 51 | 52 | ### 💯 Suspense and Error Boundary positioning 53 | 54 | You don't have to wrap the suspending component in a suspense and error boundary 55 | directly. There can be many layers of nesting and it'll still work. But there's 56 | some semantically important differences that I want you to learn about so go 57 | ahead and try to play around with wrapping more of your elements in these 58 | boundaries and see what changes with the user experience. 59 | -------------------------------------------------------------------------------- /src/exercises/03.js: -------------------------------------------------------------------------------- 1 | // useTransition for improved loading states 2 | 3 | // http://localhost:3000/isolated/exercises/03 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../fetch-pokemon' 7 | import { 8 | ErrorBoundary, 9 | createResource, 10 | PokemonInfoFallback, 11 | PokemonForm, 12 | PokemonDataView, 13 | } from '../utils' 14 | 15 | // By default, all fetches are mocked so we can control the time easily. 16 | // You can adjust the fetch time with this: 17 | // window.FETCH_TIME = 3000 18 | // If you want to make an actual network call for the pokemon 19 | // then uncomment the following line 20 | // window.fetch.restoreOriginalFetch() 21 | // Note that by doing this, the FETCH_TIME will no longer be considered 22 | // and if you want to slow things down you should use the Network tab 23 | // in your developer tools to throttle your network to something like "Slow 3G" 24 | 25 | function PokemonInfo({pokemonResource}) { 26 | const pokemon = pokemonResource.read() 27 | return ( 28 |
29 |
30 | {pokemon.name} 31 |
32 | 33 |
34 | ) 35 | } 36 | 37 | // try a few of these fetch times: 38 | // shows busy indicator 39 | // window.FETCH_TIME = 450 40 | 41 | // shows busy indicator, then suspense fallback 42 | // window.FETCH_TIME = 5000 43 | 44 | // shows busy indicator for a split second 45 | // 💯 this is what the extra credit improves 46 | // window.FETCH_TIME = 200 47 | 48 | // 🐨 create a SUSPENSE_CONFIG variable right here and configure timeoutMs to 49 | // whatever feels right to you, then try it out and tweek it until you're happy 50 | // with the experience. 51 | 52 | function createPokemonResource(pokemonName) { 53 | return createResource(() => fetchPokemon(pokemonName)) 54 | } 55 | 56 | function App() { 57 | const [pokemonName, setPokemonName] = React.useState(null) 58 | // 🐨 add a useTransition hook here 59 | const [pokemonResource, setPokemonResource] = React.useState(null) 60 | 61 | function handleSubmit(newPokemonName) { 62 | setPokemonName(newPokemonName) 63 | // 🐨 wrap this next line in a startTransition call 64 | setPokemonResource(createPokemonResource(newPokemonName)) 65 | // 🦉 what do you think would happen if you put the setPokemonName above 66 | // into the `startTransition` call? Go ahead and give that a try! 67 | } 68 | 69 | return ( 70 |
71 | 72 |
73 | {/* 74 | 🐨 add inline styles here to set the opacity to 0.6 if the 75 | useTransition above is pending 76 | */} 77 |
78 | {pokemonResource ? ( 79 | 80 | } 82 | > 83 | 84 | 85 | 86 | ) : ( 87 | 'Submit a pokemon' 88 | )} 89 |
90 |
91 | ) 92 | } 93 | 94 | /* 95 | 🦉 Elaboration & Feedback 96 | After the instruction, copy the URL below into your browser and fill out the form: 97 | http://ws.kcd.im/?ws=Concurrent%20React&e=useTransition%20for%20improved%20loading%20states&em= 98 | */ 99 | 100 | //////////////////////////////////////////////////////////////////// 101 | // // 102 | // Don't make changes below here. // 103 | // But do look at it to see how your code is intended to be used. // 104 | // // 105 | //////////////////////////////////////////////////////////////////// 106 | 107 | export default App 108 | -------------------------------------------------------------------------------- /src/exercises/03.md: -------------------------------------------------------------------------------- 1 | # useTransition for improved loading states 2 | 3 | ## Background 4 | 5 | When a component suspends, it's literally telling React: "Don't render any 6 | updates at all from the suspense component on down until I'm ready to roll." 7 | Now, eventually React will give up on the suspending component and render your 8 | fallback instead. But there's that brief amount of time that your app will 9 | appear to be unresponsive to the user and it'd be great if we could avoid that. 10 | 11 | Also, you're probably seeing an error in your console right about now and it'd 12 | be cool to make that go away 😉 13 | 14 | The API for this is a hook called `useTransition`. Here's what that looks like: 15 | 16 | ```javascript 17 | const SUSPENSE_CONFIG = {timeoutMs: 4000} 18 | 19 | function Component() { 20 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 21 | // etc... 22 | 23 | function handleClick() { 24 | // do something that triggers some interum state change we want to 25 | // happen before suspending starts 26 | startTransition(() => { 27 | // do something that triggers a suspending component to render 28 | }) 29 | } 30 | 31 | // if needed, you can use the `isPending` boolean to display a loading spinner 32 | // or similar 33 | } 34 | ``` 35 | 36 | ## Exercise 37 | 38 | In this exercise, we'll wrap the existing call to set the resource in a 39 | transition so the input value gets updated when you select a pokemon. We'll also 40 | make the pokemon information area "appear stale" by making it slightly 41 | transparent if `isPending` is true. 42 | 43 | This is a good one to play around with setting `window.FETCH_TIME` a bit. 44 | 45 | ## Extra Credit 46 | 47 | ### 💯 use css transitions 48 | 49 | If the user has a really fast connection, then they'll see a "flash of loading 50 | content" which isn't a great experience. To combat this, I've written a css rule 51 | that has a transition delay for the opacity to not become transparent for 300 52 | milliseconds. That way if the user's on a fast connection, they wont see the 53 | loading state. 54 | 55 | Instead of using inline styles, dynamically apply the class name 56 | `pokemon-loading` if `isPending` is true to take advantage of this. The styles 57 | are in `src/styles.css` if you want to take a look. 58 | 59 | ### 💯 avoid flash of loading content 60 | 61 | **EXPERIMENTAL AND AWKWARD API AHEAD** 62 | 63 | Our previous improvement is great. We're not showing the loading state for 300ms 64 | so we're pretty good. But what if the request takes 350ms? Then we're right back 65 | where we started! The user will see a flash of loading state for 50ms. 66 | 67 | What we really need is a way to say: "Hey React, if this transition takes 300ms, 68 | then I want you to keep the transition state around for at least 500ms total no 69 | matter what." 70 | 71 | Now, this API is a little strange, it's not documented (so it's pretty likely to 72 | change). In my testing of it, it was kind of inconsistent, so I think it may be 73 | buggy. But to make this happen, you can add the following properties to your 74 | `SUSPENSE_CONFIG`: 75 | 76 | - `busyDelayMs`: Set this to the time of our CSS transition. This is the part 77 | that says "if the transition takes X amount of time" 78 | - `busyMinDurationMs`: Set this to the total time you want the transition state 79 | to persist if we surpass the `busyDelayMs` time. 80 | -------------------------------------------------------------------------------- /src/exercises/04.js: -------------------------------------------------------------------------------- 1 | // Cache resources 2 | 3 | // http://localhost:3000/isolated/exercises/04 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../fetch-pokemon' 7 | import { 8 | ErrorBoundary, 9 | createResource, 10 | PokemonInfoFallback, 11 | PokemonForm, 12 | PokemonDataView, 13 | } from '../utils' 14 | 15 | // By default, all fetches are mocked so we can control the time easily. 16 | // You can adjust the fetch time with this: 17 | // window.FETCH_TIME = 3000 18 | // If you want to make an actual network call for the pokemon 19 | // then uncomment the following line 20 | // window.fetch.restoreOriginalFetch() 21 | // Note that by doing this, the FETCH_TIME will no longer be considered 22 | // and if you want to slow things down you should use the Network tab 23 | // in your developer tools to throttle your network to something like "Slow 3G" 24 | 25 | function PokemonInfo({pokemonResource}) { 26 | const pokemon = pokemonResource.read() 27 | return ( 28 |
29 |
30 | {pokemon.name} 31 |
32 | 33 |
34 | ) 35 | } 36 | 37 | const SUSPENSE_CONFIG = { 38 | timeoutMs: 4000, 39 | busyDelayMs: 300, // this time is slightly shorter than our css transition delay 40 | busyMinDurationMs: 700, 41 | } 42 | 43 | // 🐨 create a pokemonResourceCache object 44 | 45 | // 🐨 create a getPokemonResource function which accepts a name checks the cache 46 | // for an existing resource. If there is none, then it creates a resource 47 | // and inserts it into the cache. Finally the function should return the 48 | // resource. 49 | 50 | function createPokemonResource(pokemonName) { 51 | return createResource(() => fetchPokemon(pokemonName)) 52 | } 53 | 54 | function App() { 55 | const [pokemonName, setPokemonName] = React.useState('') 56 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 57 | const [pokemonResource, setPokemonResource] = React.useState(null) 58 | 59 | function handleSubmit(newPokemonName) { 60 | setPokemonName(newPokemonName) 61 | startTransition(() => { 62 | // 🐨 change this to getPokemonResource instead 63 | setPokemonResource(createPokemonResource(newPokemonName)) 64 | }) 65 | } 66 | 67 | return ( 68 |
69 | 70 |
71 |
72 | {pokemonResource ? ( 73 | 74 | } 76 | > 77 | 78 | 79 | 80 | ) : ( 81 | 'Submit a pokemon' 82 | )} 83 |
84 |
85 | ) 86 | } 87 | 88 | /* 89 | 🦉 Elaboration & Feedback 90 | After the instruction, copy the URL below into your browser and fill out the form: 91 | http://ws.kcd.im/?ws=Concurrent%20React&e=Cache%20resources&em= 92 | */ 93 | 94 | //////////////////////////////////////////////////////////////////// 95 | // // 96 | // Don't make changes below here. // 97 | // But do look at it to see how your code is intended to be used. // 98 | // // 99 | //////////////////////////////////////////////////////////////////// 100 | 101 | export default App 102 | -------------------------------------------------------------------------------- /src/exercises/04.md: -------------------------------------------------------------------------------- 1 | # Cache resources 2 | 3 | ## Background 4 | 5 | State that comes from the server is basically a cache of state. It's not UI 6 | state. How long that cache sticks around is totally up to you. Right now, our 7 | cache only hangs around until we select a new resource, but we could persist it 8 | in memory somewhere and retrieve it later if needed. 9 | 10 | Remember that caches are among the hardest problems in computer science. If 11 | you're not careful, you can run into stale data bugs and memory leaks. But let's 12 | experiment with it here and see if we can improve the user experience. 13 | 14 | ### Promises in render 15 | 16 | 💰 here's a quick tip. Creating a new promise in the render method is dangerous 17 | because you cannot rely on your render method only being called once, so you 18 | have to do things carefully by using a promise cache. Here's an example of what 19 | I mean: 20 | 21 | ```javascript 22 | const promiseCache = {} 23 | function MySuspendingComponent({value}) { 24 | let resource = promiseCache[value] 25 | if (!resource) { 26 | resource = doAsyncThing(value) 27 | promiseCache[value] = resource // <-- this is very important 28 | } 29 | return
{resource.read()}
30 | } 31 | ``` 32 | 33 | You'll be doing something similar for this exercise. 34 | 35 | ## Exercise 36 | 37 | If you select a pokemon, then choose another, and then go back to the pokemon 38 | you selected the first time, you'll notice that you're loading that first 39 | pokemon twice, even though the data hasn't changed. That data is unlikely to 40 | ever change, so we could improve the user experience considerably by caching the 41 | data so it's available for the next time the user wants to look at that pokemon. 42 | 43 | In this exercise, our cache will function very similar to the image cache we had 44 | in the last exercise. The cache key will be the pokemon name, and the cache 45 | value will be the resource object. 46 | -------------------------------------------------------------------------------- /src/exercises/05.js: -------------------------------------------------------------------------------- 1 | // Suspense Image 2 | 3 | // http://localhost:3000/isolated/exercises/05 4 | 5 | import React from 'react' 6 | import fetchPokemon from '../fetch-pokemon' 7 | import { 8 | ErrorBoundary, 9 | createResource, 10 | PokemonInfoFallback, 11 | PokemonForm, 12 | PokemonDataView, 13 | } from '../utils' 14 | 15 | // By default, all fetches are mocked so we can control the time easily. 16 | // You can adjust the fetch time with this: 17 | // window.FETCH_TIME = 3000 18 | // If you want to make an actual network call for the pokemon 19 | // then uncomment the following line 20 | // window.fetch.restoreOriginalFetch() 21 | // Note that by doing this, the FETCH_TIME will no longer be considered 22 | // and if you want to slow things down you should use the Network tab 23 | // in your developer tools to throttle your network to something like "Slow 3G" 24 | 25 | // 🦉 On this one, make sure that you uncheck the "Disable cache" checkbox. 26 | // We're relying on that cache for this approach to work! 27 | 28 | // we need to make a place to store the resources outside of render so 29 | // 🐨 create "cache" object here. 30 | 31 | // 🐨 create an Img component that renders a regular and accepts a src 32 | // prop and forwards on any remaining props. 33 | // 🐨 The first thing you do in this component is check wither your 34 | // imgSrcResourceCache already has a resource for the given src prop. If it does 35 | // not, then you need to create one (💰 using createResource). 36 | // 🐨 Once you have the resource, then render the . 37 | // 💰 Here's what rendering the should look like: 38 | // 39 | 40 | function PokemonInfo({pokemonResource}) { 41 | const pokemon = pokemonResource.read() 42 | return ( 43 |
44 |
45 | {/* 🐨 swap this img for your new Img component */} 46 | {pokemon.name} 47 |
48 | 49 |
50 | ) 51 | } 52 | 53 | const SUSPENSE_CONFIG = { 54 | timeoutMs: 4000, 55 | busyDelayMs: 300, // this time is slightly shorter than our css transition delay 56 | busyMinDurationMs: 700, 57 | } 58 | 59 | const pokemonResourceCache = {} 60 | 61 | function getPokemonResource(name) { 62 | const lowerName = name.toLowerCase() 63 | let resource = pokemonResourceCache[lowerName] 64 | if (!resource) { 65 | resource = createPokemonResource(lowerName) 66 | pokemonResourceCache[lowerName] = resource 67 | } 68 | return resource 69 | } 70 | 71 | function createPokemonResource(pokemonName) { 72 | return createResource(() => fetchPokemon(pokemonName)) 73 | } 74 | 75 | function App() { 76 | const [pokemonName, setPokemonName] = React.useState('') 77 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 78 | const [pokemonResource, setPokemonResource] = React.useState(null) 79 | 80 | function handleSubmit(newPokemonName) { 81 | setPokemonName(newPokemonName) 82 | startTransition(() => { 83 | setPokemonResource(getPokemonResource(newPokemonName)) 84 | }) 85 | } 86 | 87 | return ( 88 |
89 | 90 |
91 |
92 | {pokemonResource ? ( 93 | 94 | } 96 | > 97 | 98 | 99 | 100 | ) : ( 101 | 'Submit a pokemon' 102 | )} 103 |
104 |
105 | ) 106 | } 107 | 108 | /* 109 | 🦉 Elaboration & Feedback 110 | After the instruction, copy the URL below into your browser and fill out the form: 111 | http://ws.kcd.im/?ws=Concurrent%20React&e=Suspense%20Image&em= 112 | */ 113 | 114 | //////////////////////////////////////////////////////////////////// 115 | // // 116 | // Don't make changes below here. // 117 | // But do look at it to see how your code is intended to be used. // 118 | // // 119 | //////////////////////////////////////////////////////////////////// 120 | 121 | export default App 122 | -------------------------------------------------------------------------------- /src/exercises/05.md: -------------------------------------------------------------------------------- 1 | # Suspense Image 2 | 3 | ## Background 4 | 5 | Loading images is tricky business because you're handing the asynchronous state 6 | over to the browser. It manages the loading, error, and success states for you. 7 | But what if you have an experience that doesn't look any good until the image is 8 | actually loaded? Or what if you want to render a fallback in the image's place 9 | while it's loading (you want to provide your own loading UI)? In that case, 10 | you're kinda out of luck, because the browser gives us no such API. 11 | 12 | Suspense can help us with this too! Luckily for us, we can pre-load images into 13 | the browser's cache using the following code: 14 | 15 | ```javascript 16 | function preloadImage(src) { 17 | return new Promise(resolve => { 18 | const img = document.createElement('img') 19 | img.src = src 20 | img.onload = () => resolve(src) 21 | }) 22 | } 23 | ``` 24 | 25 | That function will resolve to the source you gave it as soon as the image has 26 | loaded. Once that promise resolves, you know that the browser has it in it's 27 | cache and any `` elements you render with the `src` set to that `src` 28 | value will get instantly rendered with the image straight from the browser 29 | cache. 30 | 31 | ## Exercise 32 | 33 | If you turn up the throttle on your network tab (to "Slow 3G" for example) and 34 | select pokemon, you may notice that images take a moment to load in. 35 | 36 | For the first one, there's nothing there and then it bumps the content down when 37 | it loads. This can be "fixed" by setting a fixed height for the images. But 38 | let's assume that you can't be sure what that height is. 39 | 40 | If you select another pokemon, then that pokemon's data pops in, but the old 41 | pokemon's image remains in place until the new one's image finishes loading. 42 | 43 | With suspense, we have an opportunity to make this experience a lot better. We 44 | have two related options: 45 | 46 | 1. Make an `Img` component that suspends until the browser has actually loaded 47 | the image. 48 | 2. Make a request for the image alongside the pokemon data. 49 | 50 | Option 1 means that nothing will render until both the data and the image are 51 | ready. 52 | 53 | Option 2 is even better because it loads the data and image at the same time. It 54 | works because all the images are available via the same information we use to 55 | get the pokemon data. 56 | 57 | We're going to do both of these approaches for this exercise (option 2 is extra 58 | credit). 59 | 60 | ## Extra Credit 61 | 62 | ### 💯 avoid waterfall 63 | 64 | If you open up the network tab, you'll notice that you have to load the data 65 | before you can load the image because the data is where we get the image URL. 66 | You may also notice that the image URL is always very predictable. In fact, I 67 | even wrote a function for you to get the image URL based on the pokemon name! 68 | It's exported by `src/fetch-pokemon.js` and is called `getImageUrlForPokemon`. 69 | 70 | ```javascript 71 | const imageUrl = getImageUrlForPokemon('pikachu') 72 | ``` 73 | 74 | Try to pre-load this at the same time as the rest of your data. This one will be 75 | a bit trickier. I'll give you a hint. There are several ways you could do this, 76 | but in my solution, I end up changing the `PokemonInfo` component to this: 77 | 78 | ```javascript 79 | function PokemonInfo({pokemonResource}) { 80 | const pokemon = pokemonResource.data.read() 81 | return ( 82 |
83 |
84 | {pokemon.name} 85 |
86 | 87 |
88 | ) 89 | } 90 | ``` 91 | 92 | ### 💯 Render as you Fetch 93 | 94 | Remember when we did this with 02? Now that we're pre-loading the image along 95 | with the data, the improvements will be even more pronounced. Go ahead and put 96 | this at the top of your file: 97 | 98 | ```javascript 99 | import createPokemonInfoResource from '../lazy/pokemon-info-render-as-you-fetch-04.data' 100 | 101 | const PokemonInfo = React.lazy(() => 102 | import('../lazy/pokemon-info-render-as-you-fetch-04'), 103 | ) 104 | ``` 105 | 106 | And make that work. Then checkout the network tab and see how your waterfall has 107 | turned into a... stone wall? Yeah! 108 | 109 | Again this is a good one to have `window.fetch.restoreOriginalFetch()` called so 110 | you can see a real network request for the data. You will notice that we're 111 | making two requests to the pokemon endpoint. The first is an OPTIONS request. 112 | That's basically a request the browser makes to ask the server if it will accept 113 | our request across domains. It's kind of annoying because it makes our network 114 | requests take longer, but it is what it is. 115 | -------------------------------------------------------------------------------- /src/exercises/06.js: -------------------------------------------------------------------------------- 1 | // Suspense with a custom hook 2 | 3 | // http://localhost:3000/isolated/exercises/06 4 | 5 | import React from 'react' 6 | import fetchPokemon, {getImageUrlForPokemon} from '../fetch-pokemon' 7 | import { 8 | ErrorBoundary, 9 | createResource, 10 | preloadImage, 11 | PokemonInfoFallback, 12 | PokemonForm, 13 | PokemonDataView, 14 | } from '../utils' 15 | 16 | // By default, all fetches are mocked so we can control the time easily. 17 | // You can adjust the fetch time with this: 18 | // window.FETCH_TIME = 3000 19 | // If you want to make an actual network call for the pokemon 20 | // then uncomment the following line 21 | // window.fetch.restoreOriginalFetch() 22 | // Note that by doing this, the FETCH_TIME will no longer be considered 23 | // and if you want to slow things down you should use the Network tab 24 | // in your developer tools to throttle your network to something like "Slow 3G" 25 | 26 | function PokemonInfo({pokemonResource}) { 27 | const pokemon = pokemonResource.data.read() 28 | return ( 29 |
30 |
31 | {pokemon.name} 32 |
33 | 34 |
35 | ) 36 | } 37 | 38 | const SUSPENSE_CONFIG = { 39 | timeoutMs: 4000, 40 | busyDelayMs: 300, // this time is slightly shorter than our css transition delay 41 | busyMinDurationMs: 700, 42 | } 43 | 44 | const pokemonResourceCache = {} 45 | 46 | function getPokemonResource(name) { 47 | const lowerName = name.toLowerCase() 48 | let resource = pokemonResourceCache[lowerName] 49 | if (!resource) { 50 | resource = createPokemonResource(lowerName) 51 | pokemonResourceCache[lowerName] = resource 52 | } 53 | return resource 54 | } 55 | 56 | function createPokemonResource(pokemonName) { 57 | const lowerName = pokemonName 58 | const data = createResource(() => fetchPokemon(lowerName)) 59 | const image = createResource(() => 60 | preloadImage(getImageUrlForPokemon(lowerName)), 61 | ) 62 | return {data, image} 63 | } 64 | 65 | function App() { 66 | const [pokemonName, setPokemonName] = React.useState('') 67 | // 🐨 move these two lines to a custom hook called usePokemonResource 68 | 69 | // 🐨 call usePokemonResource with the pokemonName. 70 | // It should return both the pokemonResource and isPending 71 | const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG) 72 | const [pokemonResource, setPokemonResource] = React.useState(null) 73 | 74 | function handleSubmit(newPokemonName) { 75 | setPokemonName(newPokemonName) 76 | // 🐨 move this startTransition call to a useLayoutEffect inside your 77 | // custom usePokemonResource hook (it should list pokemonName as a 78 | // dependency). 79 | startTransition(() => { 80 | setPokemonResource(getPokemonResource(newPokemonName)) 81 | }) 82 | // 💰 tip: in your effect callback, if pokemonName is an empty string, 83 | // return early. 84 | } 85 | 86 | return ( 87 |
88 | 89 |
90 |
91 | {pokemonResource ? ( 92 | 93 | } 95 | > 96 | 97 | 98 | 99 | ) : ( 100 | 'Submit a pokemon' 101 | )} 102 |
103 |
104 | ) 105 | } 106 | 107 | /* 108 | 🦉 Elaboration & Feedback 109 | After the instruction, copy the URL below into your browser and fill out the form: 110 | http://ws.kcd.im/?ws=Concurrent%20React&e=Suspense%20with%20a%20custom%20hook&em= 111 | */ 112 | 113 | //////////////////////////////////////////////////////////////////// 114 | // // 115 | // Don't make changes below here. // 116 | // But do look at it to see how your code is intended to be used. // 117 | // // 118 | //////////////////////////////////////////////////////////////////// 119 | 120 | export default App 121 | -------------------------------------------------------------------------------- /src/exercises/06.md: -------------------------------------------------------------------------------- 1 | # Suspense with a custom hook 2 | 3 | ## Background 4 | 5 | React Hooks are amazing. Combine them with React Suspense, and you get some 6 | really awesome APIs. 7 | 8 | ## Exercise 9 | 10 | In this exercise, you're going to create a `usePokemonResource` with the 11 | following API: 12 | 13 | ```javascript 14 | const [pokemonResource, isPending] = usePokemonResource(pokemonName) 15 | ``` 16 | 17 | This way users of your hook don't need to bother calling `startTransition` or 18 | anything. Your custom hook will take care of that. Any time the `pokemonName` 19 | changes, your hook will trigger an update to the pokemonResource. 20 | 21 | Note: Currently there are two bugs you'll want to be aware of: 22 | 23 | - https://github.com/facebook/react/issues/17272: Have to `useLayoutEffect` and 24 | cannot `useEffect` currently 25 | - https://github.com/facebook/react/issues/17273: Cannot include 26 | `startTransition` in the effect dependency array 27 | -------------------------------------------------------------------------------- /src/exercises/07.js: -------------------------------------------------------------------------------- 1 | // Coordinate Suspending components with SuspenseList 2 | 3 | // http://localhost:3000/isolated/exercises/07 4 | 5 | import React from 'react' 6 | import '../suspense-list/style-overrides.css' 7 | import * as cn from '../suspense-list/app.module.css' 8 | import Spinner from '../suspense-list/spinner' 9 | import {createResource, ErrorBoundary, PokemonForm} from '../utils' 10 | import {fetchUser} from '../fetch-pokemon' 11 | 12 | // 💰 this delay function just allows us to make a promise take longer to resolve 13 | // so we can easily play around with the loading time of our code. 14 | const delay = time => promiseResult => 15 | new Promise(resolve => setTimeout(() => resolve(promiseResult), time)) 16 | 17 | // 🐨 feel free to play around with the delay timings. 18 | const NavBar = React.lazy(() => 19 | import('../suspense-list/nav-bar').then(delay(500)), 20 | ) 21 | const LeftNav = React.lazy(() => 22 | import('../suspense-list/left-nav').then(delay(2000)), 23 | ) 24 | const MainContent = React.lazy(() => 25 | import('../suspense-list/main-content').then(delay(1500)), 26 | ) 27 | const RightNav = React.lazy(() => 28 | import('../suspense-list/right-nav').then(delay(1000)), 29 | ) 30 | 31 | const fallback = ( 32 |
33 | 34 |
35 | ) 36 | const SUSPENSE_CONFIG = {timeoutMs: 4000} 37 | 38 | function App() { 39 | const [startTransition] = React.useTransition(SUSPENSE_CONFIG) 40 | const [pokemonResource, setPokemonResource] = React.useState(null) 41 | 42 | function handleSubmit(pokemonName) { 43 | startTransition(() => { 44 | setPokemonResource(createResource(() => fetchUser(pokemonName))) 45 | }) 46 | } 47 | 48 | if (!pokemonResource) { 49 | return ( 50 |
51 | 52 |
53 | ) 54 | } 55 | 56 | // 🐨 Use React.SuspenseList throughout these Suspending components to make 57 | // them load in a way that is not jaring to the user. 58 | // 💰 there's not really a specifically "right" answer for this. 59 | return ( 60 |
61 | 62 | 63 | 64 | 65 |
66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
76 |
77 |
78 | ) 79 | } 80 | 81 | /* 82 | 🦉 Elaboration & Feedback 83 | After the instruction, copy the URL below into your browser and fill out the form: 84 | http://ws.kcd.im/?ws=Concurrent%20React&e=Coordinate%20Suspending%20components%20with%20SuspenseList&em= 85 | */ 86 | 87 | //////////////////////////////////////////////////////////////////// 88 | // // 89 | // Don't make changes below here. // 90 | // But do look at it to see how your code is intended to be used. // 91 | // // 92 | //////////////////////////////////////////////////////////////////// 93 | 94 | export default App 95 | -------------------------------------------------------------------------------- /src/exercises/07.md: -------------------------------------------------------------------------------- 1 | # Coordinate Suspending components with SuspenseList 2 | 3 | ## Background 4 | 5 | When your app is simple, you can pretty much expect everything to be there and 6 | load together when you need them, and that works nicely. But when your app grows 7 | and you start code splitting and loading data alongside the code that needs it, 8 | pretty soon you end up in situations where you have several things loading all 9 | at once. Having those all pop into place on the page can be a jarring experience 10 | for the user. 11 | 12 | A better experience for the user is a more predictable loading experience, even 13 | if it means that they see the data displayed out of order from how it was 14 | loaded. 15 | 16 | Coordinating these loading states is a really hard problem, but thanks to 17 | Suspense and ``, it's fairly trivial. 18 | 19 | 📜 Example from the React docs: 20 | https://reactjs.org/docs/concurrent-mode-reference.html#suspenselist 21 | 22 | ```jsx 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ``` 35 | 36 | The `SuspenseList` component has the following props: 37 | 38 | - `revealOrder`: the order in which the suspending components are to render 39 | - `{undefined}`: the default behavior: everything pops in when it's loaded (as 40 | if you didn't wrap everything in a `SuspenseList`). 41 | - `"forwards"`: Only show the component when all components before it have 42 | finished suspending. 43 | - `"backwards"`: Only show the component when all the components after it have 44 | finished suspending. 45 | - `"together"`: Don't show any of the components until they've all finished 46 | loading 47 | - `tail`: determines how to show the fallbacks for the suspending components 48 | - `{undefined}`: the default behavior: show all fallbacks 49 | - `"collapsed"`: Only show the fallback for the component that should be 50 | rendered next (this will differ based on the `revealOrder` specified). 51 | - `"hidden"`: Opposite of the default behavior: show none of the fallbacks 52 | - `children`: other react elements which render `` components. 53 | Note: `` components do not have to be direct children as in 54 | the example above. You can wrap them in `
`s or other components if you 55 | need. 56 | 57 | ## Exercise 58 | 59 | In this exercise, we've built Pokemon Banking app and because the app is getting 60 | so large and there's so many dynamic parts, we've decided to codesplit a lot of 61 | it, this makes our app load faster, but it makes the loading experience 62 | sub-optimal. 63 | 64 | Let's play around with `` to coordiante the loading 65 | states. 66 | 67 | 💰 tip, you can nest Suspense lists! Give that a try. 68 | 69 | ## Extra Credit 70 | 71 | ### 💯 eagerly load modules as resources 72 | 73 | You may have noticed that the page actually loads slower when you added the 74 | SuspenseList. Even though it's less janky. The reason for this is because 75 | SuspenseList will avoid rendering children of Suspense Boundaries when using 76 | `forwards` until the "most forward" component is rendered. This results in a 77 | waterfall effect because `React.lazy` is not "eager." The idiomatic use of 78 | `React.lazy` is a perfect example of "fetch on render." 79 | 80 | Hopefully the React team comes out with something better before SuspenseList is 81 | stable (stay up-to-date with https://github.com/facebook/react/issues/17413), 82 | but for now, we need to preload our modules as soon as we know we're going to 83 | need them. 84 | 85 | I've written a handy function that you can use in place of `React.lazy` that 86 | allows you to do this: 87 | 88 | ```javascript 89 | function preloadableLazy(dynamicImport) { 90 | let promise 91 | function load() { 92 | if (!promise) { 93 | promise = dynamicImport() 94 | } 95 | return promise 96 | } 97 | const Comp = React.lazy(load) 98 | Comp.preload = load 99 | return Comp 100 | } 101 | // Usage: 102 | // const LazyComp = preloadableLazy(() => import('./lazy-loaded-component')) 103 | // then, when you need to preload the code: LazyComp.preload() 104 | ``` 105 | 106 | With this, see if you can change the implementation to make use of the 107 | SuspenseList for coordinating suspending components without the drawback of 108 | "fetch on render" with `React.lazy`. 109 | -------------------------------------------------------------------------------- /src/fetch-pokemon.js: -------------------------------------------------------------------------------- 1 | import transactions from './hacks/transactions' 2 | import users from './hacks/users' 3 | import pkg from '../package.json' 4 | // if you need this to work locally then comment out the import above and comment in the next line 5 | // const pkg = {homepage: '/'} 6 | 7 | const sleep = time => new Promise(resolve => setTimeout(resolve, time)) 8 | 9 | const formatDate = date => 10 | `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')} ${String( 11 | date.getSeconds(), 12 | ).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}` 13 | 14 | // the delay argument is for faking things out a bit 15 | function fetchPokemon(name, delay = 1500) { 16 | const endTime = Date.now() + delay 17 | const pokemonQuery = ` 18 | query ($name: String) { 19 | pokemon(name: $name) { 20 | id 21 | number 22 | name 23 | image 24 | attacks { 25 | special { 26 | name 27 | type 28 | damage 29 | } 30 | } 31 | } 32 | } 33 | ` 34 | 35 | return window 36 | .fetch('https://graphql-pokemon.now.sh', { 37 | // learn more about this API here: https://graphql-pokemon.now.sh/ 38 | method: 'POST', 39 | headers: { 40 | 'content-type': 'application/json;charset=UTF-8', 41 | }, 42 | body: JSON.stringify({ 43 | query: pokemonQuery, 44 | variables: {name: name.toLowerCase()}, 45 | }), 46 | }) 47 | .then(response => response.json()) 48 | .then(async response => { 49 | await sleep(endTime - Date.now()) 50 | return response 51 | }) 52 | .then(response => { 53 | const pokemon = response.data.pokemon 54 | if (pokemon) { 55 | pokemon.fetchedAt = formatDate(new Date()) 56 | return pokemon 57 | } else { 58 | return Promise.reject(new Error(`No pokemon with the name "${name}"`)) 59 | } 60 | }) 61 | } 62 | 63 | function getImageUrlForPokemon(pokemonName) { 64 | if (fetch.isHacked) { 65 | return `${pkg.homepage}img/pokemon/${pokemonName.toLowerCase()}.jpg` 66 | } else { 67 | return `https://img.pokemondb.net/artwork/${pokemonName.toLowerCase()}.jpg` 68 | } 69 | } 70 | 71 | async function fetchUser(pokemonName, delay = 0) { 72 | await sleep(delay) 73 | const lowerName = pokemonName.toLowerCase() 74 | const pokemonTransactions = transactions.filter( 75 | t => t.recipient !== lowerName, 76 | ) 77 | const user = users[lowerName] 78 | if (!user) { 79 | throw new Error( 80 | `${pokemonName} is not a user. Try ${Object.keys(users).join(', ')}`, 81 | ) 82 | } 83 | return { 84 | transactions: pokemonTransactions, 85 | friends: Object.keys(users) 86 | .filter(u => lowerName !== u) 87 | .map(n => upperName(n)), 88 | ...user, 89 | name: upperName(lowerName), 90 | } 91 | } 92 | const upperName = name => `${name.slice(0, 1).toUpperCase()}${name.slice(1)}` 93 | 94 | export default fetchPokemon 95 | export {getImageUrlForPokemon, fetchUser} 96 | -------------------------------------------------------------------------------- /src/hacks/fetch.js: -------------------------------------------------------------------------------- 1 | import allPokemon from './pokemon.json' 2 | // Please don't actually do this in a real app 3 | // this is here to make it easy for us to simulate making HTTP calls in this 4 | // little app that doesn't actually have any server element. 5 | const originalFetch = window.fetch 6 | 7 | // Allows us to restore the original fetch 8 | originalFetch.restoreOriginalFetch = () => (window.fetch = originalFetch) 9 | originalFetch.overrideFetch = () => (window.fetch = hackFetch) 10 | 11 | window.FETCH_TIME = 0 12 | window.MIN_FETCH_TIME = 500 13 | window.FETCH_TIME_RANDOM = false 14 | 15 | function sleep(t = window.FETCH_TIME) { 16 | if (window.FETCH_TIME_RANDOM) { 17 | t = Math.random() * t + window.MIN_FETCH_TIME 18 | } 19 | return new Promise(resolve => setTimeout(resolve, t)) 20 | } 21 | 22 | const fakeResponses = [ 23 | { 24 | test: url => url.includes('pokemon'), 25 | handler: async (url, config) => { 26 | const body = JSON.parse(config.body) 27 | await sleep() 28 | const pokemonName = body.variables.name 29 | const pokemon = allPokemon[pokemonName] 30 | if (!pokemon) { 31 | throw new Error( 32 | `🚨 fetch calls are "hacked" so you can work this workshop offline, so we don't support the pokemon with the name "${pokemonName}." We only support: ${Object.keys( 33 | allPokemon, 34 | ).join(', ')}`, 35 | ) 36 | } 37 | return { 38 | status: 200, 39 | json: async () => ({data: {pokemon}}), 40 | } 41 | }, 42 | }, 43 | // fallback to originalFetch 44 | { 45 | test: () => true, 46 | handler: (...args) => originalFetch(...args), 47 | }, 48 | ] 49 | 50 | async function hackFetch(...args) { 51 | const {handler} = fakeResponses.find(({test}) => { 52 | try { 53 | return test(...args) 54 | } catch (error) { 55 | // ignore the error and hope everything's ok... 56 | return false 57 | } 58 | }) 59 | const groupTitle = `%c ${args[1].method} -> ${args[0]}` 60 | try { 61 | const response = await handler(...args) 62 | console.groupCollapsed(groupTitle, 'color: #0f9d58') 63 | let parsedBody 64 | try { 65 | parsedBody = JSON.parse(args[1].body) 66 | } catch (error) { 67 | // ignore 68 | } 69 | console.info('REQUEST:', { 70 | url: args[0], 71 | ...args[1], 72 | ...(parsedBody ? {parsedBody} : null), 73 | }) 74 | console.info('RESPONSE:', { 75 | ...response, 76 | ...(response.json ? {json: await response.json()} : {}), 77 | }) 78 | console.groupEnd() 79 | return response 80 | } catch (error) { 81 | let rejection = error 82 | if (error instanceof Error) { 83 | rejection = { 84 | status: 500, 85 | message: error.message, 86 | } 87 | } 88 | console.groupCollapsed(groupTitle, 'color: #ef5350') 89 | console.info('REQUEST:', {url: args[0], ...args[1]}) 90 | console.info('REJECTION:', rejection) 91 | console.groupEnd() 92 | return Promise.reject(rejection) 93 | } 94 | } 95 | hackFetch.isHacked = true 96 | Object.assign(hackFetch, window.fetch) 97 | 98 | // alright. Let's hack fetch! 99 | window.fetch.overrideFetch() 100 | -------------------------------------------------------------------------------- /src/hacks/index.js: -------------------------------------------------------------------------------- 1 | import 'stop-runaway-react-effects/hijack' 2 | import './fetch' 3 | -------------------------------------------------------------------------------- /src/hacks/pokemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "pikachu": { 3 | "id": "UG9rZW1vbjowMjU=", 4 | "number": "025", 5 | "name": "Pikachu", 6 | "image": "/img/pokemon/pikachu.jpg", 7 | "attacks": { 8 | "special": [ 9 | { 10 | "name": "Discharge", 11 | "type": "Electric", 12 | "damage": 35 13 | }, 14 | { 15 | "name": "Thunder", 16 | "type": "Electric", 17 | "damage": 100 18 | }, 19 | { 20 | "name": "Thunderbolt", 21 | "type": "Electric", 22 | "damage": 55 23 | } 24 | ] 25 | } 26 | }, 27 | "mew": { 28 | "id": "UG9rZW1vbjoxNTE=", 29 | "number": "151", 30 | "image": "/img/pokemon/mew.jpg", 31 | "name": "Mew", 32 | "attacks": { 33 | "special": [ 34 | { 35 | "name": "Dragon Pulse", 36 | "type": "Dragon", 37 | "damage": 65 38 | }, 39 | { 40 | "name": "Earthquake", 41 | "type": "Ground", 42 | "damage": 100 43 | }, 44 | { 45 | "name": "Fire Blast", 46 | "type": "Fire", 47 | "damage": 100 48 | }, 49 | { 50 | "name": "Hurricane", 51 | "type": "Flying", 52 | "damage": 80 53 | }, 54 | { 55 | "name": "Hyper Beam", 56 | "type": "Normal", 57 | "damage": 120 58 | }, 59 | { 60 | "name": "Moonblast", 61 | "type": "Fairy", 62 | "damage": 85 63 | }, 64 | { 65 | "name": "Psychic", 66 | "type": "Psychic", 67 | "damage": 55 68 | }, 69 | { 70 | "name": "Solar Beam", 71 | "type": "Grass", 72 | "damage": 120 73 | }, 74 | { 75 | "name": "Thunder", 76 | "type": "Electric", 77 | "damage": 100 78 | } 79 | ] 80 | } 81 | }, 82 | "mewtwo": { 83 | "id": "UG9rZW1vbjoxNTA=", 84 | "number": "150", 85 | "image": "/img/pokemon/mewtwo.jpg", 86 | "name": "Mewtwo", 87 | "attacks": { 88 | "special": [ 89 | { 90 | "name": "Hyper Beam", 91 | "type": "Normal", 92 | "damage": 120 93 | }, 94 | { 95 | "name": "Psychic", 96 | "type": "Psychic", 97 | "damage": 55 98 | }, 99 | { 100 | "name": "Shadow Ball", 101 | "type": "Ghost", 102 | "damage": 45 103 | } 104 | ] 105 | } 106 | }, 107 | "ditto": { 108 | "id": "UG9rZW1vbjoxMzI=", 109 | "number": "132", 110 | "image": "/img/pokemon/ditto.jpg", 111 | "name": "Ditto", 112 | "attacks": { 113 | "special": [ 114 | { 115 | "name": "Struggle", 116 | "type": "Normal", 117 | "damage": 15 118 | } 119 | ] 120 | } 121 | }, 122 | "charizard": { 123 | "id": "UG9rZW1vbjowMDY=", 124 | "number": "006", 125 | "name": "Charizard", 126 | "image": "/img/pokemon/charizard.jpg", 127 | "attacks": { 128 | "special": [ 129 | { 130 | "name": "Dragon Claw", 131 | "type": "Dragon", 132 | "damage": 35 133 | }, 134 | { 135 | "name": "Fire Blast", 136 | "type": "Fire", 137 | "damage": 100 138 | }, 139 | { 140 | "name": "Flamethrower", 141 | "type": "Fire", 142 | "damage": 55 143 | } 144 | ] 145 | } 146 | }, 147 | "bulbasaur": { 148 | "id": "UG9rZW1vbjowMDE=", 149 | "number": "001", 150 | "name": "Bulbasaur", 151 | "image": "/img/pokemon/bulbasaur.jpg", 152 | "attacks": { 153 | "special": [ 154 | { 155 | "name": "Power Whip", 156 | "type": "Grass", 157 | "damage": 70 158 | }, 159 | { 160 | "name": "Seed Bomb", 161 | "type": "Grass", 162 | "damage": 40 163 | }, 164 | { 165 | "name": "Sludge Bomb", 166 | "type": "Poison", 167 | "damage": 55 168 | } 169 | ] 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/hacks/transactions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "4P812765GHI029827", 4 | "recipient": "mew", 5 | "amount": "$ 15.34", 6 | "message": "Thanks for the salad 🥗" 7 | }, 8 | { 9 | "id": "90X21040KL118401T", 10 | "recipient": "charizard", 11 | "amount": "$ 20.00", 12 | "message": "Thanks for the tip about Gyarados 🌊. I never would have won otherwise 😈" 13 | }, 14 | { 15 | "id": "89UI190WJJ2240023", 16 | "recipient": "bulbasaur", 17 | "amount": "$ 25.00", 18 | "message": "That play was awesome 🎭. Thanks again for inviting me." 19 | }, 20 | { 21 | "id": "0A6FJI65K8173802P", 22 | "recipient": "ditto", 23 | "amount": "$ 12.21", 24 | "message": "blub." 25 | }, 26 | { 27 | "id": "9CF911038X034441W", 28 | "recipient": "mewtwo", 29 | "amount": "$ 35.00", 30 | "message": "Still can't believe 🔥 Charizard 🔥 won. That's the last time I bet against him." 31 | }, 32 | { 33 | "id": "48L3561JH8132451D", 34 | "recipient": "pikachu", 35 | "amount": "$ 91.10", 36 | "message": "That was ELECTRIC ⚡" 37 | }, 38 | { 39 | "id": "6CG59877V61376422", 40 | "recipient": "ditto", 41 | "amount": "$ 98.89", 42 | "message": "blub." 43 | }, 44 | { 45 | "id": "U8991IJW02J204032", 46 | "recipient": "bulbasaur", 47 | "amount": "$ 15.98", 48 | "message": "Thanks again for the loan 💵" 49 | }, 50 | { 51 | "id": "8XS08JI93J918102S", 52 | "recipient": "charizard", 53 | "amount": "$ 45.14", 54 | "message": "Lunch was delicious, thank you!" 55 | }, 56 | { 57 | "id": "21CWW205ND917964J", 58 | "recipient": "mew", 59 | "amount": "$ 12.87", 60 | "message": "Thanks again for the ride 🍃" 61 | } 62 | ] 63 | -------------------------------------------------------------------------------- /src/hacks/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "pikachu": { 3 | "name": "Pikachu", 4 | "color": "#EDD37E" 5 | }, 6 | "mew": { 7 | "name": "Mew", 8 | "color": "#ECC4D0" 9 | }, 10 | "mewtwo": { 11 | "name": "Mewtwo", 12 | "color": "#BAABBA" 13 | }, 14 | "ditto": { 15 | "name": "Ditto", 16 | "color": "#BDAED1" 17 | }, 18 | "charizard": { 19 | "name": "Charizard", 20 | "color": "#EAC492" 21 | }, 22 | "bulbasaur": { 23 | "name": "Bulbasaur", 24 | "color": "#7DAD96" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './hacks' 2 | import 'normalize.css/normalize.css' 3 | import './styles.css' 4 | import React from 'react' 5 | import ReactDOM from 'react-dom' 6 | import MainApp from './app' 7 | 8 | const rootEl = document.getElementById('⚛') 9 | const root = ReactDOM.createRoot(rootEl) 10 | root.render() 11 | 12 | // to enable sync mode, comment out the above stuff and comment this in. 13 | // ReactDOM.render(, rootEl) 14 | -------------------------------------------------------------------------------- /src/load-exercises.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const glob = require('glob') 4 | 5 | const exerciseInfo = glob 6 | .sync('./src/exercises*/[0-9][0-9]*.js') 7 | .reduce((acc, filePath) => { 8 | const contents = String(fs.readFileSync(filePath)) 9 | const {dir, name} = path.parse(filePath) 10 | const parentDir = path.basename(dir) 11 | const id = name.split('-')[0] 12 | const number = Number(id) 13 | const previous = String(number - 1).padStart(2, '0') 14 | const next = String(number + 1).padStart(2, '0') 15 | const isExercise = parentDir === 'exercises' 16 | const isExtraCredit = name.includes('-extra') 17 | const isFinal = !isExercise && !isExtraCredit 18 | const [firstLine, secondLine] = contents.split('\n') 19 | const title = firstLine.replace(/\/\//, '').trim() 20 | const extraCreditTitle = secondLine.replace(/\/\/ 💯/, '').trim() 21 | const extraCreditNumber = (name.match(/-extra.(\d+)/) || [])[1] 22 | 23 | acc[id] = acc[id] || {} 24 | 25 | Object.assign( 26 | acc[id], 27 | isExercise 28 | ? { 29 | title, 30 | exercise: { 31 | previous, 32 | next, 33 | isolatedPath: `/isolated/exercises/${name}`, 34 | }, 35 | } 36 | : null, 37 | isFinal 38 | ? { 39 | final: { 40 | previous, 41 | next, 42 | isolatedPath: `/isolated/exercises-final/${name}`, 43 | }, 44 | } 45 | : null, 46 | isExtraCredit 47 | ? { 48 | extraCreditTitles: { 49 | ...acc[id].extraCreditTitles, 50 | [extraCreditNumber]: extraCreditTitle, 51 | }, 52 | } 53 | : null, 54 | ) 55 | return acc 56 | }, {}) 57 | 58 | // get rid of next and previous which don't exist 59 | for (const infoKey in exerciseInfo) { 60 | const info = exerciseInfo[infoKey] 61 | if (!exerciseInfo[info.exercise.previous]) { 62 | delete info.exercise.previous 63 | } 64 | if (!exerciseInfo[info.exercise.next]) { 65 | delete info.exercise.next 66 | } 67 | if (!exerciseInfo[info.final.previous]) { 68 | delete info.final.previous 69 | } 70 | if (!exerciseInfo[info.final.next]) { 71 | delete info.final.next 72 | } 73 | } 74 | 75 | module.exports = exerciseInfo 76 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | 3 | afterEach(() => { 4 | jest.clearAllMocks() 5 | }) 6 | 7 | jest.spyOn(window, 'alert').mockImplementation(() => {}) 8 | 9 | // none of these tests should actually invoke fetch 10 | beforeEach(() => { 11 | jest.spyOn(window, 'fetch').mockImplementation((...args) => { 12 | console.warn('window.fetch is not mocked for this call', ...args) 13 | return Promise.reject(new Error('This must be mocked!')) 14 | }) 15 | }) 16 | 17 | afterEach(() => { 18 | window.fetch.mockRestore() 19 | }) 20 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: #cc0000; 3 | } 4 | 5 | a:focus, 6 | a:hover, 7 | a:active { 8 | color: #8a0000; 9 | } 10 | 11 | input { 12 | line-height: 2; 13 | font-size: 16px; 14 | box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); 15 | border: none; 16 | border-radius: 2px; 17 | padding-left: 10px; 18 | padding-right: 10px; 19 | background-color: #eee; 20 | } 21 | 22 | button { 23 | font-size: 1rem; 24 | font-family: inherit; 25 | border: 1px solid #ff0000; 26 | background-color: #cc0000; 27 | cursor: pointer; 28 | padding: 8px 10px; 29 | color: #eee; 30 | border-radius: 3px; 31 | } 32 | 33 | button:disabled { 34 | border-color: #dc9494; 35 | background-color: #f16161; 36 | cursor: unset; 37 | } 38 | 39 | button:hover:not(:disabled), 40 | button:active:not(:disabled), 41 | button:focus:not(:disabled) { 42 | border-color: #cc0000; 43 | background-color: #8a0000; 44 | } 45 | 46 | .isolated-top-container { 47 | padding: 30px; 48 | height: 100vh; 49 | display: grid; 50 | align-items: center; 51 | justify-content: center; 52 | } 53 | 54 | .totally-centered { 55 | width: 100%; 56 | height: 100%; 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | } 61 | 62 | .pokemon-form { 63 | display: flex; 64 | flex-direction: column; 65 | align-items: center; 66 | } 67 | 68 | .pokemon-form input { 69 | margin-top: 10px; 70 | margin-right: 10px; 71 | } 72 | 73 | .pokemon-info { 74 | height: 400px; 75 | width: 300px; 76 | overflow: scroll; 77 | background-color: #eee; 78 | border-radius: 4px; 79 | padding: 10px; 80 | position: relative; 81 | } 82 | 83 | .pokemon-info.pokemon-loading { 84 | opacity: 0.6; 85 | transition: opacity 0s; 86 | /* note: the transition delay is the same as the busyDelayMs config */ 87 | transition-delay: 0.4s; 88 | } 89 | 90 | .pokemon-info h2 { 91 | font-weight: bold; 92 | text-align: center; 93 | margin-top: 0.3em; 94 | } 95 | 96 | .pokemon-info img { 97 | max-width: 100%; 98 | max-height: 200px; 99 | } 100 | 101 | .pokemon-info .pokemon-info__img-wrapper { 102 | text-align: center; 103 | margin-top: 20px; 104 | } 105 | 106 | .pokemon-info .pokemon-info__fetch-time { 107 | position: absolute; 108 | top: 6px; 109 | right: 10px; 110 | } 111 | 112 | button.invisible-button { 113 | border: none; 114 | padding: inherit; 115 | font-size: inherit; 116 | font-family: inherit; 117 | cursor: pointer; 118 | font-weight: inherit; 119 | background-color: transparent; 120 | color: #000; 121 | } 122 | button.invisible-button:hover, 123 | button.invisible-button:active, 124 | button.invisible-button:focus { 125 | border: none; 126 | background-color: transparent; 127 | } 128 | -------------------------------------------------------------------------------- /src/suspense-list/app.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | background-color: #eee; 3 | min-height: 100%; 4 | } 5 | 6 | .mainContentArea { 7 | display: flex; 8 | justify-content: space-between; 9 | } 10 | 11 | .mainContentArea > *:not(:last-child) { 12 | margin-right: 20px; 13 | } 14 | 15 | .mainContentArea > *:not(:first-child) { 16 | margin-left: 20px; 17 | } 18 | 19 | .spinnerContainer { 20 | flex: 1; 21 | padding-top: 10px; 22 | } 23 | -------------------------------------------------------------------------------- /src/suspense-list/img.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {createResource} from '../utils' 3 | 4 | function preloadImage(src) { 5 | return new Promise(resolve => { 6 | const img = document.createElement('img') 7 | img.src = src 8 | img.onload = () => resolve(src) 9 | }) 10 | } 11 | 12 | const imgSrcResourceCache = {} 13 | 14 | function Img({src, alt, ...props}) { 15 | let imgSrcResource = imgSrcResourceCache[src] 16 | if (!imgSrcResource) { 17 | imgSrcResource = createResource(() => preloadImage(src)) 18 | imgSrcResourceCache[src] = imgSrcResource 19 | } 20 | return {alt} 21 | } 22 | 23 | export default Img 24 | -------------------------------------------------------------------------------- /src/suspense-list/left-nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as cn from './left-nav.module.css' 3 | 4 | function LeftNav() { 5 | return ( 6 |
7 |
Home
8 |
9 | 29 |
30 | ) 31 | } 32 | 33 | export default LeftNav 34 | 35 | /* 36 | eslint 37 | jsx-a11y/anchor-is-valid:0 38 | */ 39 | -------------------------------------------------------------------------------- /src/suspense-list/left-nav.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | min-width: 300px; 3 | padding-left: 30px; 4 | } 5 | 6 | .title { 7 | font-size: 40px; 8 | font-weight: bold; 9 | } 10 | 11 | .list { 12 | list-style: none; 13 | padding: 0; 14 | font-size: 20px; 15 | } 16 | 17 | .list li:before { 18 | content: ''; 19 | display: inline-block; 20 | height: 0.8rem; 21 | width: 0.8rem; 22 | margin-right: 6px; 23 | background-image: url(/img/pokeball.png); 24 | background-size: contain; 25 | background-repeat: no-repeat; 26 | } 27 | 28 | .list li { 29 | margin-bottom: 24px; 30 | } 31 | 32 | .list li a { 33 | text-decoration: none; 34 | } 35 | -------------------------------------------------------------------------------- /src/suspense-list/main-content.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Img from './img' 3 | import * as cn from './main-content.module.css' 4 | 5 | function MainContent({pokemonResource}) { 6 | const pokemon = pokemonResource.read() 7 | return ( 8 |
9 |
10 |
11 |
12 |
Watch out for Go players!
13 |
14 | pokemon go 15 |
16 |
17 |
18 |
Collector's Squirtle Toy
19 |
20 | squirtle figurine 21 |
22 |
23 |
24 |
25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 |
34 |
35 | 36 |
37 |
38 |
39 | {pokemon.transactions.map(t => ( 40 | 41 | ))} 42 |
43 |
44 | ) 45 | } 46 | 47 | function Transaction({id, recipient, amount, message}) { 48 | return ( 49 |
50 |
{id}
51 |
52 | {recipient} 57 |
58 |
59 |
{amount}
60 |
{message}
61 |
62 |
63 | ) 64 | } 65 | 66 | export default MainContent 67 | -------------------------------------------------------------------------------- /src/suspense-list/main-content.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | flex: 1; 3 | display: flex; 4 | justify-content: center; 5 | max-width: 600px; 6 | } 7 | 8 | .container { 9 | max-width: 100%; 10 | } 11 | 12 | .quickLook { 13 | display: flex; 14 | justify-content: space-between; 15 | margin-bottom: 30px; 16 | } 17 | 18 | .quickLook > * { 19 | text-align: center; 20 | } 21 | 22 | .quickLook > *:not(:last-child) { 23 | margin-right: 16px; 24 | } 25 | 26 | .quickLook > *:not(:first-child) { 27 | margin-left: 16px; 28 | } 29 | 30 | .quickLookTitle { 31 | margin-bottom: 10px; 32 | font-weight: bold; 33 | font-size: 1.25rem; 34 | } 35 | 36 | .quickLook img { 37 | height: 160px; 38 | } 39 | 40 | .createNewTransaction { 41 | margin-bottom: 30px; 42 | } 43 | 44 | .createNewTransaction form { 45 | display: flex; 46 | justify-content: center; 47 | } 48 | 49 | .createNewTransaction form > *:not(:last-child) { 50 | margin-right: 6px; 51 | } 52 | 53 | .createNewTransaction form > *:not(:first-child) { 54 | margin-left: 6px; 55 | } 56 | 57 | .newTransactionSubmitButton { 58 | align-self: flex-end; 59 | } 60 | 61 | .createNewTransaction label { 62 | display: block; 63 | } 64 | 65 | .newTransactionSubmitButton button { 66 | font-size: 1.15rem; 67 | height: 57px; 68 | } 69 | 70 | .createNewTransaction input { 71 | font-size: 1.15rem; 72 | line-height: 3; 73 | } 74 | 75 | .transaction { 76 | position: relative; 77 | display: flex; 78 | background-color: #fff; 79 | border: 1px solid rgba(0, 0, 0, 0.125); 80 | border-radius: 0.25rem; 81 | padding: 30px; 82 | margin-bottom: 30px; 83 | } 84 | 85 | .transactionId { 86 | position: absolute; 87 | top: 10px; 88 | right: 10px; 89 | color: #aaa; 90 | } 91 | 92 | .transactionImage { 93 | margin-right: 20px; 94 | height: 80px; 95 | border-radius: 2px; 96 | box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); 97 | } 98 | 99 | .transactionAmount { 100 | font-size: 40px; 101 | font-weight: bold; 102 | } 103 | 104 | .transactionMessage { 105 | font-size: 30px; 106 | color: #777; 107 | } 108 | -------------------------------------------------------------------------------- /src/suspense-list/nav-bar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Img from './img' 3 | import * as cn from './nav-bar.module.css' 4 | import { 5 | IoIosHome, 6 | IoIosNotifications, 7 | IoIosFiling, 8 | IoIosList, 9 | IoIosSync, 10 | IoIosCopy, 11 | IoIosCog, 12 | } from 'react-icons/io' 13 | 14 | function NavBar({pokemonResource}) { 15 | const pokemon = pokemonResource.read() 16 | return ( 17 |
18 |
19 | 22 | 23 |
24 |
25 | 31 | 37 | 43 | 53 | 59 | 65 | 71 |
72 |
73 | 79 |
80 |
81 | ) 82 | } 83 | 84 | export default NavBar 85 | -------------------------------------------------------------------------------- /src/suspense-list/nav-bar.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | height: 100px; 3 | width: 100%; 4 | background-color: #fff; 5 | display: flex; 6 | justify-content: space-between; 7 | align-items: center; 8 | padding: 6px 10px; 9 | box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.4); 10 | margin-bottom: 20px; 11 | } 12 | 13 | .root > * { 14 | flex: 1; 15 | text-align: center; 16 | } 17 | 18 | .root > *:last-child { 19 | text-align: right; 20 | } 21 | 22 | .root > *:first-child { 23 | text-align: left; 24 | } 25 | 26 | .root button { 27 | border: none; 28 | color: black; 29 | background-color: transparent; 30 | font-size: 30px; 31 | cursor: pointer; 32 | } 33 | 34 | .root button:focus, 35 | .root button:active, 36 | .root button:hover { 37 | opacity: 0.8; 38 | background-color: transparent; 39 | } 40 | 41 | .logoAndSearch { 42 | display: flex; 43 | align-items: center; 44 | } 45 | 46 | .logoAndSearch img { 47 | height: 40px; 48 | margin-right: 10px; 49 | box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); 50 | border-radius: 50%; 51 | } 52 | 53 | .logoAndSearch input { 54 | background-color: #eee; 55 | width: 250px; 56 | } 57 | 58 | .centerButtons { 59 | display: flex; 60 | justify-content: center; 61 | } 62 | 63 | .centerButtons > * { 64 | margin-left: 8px; 65 | margin-right: 8px; 66 | } 67 | 68 | .profilePhoto { 69 | border-radius: 50%; 70 | height: 60px; 71 | } 72 | -------------------------------------------------------------------------------- /src/suspense-list/right-nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Img from './img' 3 | import * as cn from './right-nav.module.css' 4 | 5 | function RightNav({pokemonResource}) { 6 | const pokemon = pokemonResource.read() 7 | return ( 8 |
9 |
10 |
Check Split
11 |
Go out to eat with your friends and split the check.
12 |
13 | pokemon cafe 14 |
15 |
16 |
17 |
Friends
18 |
    19 | {pokemon.friends.map(friend => ( 20 |
  • 21 | 22 |
  • 23 | ))} 24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | function Friend({name}) { 31 | return ( 32 | 33 |
34 | {name} 39 |
40 | {name} 41 |
42 | ) 43 | } 44 | 45 | export default RightNav 46 | 47 | /* 48 | eslint 49 | jsx-a11y/anchor-is-valid: 0 50 | */ 51 | -------------------------------------------------------------------------------- /src/suspense-list/right-nav.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | min-width: 200px; 3 | padding-right: 30px; 4 | } 5 | 6 | .checkSplitTitle { 7 | font-size: 1.5rem; 8 | font-weight: bold; 9 | } 10 | 11 | .cafeContainer { 12 | max-width: 100%; 13 | text-align: center; 14 | margin-top: 10px; 15 | margin-bottom: 20px; 16 | } 17 | 18 | .cafeContainer img { 19 | border-radius: 4px; 20 | max-width: 260px; 21 | } 22 | 23 | .friendTitle { 24 | font-weight: bold; 25 | font-size: 1.3rem; 26 | margin-bottom: 10px; 27 | } 28 | .friendList { 29 | list-style: none; 30 | padding: 0; 31 | } 32 | 33 | .friendList li { 34 | margin-bottom: 20px; 35 | } 36 | .friendLink { 37 | font-size: 1.2rem; 38 | display: flex; 39 | align-items: center; 40 | } 41 | .friendLink:hover, 42 | .friendLink:active, 43 | .friendLink:focus { 44 | opacity: 0.8; 45 | } 46 | .friendPhotoContainer { 47 | display: inline-block; 48 | margin-right: 20px; 49 | } 50 | .friendPhoto { 51 | height: 50px; 52 | width: 50px; 53 | border-radius: 50%; 54 | object-fit: cover; 55 | } 56 | -------------------------------------------------------------------------------- /src/suspense-list/spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as cn from './spinner.module.css' 3 | 4 | function Spinner() { 5 | return loading 6 | } 7 | 8 | export default Spinner 9 | -------------------------------------------------------------------------------- /src/suspense-list/spinner.module.css: -------------------------------------------------------------------------------- 1 | /* inspired by https://codepen.io/igorsheg/pen/MBpwGw */ 2 | 3 | .pulse { 4 | margin: auto; 5 | display: block; 6 | width: 40px; 7 | height: 40px; 8 | border-radius: 50%; 9 | box-shadow: 0 0 0 rgba(138, 0, 0, 0.4); 10 | animation: pulse 1.3s infinite; 11 | } 12 | 13 | @keyframes pulse { 14 | 0% { 15 | box-shadow: 0 0 0 0 rgba(138, 0, 0, 0.4); 16 | } 17 | 70% { 18 | box-shadow: 0 0 0 25px rgba(138, 0, 0, 0); 19 | } 20 | 100% { 21 | box-shadow: 0 0 0 0 rgba(138, 0, 0, 0); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/suspense-list/style-overrides.css: -------------------------------------------------------------------------------- 1 | #⚛ { 2 | height: 100vh; 3 | } 4 | 5 | body #⚛ .isolated-top-container { 6 | padding: 0; 7 | display: unset; 8 | align-items: unset; 9 | justify-content: unset; 10 | } 11 | 12 | body #⚛ .isolated-div-wrapper { 13 | height: 100vh; 14 | } 15 | 16 | input { 17 | background-color: white; 18 | } 19 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import pkg from '../package.json' 3 | // if you need this to work locally then comment out the import above and comment in the next line 4 | // const pkg = {homepage: '/'} 5 | 6 | // You really only get the benefit of pre-loading an image when the cache-control 7 | // is set to cache the image for some period of time. We can't do that with our 8 | // local server, but we are hosting the images on netlify so we can use those 9 | // instead. Note our public/_headers file that forces these to cache. 10 | const fallbackImgUrl = `${pkg.homepage}img/pokemon/fallback-pokemon.jpg` 11 | preloadImage(`${pkg.homepage}img/pokeball.png`) 12 | preloadImage(fallbackImgUrl) 13 | 14 | // this is just a hacky error boundary for handling any errors in the app 15 | // it just shows "there was an error" with a button to try and re-render 16 | // the whole app over again. 17 | // In a regular app, I recommend using https://npm.im/react-error-boundary 18 | // and reporting errors to a monitoring service. 19 | class ErrorBoundary extends React.Component { 20 | state = {error: null} 21 | static getDerivedStateFromError(error) { 22 | return {error} 23 | } 24 | componentDidCatch() { 25 | // log the error to the server 26 | } 27 | tryAgain = () => this.setState({error: null}) 28 | render() { 29 | return this.state.error ? ( 30 |
31 | There was an error. 32 |
{this.state.error.message}
33 |
34 | ) : ( 35 | this.props.children 36 | ) 37 | } 38 | } 39 | 40 | function PokemonInfoFallback({name}) { 41 | const initialName = React.useRef(name).current 42 | const fallbackPokemonData = { 43 | name: initialName, 44 | number: 'XXX', 45 | attacks: { 46 | special: [ 47 | {name: 'Loading Attack 1', type: 'Type', damage: 'XX'}, 48 | {name: 'Loading Attack 2', type: 'Type', damage: 'XX'}, 49 | ], 50 | }, 51 | fetchedAt: 'loading...', 52 | } 53 | return ( 54 |
55 |
56 | {initialName} 57 |
58 | 59 |
60 | ) 61 | } 62 | 63 | function PokemonDataView({pokemon}) { 64 | return ( 65 | <> 66 |
67 |

68 | {pokemon.name} 69 | {pokemon.number} 70 |

71 |
72 |
73 |
    74 | {pokemon.attacks.special.map(attack => ( 75 |
  • 76 | :{' '} 77 | 78 | {attack.damage} ({attack.type}) 79 | 80 |
  • 81 | ))} 82 |
83 |
84 | {pokemon.fetchedAt} 85 | 86 | ) 87 | } 88 | 89 | // 🚨 This should NOT be copy/pasted for production code and is only here 90 | // for experimentation purposes. The API for suspense (currently throwing a 91 | // promise) is likely to change before suspense is officially released. 92 | // This was strongly inspired by work done in the React Docs by Dan Abramov 93 | function createResource(asyncFn) { 94 | let status = 'pending' 95 | let result 96 | let promise = asyncFn().then( 97 | r => { 98 | status = 'success' 99 | result = r 100 | }, 101 | e => { 102 | status = 'error' 103 | result = e 104 | }, 105 | ) 106 | return { 107 | read() { 108 | if (status === 'pending') throw promise 109 | if (status === 'error') throw result 110 | if (status === 'success') return result 111 | throw new Error('This should be impossible') 112 | }, 113 | } 114 | } 115 | 116 | function preloadImage(src) { 117 | return new Promise(resolve => { 118 | const img = document.createElement('img') 119 | img.src = src 120 | img.onload = () => resolve(src) 121 | }) 122 | } 123 | 124 | function PokemonForm({initialPokemonName = '', onSubmit}) { 125 | const [pokemonName, setPokemonName] = React.useState(initialPokemonName) 126 | 127 | function handleChange(e) { 128 | setPokemonName(e.target.value) 129 | } 130 | 131 | function handleSubmit(e) { 132 | e.preventDefault() 133 | onSubmit(pokemonName) 134 | } 135 | 136 | function handleSelect(newPokemonName) { 137 | setPokemonName(newPokemonName) 138 | onSubmit(newPokemonName) 139 | } 140 | 141 | return ( 142 |
143 | 144 | 145 | Try{' '} 146 | 153 | {', '} 154 | 161 | {', or '} 162 | 169 | 170 |
171 | 179 | 182 |
183 |
184 | ) 185 | } 186 | 187 | export { 188 | ErrorBoundary, 189 | PokemonInfoFallback, 190 | createResource, 191 | preloadImage, 192 | PokemonForm, 193 | PokemonDataView, 194 | } 195 | --------------------------------------------------------------------------------