Coordinating async UI state with React Suspense
React’s Suspense feature is designed to handle asynchronous actions like data loading, with the goal of keeping your UI consistent. When multiple parts of your application need data that loads independently, you normally end up with individual loading spinners scattered through the UI — and worse, those spinners can sit on top of stale content while the rest of the page has already updated. Suspense aims to solve that by holding off on rendering until everything is ready.
Keep in mind that these APIs are still in alpha and should not be used in production. This is a look at what’s coming down the road.
The core primitives
Suspense is built on low-level primitives, so it isn’t limited to data fetching. The two you’ll use most are the <Suspense> boundary and the useTransition hook.
A <Suspense> boundary takes a fallback prop:
<Suspense fallback={<Fallback />}>
When any child under this component suspends, React renders the fallback instead. It doesn’t matter how many children are waiting or why — the fallback shows until everything is ready, which is how React enforces a consistent UI.
The picture changes once content has already rendered and the user triggers a state change that loads new data. Replacing the visible UI with a fallback would be a poor experience, so useTransition handles this case. The hook returns a function and a boolean. You call the function, wrapping your state change. React applies the change; if anything suspends, it sets the boolean to true and waits for the suspension to resolve. Then it tries the state change again. If something else suspends, it waits again. The flag stays true until the entire state change can be applied, at which point the UI updates in one shot.
To suspend, you throw a promise. That’s the whole mechanism. A data request that hasn’t finished throws a promise tied to the fetch; React catches it and handles the wait. Because the suspension point is so low-level, you can use it with anything — React.lazy already works with Suspense, and the same pattern can preload images before displaying a UI to prevent layout shift.
A realistic use case
To see how this plays out in practice, let’s walk through an infinite loading list that fetches data and preloads images with Suspense. The data loading will be faked with a timeout, but the Suspense mechanics are real.
The root App component sets up a Suspense boundary:
<Suspense fallback={<Fallback />}>
For clarity, the fallback tints the whole screen pink so it’s impossible to miss when it appears. The current chunk of data is loaded inside DataList:
const newData = useQuery(param);
The useQuery hook simulates a network request with a hardcoded timeout. It caches results and throws a promise when data isn’t cached yet. The master list of displayed items lives in state:
const [data, setData] = useState([]);
As new data arrives from the hook, it gets appended:
useEffect(() => {
setData((d) => d.concat(newData));
}, [newData]);
When the user clicks the button to load more, this handler runs:
function loadMore() {
startTransition(() => {
setParam((x) => x + 1);
});
}
Each data entry renders through a SuspenseImg component that preloads the image and suspends until it’s ready. A query string is added to each image URL to force a fresh fetch even though only five distinct images exist.
Where it breaks down
On the initial load, everything behaves: the pink fallback appears, then disappears as data arrives. But then it reappears — and after clicking to load more, the useTransition inline indicator flips to true, then to false, and then the pink fallback shows anyway. The inline indicator was supposed to that’s the point at which the new data starts rendering, and those image preloads suspend.
Running useEffect after a state change has fully suspended and committed means any state you set inside it kicks off a brand new suspension. On initial load, the data fetch completes, then the effect fires, the new data renders, and the image preloads suspend — producing the second pink flash. The same thing happens on subsequent loads.
Dropping the useEffect entirely seems like the obvious fix, but it isn’t trivial. You’d think a ref could track the running list, but Suspense brings new rules. React may start a render, hit a thrown promise, and discard that render midway. If you mutate a ref during render and that render is thrown away, the ref keeps an invalid value. Render functions need to be pure, side-effect free. That was already the rule in React, but Suspense makes violations much more costly.
Decoupling state changes from effects
The fix is to stop updating the master list in an effect and redesign how the list gets built. Instead of storing the full data array in state, you store the list of pages you’ve loaded. The most recent page stays in a ref — not written during render — and an array of all loaded pages lives in state:
const currentPage = useRef(0);
const [pages, setPages] = useState([currentPage.current]);
Loading more data becomes a direct state update:
function loadMore() {
startTransition(() => {
currentPage.current = currentPage.current + 1;
setPages((pages) => pages.concat(currentPage.current));
});
}
The challenging part is converting those page numbers into actual data. You can’t loop over pages calling useQuery — hooks can’t run in loops. Instead, you need a non-hook data-reading API. Following the unofficial convention from earlier Suspense demos, call it read(). It’s not a hook. It returns cached data or throws a promise. For faking data loading, copying the hook and renaming it worked. Real data libraries will wants to expose both options publicly; for example, the GraphQL client used in the underlying project has both a useSuspenseQuery hook and a read() method on the client object.
With read() in place, the final rendering logic is straightforward:
const data = pages.flatMap((page) => read(page));
Each page passes its data request through read(). Any uncached page — the last one, in practice — throws a promise, and React suspends. When the promise resolves, React retries the prior state change and this code runs again.
The takeaway
With the effect-based state updates removed, the Suspense flow works as intended. The pink fallback appears exactly once on the initial load, and subsequent loads use the inline useTransition indicator until everything is ready.
The key lesson is structural: Suspense requires that data derived from state be read during render, not after it. useEffect fires only after the suspension has fully resolved, so any state you set there and any subsequent suspension it causes won’t be covered by the existing transition — you’ll see a fresh fallback instead. Keeping derived loading calls in render, via non-hook readers like read(), lets React see the whole picture and manage suspension consistently. React is still finalizing these APIs, but the direction is worth understanding now, so when Suspense reaches stable, the transition is smooth.



