ReactJS JavaScript Reactivity

React Suspense has finally left the experimental Concurrent Mode builds and landed in React 18, and with it, Suspense for data fetching. However, the official way to make full use of it for data is through one of the libraries and frameworks that already support it.

The React team name Relay, Next.js, Hydrogen and Remix, and the data libraries React Query, SWR and Recoil all have a way of loading data with Suspense too. In the React team's own words, "ad hoc data fetching with Suspense is technically possible, but still not recommended as a general strategy." However, Suspense itself is now stable in React, so we can use it directly.

What has landed

A <Suspense> boundary shows a fallback while anything inside it isn't ready to render yet, and shows the real content once it is:

<Suspense fallback={<Spinner />}>
  <LevelPanel id={id} />
</Suspense>
A Suspense boundary

The component inside is written as if its data is already there. When it isn't, the component suspends, React shows the nearest boundary's fallback, and it tries the render again when the data arrives. No loading flags in the component at all.

React 18 is what makes this work properly:

  • Concurrent rendering. There's no separate mode to switch on any more: you create the root with createRoot, and the new behaviour applies wherever you use the new features.
  • Nothing inside a boundary is shown half loaded. React waits until everything inside can render, and shows the fallback until then.
  • Transitions. "If you suspend during a transition, React will prevent already-visible content from being replaced by a fallback." Mark an update with startTransition, and the old screen stays up while the new one loads.
  • Suspense on the server. renderToPipeableStream (for Node) and renderToReadableStream (for edge runtimes) stream the page, sending each boundary's HTML as its data becomes ready.

What hasn't changed is how a component tells React it's waiting. That part still isn't a documented API, which is why the React team point everyone at the libraries.

Loading before Suspense

Suspense itself isn't new: it arrived in React 16.6, but only for React.lazy, to show a fallback while a component's code loaded. For data, every component that loaded something did it itself:

function LevelPanel({ id }) {
  const [level, setLevel] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    setLevel(null)
    setError(null)
    fetchLevel(id).then(
      (level) => { if (!cancelled) setLevel(level) },
      (error) => { if (!cancelled) setError(error) })
    return () => { cancelled = true }
  }, [id])

  if (error) return <p>Couldn't load the level.</p>
  if (!level) return <Spinner />
  return <LevelSettings level={level} />
}
Loading in an effect, with flags

It works, but:

  • Every component that loads something carries the same flags and the same three-way if.
  • The fetch only starts after the component has rendered once with nothing. A child that loads its own data can't start until its parent's data has arrived and it has rendered, so the requests run one after another.
  • The cancelled flag is there because an answer for the old id can arrive after the user has picked a new one. Forget it and the panel shows the wrong level.
  • Changing the id throws away the panel and shows a spinner, even if the new level arrives a moment later.
  • Two components that need the same level fetch it twice.

Moving the loading into Redux with a set of request, success and failure actions shares the data, but the flags just move into the store.

With a library

The libraries keep the data outside the components, in a cache, and suspend for you. With React Query, Suspense is an option, either for every query or for one:

const queryClient = new QueryClient({
  defaultOptions: { queries: { suspense: true } },
})

function LevelPanel({ id }) {
  const { data: level } = useQuery(['level', id], () => fetchLevel(id))
  return <LevelSettings level={level} />
}
React Query with suspense switched on

There are no flags: level is always there by the time the component renders. Loading goes to the nearest <Suspense>, and errors go to the nearest error boundary, with QueryErrorResetBoundary to try again. React Query's documentation still calls its Suspense mode experimental, "same as Suspense for data fetching itself".

SWR has the same option:

const { data: level } = useSWR(['level', id], () => fetchLevel(id), {
  suspense: true,
})
SWR with suspense switched on

In Recoil, an asynchronous selector suspends anything that reads it before it has a value:

const levelQuery = selectorFamily({
  key: 'level',
  get: (id) => () => fetchLevel(id),
})

function LevelPanel({ id }) {
  const level = useRecoilValue(levelQuery(id))
  return <LevelSettings level={level} />
}
A Recoil selector that loads a level

Relay goes furthest. Besides loading a query as the component renders, it can start loading in the event handler, before the component that needs the data has even rendered:

const [levelQueryRef, loadLevelQuery] = useQueryLoader(LevelPanelQuery)

//  in the click handler: start loading now
loadLevelQuery({ id })

//  in the panel: read it, suspending if it hasn't arrived yet
const data = usePreloadedQuery(LevelPanelQuery, levelQueryRef)
Relay starting the load before the render

The frameworks on the React team's list take care of all this for a whole application, tying the loading into routing and server rendering.

Doing it directly

None of this needs a library. Here are the tricks they use, one at a time.

Throwing a promise

A component suspends by throwing a promise while it renders. React catches it at the nearest boundary, shows the fallback, and renders the component again when the promise settles. If it throws an error instead, that goes to the nearest error boundary.

This is the undocumented part, and it has a consequence: a component that suspends the first time it renders hasn't kept anything. React throws its unfinished render away, so a promise stored in its state or a ref would be a new promise on every attempt. The data has to live outside the component.

A cache

So we keep a cache, keyed by what's being loaded, with a record of each promise's state. Reading gives the value if it's there, and throws the promise if it isn't:

const cache = new Map()

function preload(key, fetcher) {
  if (!cache.has(key)) {
    const entry = { status: 'pending' }
    entry.promise = fetcher().then(
      (value) => { entry.status = 'done'; entry.value = value },
      (error) => { entry.status = 'error'; entry.error = error })
    cache.set(key, entry)
  }
  return cache.get(key)
}

function read(key, fetcher) {
  const entry = preload(key, fetcher)
  if (entry.status === 'pending') throw entry.promise
  if (entry.status === 'error') throw entry.error
  return entry.value
}

function invalidate(key) {
  cache.delete(key)
}
A small cache that suspends

Now the panel is as simple as the library versions:

function LevelPanel({ id }) {
  const level = read('level:' + id, () => fetchLevel(id))
  return <LevelSettings level={level} />
}
Reading through the cache

The key does a lot of work. Two components asking for the same level share one request. And when the id changes, the component reads a different key, so an answer for the old id is simply never read: no cancelled flag.

Errors

Errors need an error boundary, which is still a class component. It's worth letting it reset when what it's showing changes, so picking another level tries again:

class ErrorBoundary extends React.Component {
  state = { error: null }

  static getDerivedStateFromError(error) {
    return { error }
  }

  componentDidUpdate(previousProps) {
    //  something different to show, so try again
    if (this.state.error && previousProps.resetKey !== this.props.resetKey) {
      this.setState({ error: null })
    }
  }

  render() {
    return this.state.error ? this.props.fallback : this.props.children
  }
}
An error boundary that resets when its key changes
<ErrorBoundary resetKey={id} fallback={<p>Couldn't load the level.</p>}>
  <Suspense fallback={<Spinner />}>
    <LevelPanel id={id} />
  </Suspense>
</ErrorBoundary>
Loading and errors around the panel

Start loading early

Reading in the component still means the request starts when the component renders. Relay's trick is to start it sooner, as soon as we know it'll be needed, and with the cache that's just a call to preload in the event handler. The component's read then finds the request already on its way.

The same goes for data that one component needs from two places. Start both before waiting for either, and they load side by side instead of one after the other:

function LevelPanel({ id }) {
  //  start both before waiting for either
  preload('sprites:' + id, () => fetchSprites(id))
  const level = read('level:' + id, () => fetchLevel(id))
  const sprites = read('sprites:' + id, () => fetchSprites(id))
  ...
}
Two requests at once (trimmed)

Keep the old screen

Picking another level shouldn't throw the current one away and show a spinner. Making the change a transition keeps the old level on screen until the new one is ready, and isPending says it's on its way, so the old one can be dimmed:

function LevelBrowser({ levels }) {
  const [selectedId, setSelectedId] = useState(levels[0].id)
  const [isPending, startTransition] = useTransition()

  function select(id) {
    //  start loading now, and keep the old level up meanwhile
    preload('level:' + id, () => fetchLevel(id))
    startTransition(() => setSelectedId(id))
  }

  return (
    <div>
      {levels.map((level) => (
        <button key={level.id} onClick={() => select(level.id)}>
          {level.name}
        </button>
      ))}
      <div style={{ opacity: isPending ? 0.5 : 1 }}>
        <ErrorBoundary resetKey={selectedId}
          fallback={<p>Couldn't load the level.</p>}>
          <Suspense fallback={<Spinner />}>
            <LevelPanel id={selectedId} />
          </Suspense>
        </ErrorBoundary>
      </div>
    </div>
  )
}
Preloading, then switching in a transition

The spinner now only shows the very first time, when there's nothing to keep.

Refreshing after a change

When something changes the data, say the level is saved, the cached copy is out of date. Throw it away, and have the component holding the panel render again in a transition: the panel reads the key again, finds nothing, loads it afresh, and the saved level stays on screen until the new copy arrives:

const [version, setVersion] = useState(0)

async function save(level) {
  await saveLevel(level)
  invalidate('level:' + level.id)
  //  render again, reading fresh data
  startTransition(() => setVersion((v) => v + 1))
}
Throwing away a stale copy and loading it again

Things to know

  • Throwing a promise isn't a documented API yet. It's what every library does, but the details could change, which is a good reason to keep it inside a small module like the cache above rather than spread through the app.
  • The cache never forgets anything. A real one needs a size limit, or entries that expire.
  • With server rendering, the cache has to belong to each request, not the whole server, and the data the server loaded has to reach the browser so hydration doesn't load it all again. That's a big part of what the frameworks do for you.
  • Suspense is about reading. Saving still needs its own pending state, from useTransition or a flag, and something to stop a second click while the first save is running.

Put together, that's a cache, keys, an error boundary, preloading and transitions: five small pieces, each simple on its own. That's what the libraries are packaging up, and with them in hand you can see exactly what you're getting, or build just the parts you need.