Lauro SilvaLauro Silva

These notes are taken from attending Kent’s React Suspense Workshop.

Note repo: https://github.com/laurosilvacom/react-suspense

React Suspense is a primitive built-into React that drastically simplifies asynchronous state management in our applications, and helps you avoid FOLC out of the box.

1. Simple Data-fetching

  • Here’s the basic idea of React Suspense:
  • A <Suspense> component that lets you “wait” for some code to load and declaratively specify a loading state (like a spinner) while we’re waiting. Example:
1function Component() {
2 if (data) {
3 return <div>{data.message}</div>
4 }
5 throw promise
6 // React with catch this, find the closest "Suspense" component
7 // and "suspend" everything from there down from rendering until the
8 // promise resolves.
9 // 🚨 THIS "API" IS LIKELY TO CHANGE
10}
11
12ReactDOM.createRoot(rootEl).render(
13 <React.Suspense fallback={<div>loading...</div>}>
14 <Component />
15 </React.Suspense>
16)
  • Where the data and promise values are coming from all depends on how you implement things.

  • Imagine when your app loads, you need some data before you can show anything useful. Typically we want to put the data loading requirements right in the component that requires the data, via something like this:

1React.useEffect(() => {
2 let current = true
3 setState({ status: "pending" })
4 doAsyncThing().then(
5 (p) => {
6 if (current) setState({ pokemon: p, status: "primary" })
7 },
8 (e) => {
9 if (current) setState({ error: e, status: "error" })
10 }
11 )
12 return () => (current = false)
13}, [pokemonName])
14
15// render stuff based on the state
  • The best approaches to using Suspense involve kicking off the request for the data as soon as you have the information you need for the request. This is called the “Render as you fetch” approach.

  • Final code of lesson 1. This handles error handler.

1let pokemon
2let pokemonPromise = fetchPokemon("pikachu").then((p) => (pokemon = p))
3
4function PokemonInfo() {
5 if (!pokemon) {
6 throw pokemonPromise
7 }
8 return (
9 <div>
10 <div className="pokemon-info__img-wrapper">
11 <img src={pokemon.image} alt={pokemon.name} />
12 </div>
13 <PokemonDataView pokemon={pokemon} />
14 </div>
15 )
16}
17
18function App() {
19 return (
20 <div className="pokemon-info-app">
21 <div className="pokemon-info">
22 <React.Suspense fallback={<div>Loading Pokemon...</div>}>
23 <PokemonInfo />
24 </React.Suspense>
25 </div>
26 </div>
27 )
28}
29
30export default App

2. Render as you fetch

  • Here’s the ultimate goal we’re looking for.

  • Get the data as soon as you have the information you need for the data.

    • This sounds obvious, but if you think about it, how often do you have a component that requests data once it’s been mounted. There’s a few milliseconds between the time you click “go” and the time that component is mounted… Unless that component’s code is lazy-loaded.
  • “Render as you fetch” is intended to fix this problem because you can make the request for the code and the data at the same time.

  • Final code should look something like this:

1function App() {
2 const [pokemonName, setPokemonName] = React.useState(null)
3 const [pokemonResource, setPokemonResource] = React.useState(null)
4
5 function handleSubmit(newPokemonName) {
6 setPokemonName(newPokemonName)
7 setPokemonResource(createPokemonResource(newPokemonName))
8 }
9
10 return (
11 <div className="pokemon-info-app">
12 <PokemonForm onSubmit={handleSubmit} />
13 <hr />
14 <div className="pokemon-info">
15 {pokemonResource ? (
16 <ErrorBoundary>
17 <React.Suspense
18 fallback={<PokemonInfoFallback name={pokemonName} />}
19 >
20 <PokemonInfo pokemonResource={pokemonResource} />
21 </React.Suspense>
22 </ErrorBoundary>
23 ) : (
24 "Submit a pokemon"
25 )}
26 </div>
27 </div>
28 )
29}
30
31export default App

3. useTransition for improved loading states

  • When a component suspends, it’s literally telling React: “Don’t render any updates at all from the suspense component on down until I’m ready to roll.”

  • And here’s how it looks like:

1const SUSPENSE_CONFIG = { timeoutMs: 4000 }
2
3function Component() {
4 const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG)
5 // etc...
6
7 function handleClick() {
8 // do something that triggers some interum state change we want to
9 // happen before suspending starts
10 startTransition(() => {
11 // do something that triggers a suspending component to render
12 })
13 }
14
15 // if needed, you can use the `isPending` boolean to display a loading spinner
16 // or similar
17}
  • React Suspense with Concurrent mode comes with a default optimization that is optimistic in that it waits a tiny bit for your suspending promise to resolve before making any DOM updates. But this can make the app feel unresponsive for some use cases.

  • The experimental useTransition hook from React gives us more fine-grained control over the timing as well as the ability to show a pending state. Let’s try that out here.

4. Cache resources

  • Caching is a really hard problem, but it’s important for good user experiences to make the app snappy, especially when you know that the data you’re showing to the user is unchanged on the server.

  • similar for this exercise:

1const promiseCache = {}
2function MySuspendingComponent({ value }) {
3 let resource = promiseCache[value]
4 if (!resource) {
5 resource = doAsyncThing(value)
6 promiseCache[value] = resource // <-- this is very important
7 }
8 return <div>{resource.read()}</div>
9}
  • Final solution:
1function App() {
2 const [pokemonName, setPokemonName] = React.useState("")
3 const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG)
4 const [pokemonResource, setPokemonResource] = React.useState(null)
5
6 function handleSubmit(newPokemonName) {
7 setPokemonName(newPokemonName)
8 startTransition(() => {
9 setPokemonResource(getPokemonResource(newPokemonName))
10 })
11 }
12
13 return (
14 <div className="pokemon-info-app">
15 <PokemonForm onSubmit={handleSubmit} />
16 <hr />
17 <div className={`pokemon-info ${isPending ? "pokemon-loading" : ""}`}>
18 {pokemonResource ? (
19 <ErrorBoundary>
20 <React.Suspense
21 fallback={<PokemonInfoFallback name={pokemonName} />}
22 >
23 <PokemonInfo pokemonResource={pokemonResource} />
24 </React.Suspense>
25 </ErrorBoundary>
26 ) : (
27 "Submit a pokemon"
28 )}
29 </div>
30 </div>
31 )
32}

5. Suspense Image

  • Now that we know how to preload images, we can create a custom

    component which suspends until the image has been loaded into the browser cache.

  • This way we avoid issues where contents bounce around when the image is loads and ensure that there aren’t consistency issues with the data and the image that’s displayed.

  • Suspense can help us with this too! Luckily for us, we can pre-load images into the browser’s cache using the following code:

1function preloadImage(src) {
2 return new Promise((resolve) => {
3 const img = document.createElement("img")
4 img.src = src
5 img.onload = () => resolve(src)
6 })
7}
  • Final code:
1function getPokemonResource(name) {
2 const lowerName = name.toLowerCase()
3 let resource = pokemonResourceCache[lowerName]
4 if (!resource) {
5 resource = createPokemonResource(lowerName)
6 pokemonResourceCache[lowerName] = resource
7 }
8 return resource
9}
10
11function createPokemonResource(pokemonName) {
12 return createResource(() => fetchPokemon(pokemonName))
13}
14
15function App() {
16 const [pokemonName, setPokemonName] = React.useState("")
17 const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG)
18 const [pokemonResource, setPokemonResource] = React.useState(null)
19
20 function handleSubmit(newPokemonName) {
21 setPokemonName(newPokemonName)
22 startTransition(() => {
23 setPokemonResource(getPokemonResource(newPokemonName))
24 })
25 }
26
27 return (
28 <div className="pokemon-info-app">
29 <PokemonForm onSubmit={handleSubmit} />
30 <hr />
31 <div className={`pokemon-info ${isPending ? "pokemon-loading" : ""}`}>
32 {pokemonResource ? (
33 <ErrorBoundary>
34 <React.Suspense
35 fallback={<PokemonInfoFallback name={pokemonName} />}
36 >
37 <PokemonInfo pokemonResource={pokemonResource} />
38 </React.Suspense>
39 </ErrorBoundary>
40 ) : (
41 "Submit a pokemon"
42 )}
43 </div>
44 </div>
45 )
46}

6. Suspense with a custom hook

  • React Hooks are amazing. Combine them with React Suspense, and you get some really awesome APIs.
1function usePokemonResource(pokemonName) {
2 const [pokemonResource, setPokemonResource] = React.useState(null)
3 const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG)
4
5 React.useEffect(() => {
6 if (!pokemonName) {
7 return
8 }
9 startTransition(() => {
10 setPokemonResource(getPokemonResource(pokemonName))
11 })
12 }, [pokemonName, startTransition])
13
14 return [pokemonResource, isPending]
15}
16
17function App() {
18 const [pokemonName, setPokemonName] = React.useState("")
19
20 const [pokemonResource, isPending] = usePokemonResource(pokemonName)
21
22 function handleSubmit(newPokemonName) {
23 setPokemonName(newPokemonName)
24 }
25
26 return (
27 <div className="pokemon-info-app">
28 <PokemonForm onSubmit={handleSubmit} />
29 <hr />
30 <div className={`pokemon-info ${isPending ? "pokemon-loading" : ""}`}>
31 {pokemonResource ? (
32 <ErrorBoundary>
33 <React.Suspense
34 fallback={<PokemonInfoFallback name={pokemonName} />}
35 >
36 <PokemonInfo pokemonResource={pokemonResource} />
37 </React.Suspense>
38 </ErrorBoundary>
39 ) : (
40 "Submit a pokemon"
41 )}
42 </div>
43 </div>
44 )
45}
46
47export default App

7. Coordinate Suspending components with SuspenseList

  • this delay function just allows us to make a promise take longer to resolve so we can easily play around with the loading time of our code.

  • SuspenseList takes two props:

    • revealOrder (forwards, backwards, together) defines the order in which the SuspenseList children should be revealed.
    • together reveals all of them when they’re ready instead of one by one. tail (collapsed, hidden) dictates how unloaded items in a SuspenseList is shown.
    • By default, SuspenseList will show all fallbacks in the list. collapsed shows only the next fallback in the list. hidden doesn’t show any unloaded items.
1function App() {
2 const [startTransition] = React.useTransition(SUSPENSE_CONFIG)
3 const [pokemonResource, setPokemonResource] = React.useState(null)
4
5 function handleSubmit(pokemonName) {
6 startTransition(() => {
7 setPokemonResource(createResource(() => fetchUser(pokemonName)))
8 })
9 }
10
11 if (!pokemonResource) {
12 return (
13 <div className="pokemon-info-app">
14 <div
15 className={`${cn.root} totally-centered`}
16 style={{ height: "100vh" }}
17 >
18 <PokemonForm onSubmit={handleSubmit} />
19 </div>
20 </div>
21 )
22 }
23
24 return (
25 <div className="pokemon-info-app">
26 <div className={cn.root}>
27 <ErrorBoundary>
28 <React.SuspenseList revealOrder="forwards" tail="collapsed">
29 <React.Suspense fallback={fallback}>
30 <NavBar pokemonResource={pokemonResource} />
31 </React.Suspense>
32 <div className={cn.mainContentArea}>
33 <React.SuspenseList revealOrder="forwards">
34 <React.Suspense fallback={fallback}>
35 <LeftNav />
36 </React.Suspense>
37 <React.SuspenseList revealOrder="together">
38 <React.Suspense fallback={fallback}>
39 <MainContent pokemonResource={pokemonResource} />
40 </React.Suspense>
41 <React.Suspense fallback={fallback}>
42 <RightNav pokemonResource={pokemonResource} />
43 </React.Suspense>
44 </React.SuspenseList>
45 </React.SuspenseList>
46 </div>
47 </React.SuspenseList>
48 </ErrorBoundary>
49 </div>
50 </div>
51 )
52}
53
54export default App

The SuspenseList component has the following props:

  • revealOrder: the order in which the suspending components are to render
    • {undefined}: the default behavior: everything pops in when it’s loaded (as if you didn’t wrap everything in a SuspenseList).
    • "forwards": Only show the component when all components before it have finished suspending.
    • "backwards": Only show the component when all the components after it have finished suspending.
    • "together": Don’t show any of the components until they’ve all finished loading
  • tail: determines how to show the fallbacks for the suspending components
    • {undefined}: the default behavior: show all fallbacks
    • "collapsed": Only show the fallback for the component that should be rendered next (this will differ based on the revealOrder specified).
    • "hidden": Opposite of the default behavior: show none of the fallbacks
  • children: other react elements which render <React.Suspense /> components. Note: <React.Suspense /> components do not have to be direct children as in the example above. You can wrap them in <div />s or other components if you need.

© Lauro Silva, LLC. All rights reserved.