Suspense & lazy loading
Code-split with lazy(), show fallbacks with Suspense, and keep UI responsive with transitions.
Some things take time: downloading a chunk of JavaScript, fetching data, rendering a huge list. React's concurrent features let you handle waiting gracefully. Suspense shows a fallback while something loads, lazy splits your code so users download less up front, and transitions keep the UI responsive while expensive updates happen in the background.
Code splitting with lazy#
By default, Vite bundles your whole app into one JavaScript file. If your settings page uses a heavy chart library, every visitor downloads it — even those who never open settings. lazy loads a component's code only when it's first rendered:
When the button is clicked, React starts downloading AdminPanel's chunk, shows the fallback, then swaps in the real component. After a production build you can see the separate file:
Rules for lazy:
- The
import()must resolve to a module with a default export that is a component. For a named export, map it:lazy(() => import("./Charts").then((m) => ({ default: m.SalesChart }))). - Call
lazyat the top level of a module, never inside a component. - A lazy component must be rendered inside a
<Suspense>boundary (any ancestor will do).
Splitting by route#
Pages are the most natural place to split code. With React Router you can use lazy components in your routes:
React Router also has a built-in route-level lazy property that loads a route's Component, loader and more in parallel — see its docs when you need it.
How Suspense works#
Suspense is a boundary, like an error boundary but for loading:
- If anything inside a boundary suspends, React shows that boundary's
fallbackinstead of its children. - The nearest boundary wins. Above, if only
Postsis loading, the profile is shown with a skeleton for the posts. - Children inside one boundary are revealed together, so you avoid "popcorn" UIs where pieces jump in one by one.
Things that can suspend:
lazycomponents while their code downloads.- Reading a promise with
use(promise)(see React 19 features). - Suspense-enabled data libraries, e.g. TanStack Query's
useSuspenseQuery, or React Router and Next.js data loading.
Tip: Suspense does not detect data fetched inside
useEffect. Effects run after rendering, so the component never suspends.
Data with use and Suspense#
React 19's use reads a promise during render. While it's pending, the component suspends; if it rejects, the nearest error boundary catches it:
The promise must be stable. Creating a new promise inside the component on every render would suspend forever. In practice, frameworks and libraries create and cache these promises for you.
Transitions: keep showing the old UI#
When something already on screen suspends again (say, switching tabs to a lazy page), Suspense would normally replace it with the fallback — a jarring flash. Marking the update as a transition tells React: "this update isn't urgent; keep showing the current UI until the new one is ready."
useTransition returns:
isPending—truewhile the transition is in progress, so you can dim the UI or show a small spinner.startTransition(fn)— state updates insidefnare low-priority and interruptible. In React 19,fncan be async (an "Action"), andisPendingstaystrueuntil it finishes.
React Router automatically wraps navigations in transitions, which is why the old page stays visible while the next one loads.
useDeferredValue: responsive typing with slow rendering#
Imagine a search box filtering a list that takes 100 ms to render. Every keystroke would freeze the input. useDeferredValue lets the input update immediately while the list catches up:
How it works:
- You type;
queryupdates and React re-renders right away with the olddeferredQuery, soSlowList(memoised) skips rendering and the input feels instant. - React then renders in the background with the new
deferredQuery. If you type again before it finishes, React abandons that render and starts over with the latest value.
useDeferredValue also works with Suspense: while new results load, it keeps showing the old results instead of a fallback.
useTransition vs useDeferredValue#
Never wrap a text input's own setState in a transition — controlled inputs must update synchronously.
Common mistakes#
- Calling
lazyinside a component, causing state loss and endless reloading. - Forgetting a
<Suspense>boundary above a lazy component. - Expecting Suspense to show a fallback for data fetched in
useEffect. - Creating a new promise on every render and passing it to
use. - One giant boundary around the whole app, so any loading shows a full-page spinner. Place boundaries where loading states make sense.
- Splitting tiny components — each split adds a network request. Split pages and heavy features.
What's next#
You've now seen several React 19 APIs. Next, we'll tour them properly: Actions, useActionState, useOptimistic, use and more.
Check your understanding
Quick quiz
1.What does
<Suspense fallback={<Spinner />}>do?2.Where should you call
lazy(() => import('./Settings'))?3.What is
useDeferredValueuseful for?
Finished reading?
Mark this lesson complete to track your progress.