React Suspense and concurrent rendering — the modern data-loading pattern
Suspense lets a component "wait" for something — usually data or a lazy-loaded module — and shows a fallback UI until it's ready. With React 18+ concurrent rendering, this becomes the primary pattern for loading states.
The mental model
"If this component throws a promise, render the fallback. When the promise resolves, try rendering again."
You don't write if (loading) return <Spinner/> anymore. You write the success case and let Suspense handle the loading state.
Lazy-loading components
import { Suspense, lazy } from 'react';
const HeavyChart = lazy(() => import('./heavy-chart'));
function Dashboard() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyChart />
</Suspense>
);
}
HeavyChart is loaded the first time it renders. While the chunk downloads, the Skeleton shows.
Data loading — Next.js App Router
// app/posts/page.tsx
import { Suspense } from 'react';
import { PostsList } from './posts-list';
export default function PostsPage() {
return (
<>
<h1>Posts</h1>
<Suspense fallback={<PostsListSkeleton />}>
<PostsList />
</Suspense>
</>
);
}
// posts-list.tsx — Server Component
async function PostsList() {
const posts = await db.post.findMany(); // slow, e.g. 2 seconds
return posts.map((p) => <Post key={p.id} {...p} />);
}
User immediately sees the <h1> and skeleton. When the DB query completes, the list streams in. No client-side fetch, no useEffect, no loading state in component code.
Multiple Suspense boundaries = parallel loading
<Suspense fallback={<Skeleton />}>
<UserProfile /> {/* takes 200ms */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentPosts /> {/* takes 1500ms */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<Recommendations /> {/* takes 3000ms */}
</Suspense>
Each boundary independently reveals when ready. Users see content stream in instead of waiting for everything.
useTransition — non-blocking updates
const [isPending, startTransition] = useTransition();
function search(term) {
startTransition(() => {
setSearchTerm(term); // marks this update as low-priority
});
}
return (
<>
<input onChange={(e) => search(e.target.value)} />
{isPending && <Spinner />}
<Results term={searchTerm} />
</>
);
startTransition tells React: "this update is okay to interrupt." Typing stays smooth even if Results is expensive to re-render.
useDeferredValue — debouncing without timers
function Search({ query }) {
const deferred = useDeferredValue(query);
const results = useMemo(() => expensiveSearch(deferred), [deferred]);
return <List items={results} />;
}
deferred lags behind query during fast typing, so React doesn't re-render List on every keystroke. No setTimeout, no debounce library.
Error boundary pairs with Suspense
<ErrorBoundary fallback={<ErrorScreen />}>
<Suspense fallback={<Loading />}>
<FlakyDataComponent />
</Suspense>
</ErrorBoundary>
Loading and error are sibling concerns:
- Suspense → loading
- Error boundary → failure
What Suspense does NOT replace
- One-time effects (
useEffectfor "scroll to top on mount") - Browser APIs
- Imperative DOM operations
It replaces only the "wait for data / lazy code" loading pattern.
Streaming SSR — the Next.js superpower
In Next.js App Router, Suspense + Server Components stream the HTML to the browser as it becomes ready:
- Browser gets the static parts immediately (header, skeleton)
- Server keeps the connection open
- When
PostsList's query completes, the chunk streams in and replaces the skeleton - Connection closes when everything's done
Result: faster Time to First Byte and faster Largest Contentful Paint than client-side fetching.
Common interview question — why Suspense over if (loading)?
- Co-location — the loading UI lives next to the success UI in the JSX tree, not scattered
- Composability — one boundary covers an entire subtree, however deep
- Streaming — Next.js can flush HTML progressively, which
if (loading)can't - No flash of loading state — if data resolves under ~100ms, React skips the fallback entirely