Suspense in Practice: Routing and Data Loading Without the Juggling
React's Suspense has been talked about a lot, but the discussion often stays theoretical. This piece looks at how it actually behaves in an application, specifically how it can own both routing and data loading. We'll use vanilla JavaScript for navigation and the micro-graphql-react library for data to see the mechanics clearly.
The Core Idea: One UI at a Time
Suspense is still an experimental API, so it isn't something to base production code on just yet. The concepts, however, are worth understanding now. Its job is to keep the UI consistent when parts of it depend on asynchronous work—lazy-loaded components, network requests, and the like.
"Consistent" here means not showing a half-finished screen. If a page has three data sources and only one has resolved, you don't want to render that one piece of fresh data while the other two sit behind spinners. You want to hold the old view until everything is ready, or show an explicit fallback. Suspense gives you both options.

The way it works is straightforward. A component can "suspend" during render, signaling that it's waiting on something. React then looks up the tree for the nearest <Suspense> boundary and shows its fallback instead.
<Suspense fallback={<Loading />}>
That solves the initial load, but it creates a problem for updates. If you already have a valid UI on screen and a new interaction causes a component to suspend, you don't want to tear down the whole page and show the generic fallback. That would be consistent but a poor experience. The better move is to keep the old screen visible while the new state is prepared.
That is what useTransition is for. It lets you trigger a state change that happens "in memory." React keeps a second copy of the tree, applies the update there, and lets components suspend without touching the live UI. When everything resolves, the new state swaps in. The hook exposes an pending boolean so you can show a subtle loading indicator during that window.
You don't want that in-memory state to run forever, though. If the user is waiting too long, the UI you're holding onto is likely stale. useTransition takes a timeoutMs value: if the in-memory update hasn't finished in that time, React gives up and lets the tree suspend for real, triggering the <Suspense> fallback.
const Component = props => {
const [startTransition, isPending] = useTransition({ timeoutMs: 3000 });
// .....
};
You call startTransition with a function that performs the state update. If a component suspends during that update, isPending flips to true, and your existing UI stays up with whatever inline indicator you choose.
startTransition(() => {
dispatch({ type: LOAD_DATA_OR_SOMETHING, value: 42 });
})
That is the whole mental model. The rest is wiring it into real features.
Building Navigation on Suspense
React's React.lazy is the primitive for component-level code splitting. It takes a function that returns a Promise resolving to a component, and that component becomes a lazy node in your tree. When it first renders, the import() fires. While that request is in flight, anything rendering the lazy component suspends.
const SettingsComponent = lazy(() => import("./modules/settings/settings"));
To see this in context, consider a small app with a handful of top-level modules. Navigation state—current module and search parameters—lives in a reducer that syncs with the URL. A routing helper scrapes those values from the URL and maps them to a component.
const ActivateComponent = lazy(() => import("./modules/activate/activate"));
const AuthenticateComponent = lazy(() =>
import("./modules/authenticate/authenticate")
);
const BooksComponent = lazy(() => import("./modules/books/books"));
const HomeComponent = lazy(() => import("./modules/home/home"));
const ScanComponent = lazy(() => import("./modules/scan/scan"));
const SubjectsComponent = lazy(() => import("./modules/subjects/subjects"));
const SettingsComponent = lazy(() => import("./modules/settings/settings"));
const AdminComponent = lazy(() => import("./modules/admin/admin"));
Given that, the app root can decide which component to render based on the module name.
export const getModuleComponent = moduleToLoad => {
if (moduleToLoad == null) {
return null;
}
switch (moduleToLoad.toLowerCase()) {
case "activate":
return ActivateComponent;
case "authenticate":
return AuthenticateComponent;
case "books":
return BooksComponent;
case "home":
return HomeComponent;
case "scan":
return ScanComponent;
case "subjects":
return SubjectsComponent;
case "settings":
return SettingsComponent;
case "admin":
return AdminComponent;
}
return HomeComponent;
};
The root component brings it together with two separate useTransition calls. One handles navigation to a brand-new module; the other handles search or state updates within the current module. Why two? Because they have different UX requirements.
When navigating to a new module, there's no existing UI for that module to preserve. A top-level fallback is acceptable. When updating the current module's search state, you want to keep showing the existing data with an inline spinner, and only fall back to a larger placeholder if the transition times out.
let appStatePacket = useAppState();
The first useEffect call tells the reducer to sync with the URL on startup. The initial module render is wrapped in the "new module" transition so that the Suspense boundary handles the loading state correctly rather than painting a fallback immediately.
let Component = getModuleComponent(appState.module);
The second useEffect subscribes to history changes. The logic branches on whether the URL points to a different module or a new search within the same one.
useEffect(() => {
return history.listen(location => {
if (appState.module != getCurrentModuleFromUrl()) {
startTransitionNewModule(() => {
dispatch({ type: URL_SYNC });
});
} else {
startTransitionModuleUpdate(() => {
dispatch({ type: URL_SYNC });
});
}
});
}, [appState.module]);
If it's a new module, startTransitionNewModule is called. Because React.lazy suspends during the import, the root sees isNewModulePending go true and shows a top-level loading indicator. Since the update is in memory, the current screen stays for the full timeout (three seconds by default). If the module isn't ready by then, the tree suspends and the fallback renders a "still loading" message.
{isNewModulePending ? <Loading /> : null}
<Suspense fallback={<LongLoading />}>
<div id="main-content" style={{ flex: 1, overflowY: "auto" }}>
{Component ? <Component updating={moduleUpdatePending} /> : null}
</div>
</Suspense>
If it's the same module with a new search state, startTransitionModuleUpdate runs. The pending flag goes onto context so the active module can pick it up and draw an inline spinner over its existing results. The same three-second timeout applies, but here it covers both the component loading and its initial data suspension. If the module loads in two seconds and then suspends on data while rendering in memory, the transition keeps waiting for another second before falling back.
Consistency in Action
This approach removes a lot of manual state juggling. Consider a search screen: when a new term is typed, the URL updates immediately. The application state for that search (for example, an active filter label like "C++") changes right away. But the visible UI doesn't reflect it yet, because the update is happening in memory. The old results stay on screen while the search suspends.
If the transition times out and the fallback renders, that fallback can be designed to show the search bar in a disabled state with the new filter label visible—acknowledging the user's intent while waiting for the results. When the data arrives, the new results render consistently with the filter label they were searching for.
That's the consistency Suspense provides. You build the application states you want, and React handles the question of whether things are ready.
Nested Boundaries and Behavior
Real apps have nested Suspense boundaries, and their interaction is worth understanding. Suppose the top-level navigation is slow and its global fallback is showing. The books component finally loads, and it defines its own Suspense boundary. As React renders it, that module's data query suspends.
The lower boundary's fallback replaces the top-level one. The books shell appears with a targeted "loading" state instead of the generic "still loading" message. As soon as a more specific UI is available, it takes over.
There's a subtlety with timeouts, though. Say the startTransition timeout is three seconds. The books component finishes loading after one second and starts rendering, immediately hitting its nested Suspense boundary because the data query suspends. The new fallback shows right away, not after the remaining two seconds. Showing a new, valid UI as soon as it exists is more useful than preserving a stale one while waiting out a timer.
Data Loading in the Same System
Navigation and data loading are the same problem to Suspense. A data fetch that suspends behaves exactly like a lazy-loaded component. The same useTransition calls and <Suspense> boundaries apply.
The mechanics for data are slightly different underneath. A component that reads a cache and finds a pending request throws a Promise. That Promise must be consistent for identical requests—searching for "C++" four times throws the same object. Libraries implement this caching, and each one will need Suspense-aware APIs. In micro-graphql-react, that's the useSuspenseQuery hook, which mirrors useQuery but throws that waiting Promise.
Preloading Isn't a Suspense Problem
Discussions of Suspense often bring up waterfalls and "fetch-on-render." The risk is real: a lazy component loads, then requests data, then renders. Two network round-trips happen in sequence.
But the key insight is that the initial query parameters are usually known before the component mounts. If the URL says /books?query=C++, the search can start as soon as that navigation begins, not after the component bundle arrives.
Preloading is orthogonal to Suspense. Any framework can do it. The requirement is that the query logic lives in its own standalone module, safe to import from a routing handler. In the routing code above, the books entry can call its preload function before the component loads:
switch (moduleToLoad.toLowerCase()) {
case "activate":
return ActivateComponent;
case "authenticate":
return AuthenticateComponent;
case "books":
// preload!!!
booksPreload();
return BooksComponent;
That's a performance optimization, not a Suspense feature. It's worth doing in any architecture.
What Suspense Changes
Manual async management meant cascading spinners, unpredictable timing, and screens that were only partially finished. Suspense creates a single system where all async dependencies—components, data, anything else—participate in the same consistency guarantees. You still write the application states; React figures out when they're ready to be seen.



