Understanding React Suspense
23. February, 2023 • 6 min read • Develop
It all comes down to fallbacks
Every app I have worked on ends up with the same scruffy corner: a pile of isLoading booleans, each one owned by a different component, each one rendering a slightly different spinner. Suspense is React's attempt to make "this part isn't ready yet" a thing the framework knows about rather than something you hand-roll.
I already touched on it when I wrote about React 18, but only in passing. The concurrent renderer is what makes it interesting, so it deserves its own article.
What it actually does
You wrap a part of your tree in Suspense and give it a fallback:
<Suspense fallback={<LoadingArticles />}>
<Articles />
</Suspense>React renders <LoadingArticles /> until <Articles />, or anything below it, is ready. When the tree is ready, React swaps the fallback out for the real thing in a single commit. No intermediate state where half the list has appeared and half hasn’t.
The important bit is who decides “ready”. Suspense is not polling your component or inspecting your state. A child signals that it is not ready by throwing a promise during render, and Suspense catches it. You will almost never write that by hand. Something else does it for you.
Not every loading state counts
This is the part that trips people up. Suspense only reacts to a Suspense-enabled source. As of React 18 that means two things in practice:
- Data fetching through a framework that has integrated with it, such as Relay or Next.js.
- Lazy-loading component code with lazy.
A plain fetch inside a useEffect is not one of them. If you write this:
useEffect(() => {
fetch('/api/articles')
.then(res => res.json())
.then(setArticles);
}, []);…then no Suspense boundary anywhere above you will ever show its fallback. The effect runs after the component has already rendered, so as far as React is concerned that render succeeded. You are back to your own isLoading flag.
I find this genuinely annoying, and it is the single thing that keeps Suspense from being useful in the average non-framework app. So for the example below I’ll use lazy, which works everywhere and shows the same mechanics.
A worked example with lazy
A well-built React app splits its code into several smaller bundles and ships only what the user needs for the page in front of them. You get that from a builder like Webpack or Vite, and lazy is how you tell React about the split point.
Start with something to load. Nothing clever here on purpose:
// Articles.tsx
import React from 'react';
const Articles = () => {
return <p>A list of articles.</p>;
};
export default Articles;Then the app that lazily imports it. I’ve added an artificial two-second delay so the fallback is actually visible on a fast connection, otherwise you blink and miss it:
// App.tsx
import React, { lazy } from 'react';
const Articles = lazy(async () => {
await new Promise(resolve => setTimeout(resolve, 2000));
return import('./Articles');
});
export const DemoAppLoading = () => <p>🌀 Loading...</p>;
export const DemoApp = () => {
return <Articles />;
};Note that lazy is called at module scope, not inside the component. Calling it during render creates a brand new lazy component on every pass, which means the chunk is treated as a fresh import every time and the fallback never goes away. It is an easy mistake to make and a miserable one to debug, because nothing errors. You just get a spinner forever.
Finally, wire it up:
// index.tsx
import React, { Suspense } from "react";
import ReactDOM from "react-dom/client";
import { DemoApp, DemoAppLoading } from "./App";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<Suspense fallback={<DemoAppLoading />}>
<DemoApp />
</Suspense>
</React.StrictMode>
);DemoAppLoading stays on screen until the ./Articles chunk has been fetched and evaluated, plus the two seconds I added. App.tsx itself is a normal static import, so it is already in the main bundle and never suspends. Only the lazy child does.
Where you put the boundary matters
The fallback replaces the entire subtree, so a boundary near the root means the whole page disappears while one small chunk downloads. That is rarely what you want.
Boundaries nest, and each one reveals independently:
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Articles />
</Suspense>Here the sidebar can still be loading while the header and the articles are on screen. The nearest boundary above a suspending component wins.
Worth pairing them with an error boundary as well. If the chunk fails to download, because you deployed while someone had the tab open and the old hashed file is gone, lazy throws and the nearest error boundary catches it. Without one you get a blank page.
Keeping content on screen
There is a second behaviour that took me a while to notice. If content is already visible and an update would cause it to suspend again, React will hide it and show the fallback, which reads as a flicker back to the spinner.
Marking that update as a transition avoids it:
const [isPending, startTransition] = useTransition();
startTransition(() => {
setTab('articles');
});React keeps the previous content on screen while the new tab loads, and gives you isPending so you can grey it out or show a small indicator instead of blowing the whole section away.
What I’d still like
Suspense on the server is the part I am most pleased with in React 18. Combined with streaming SSR, which I covered in the hydration article, the server can send HTML for the fast parts of the page and stream the slow parts in as they resolve.
On the client it is still half a feature unless you are on a framework that has done the integration for you. The documentation is honest about this, which I appreciate, but it does mean “use Suspense for data fetching” is advice you cannot act on in most codebases yet. I’ll keep using it for code splitting and wait.
‘Till next time!