Introduction to Suspense and Transitions
React applications frequently perform operations that take time, such as loading a lazily imported component, switching between large pages, or updating an expensive list. If the interface does not handle these operations carefully, users may see sudden loading screens, delayed interactions, or unnecessary visual changes.
React provides Suspense and transitions to help manage these situations. Suspense lets a component display fallback content while a supported child is waiting. Transitions let React treat certain state updates as non-urgent, allowing more urgent interactions to remain responsive.
These features solve related but different problems. Suspense coordinates loading boundaries, while transitions communicate the priority of updates. Understanding both helps you build smoother interfaces without hiding important feedback from users.
What Is React Suspense?
Suspense is a React component that displays a fallback when a supported child suspends while rendering. Its most common use is with components loaded through React.lazy(). React can show a loading indicator while the component's JavaScript module is downloaded.
Suspense also works with supported Suspense-enabled data sources and frameworks. It does not automatically detect every asynchronous operation. For example, fetching data inside an ordinary useEffect() does not, by itself, activate a Suspense fallback.
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
export default function App() {
return (
<main>
<h1>Application Dashboard</h1>
<Suspense
fallback={<p role="status">Loading dashboard...</p>}
>
<Dashboard />
</Suspense>
</main>
);
}
When React needs to load the Dashboard module, it can render the fallback until the module becomes available. After loading completes, React renders the actual dashboard.
For a deeper explanation of component-level code splitting, read the React lazy loading guide.
Choosing the Right Suspense Boundary
A Suspense boundary determines which part of the interface can display fallback content when a descendant suspends. Boundary placement affects how much of the interface React may replace with a loading state.
Consider a dashboard containing a header, navigation, and two independently loaded sections. If the entire dashboard is inside one boundary, a suspended section can cause the boundary's fallback to replace a much larger portion of the screen. Separate boundaries can preserve the surrounding interface.
import { lazy, Suspense } from 'react';
const Activity = lazy(() => import('./Activity'));
const Recommendations = lazy(
() => import('./Recommendations')
);
export default function Dashboard() {
return (
<main>
<header>
<h1>Dashboard</h1>
</header>
<section aria-label="Recent activity">
<Suspense fallback={<p role="status">Loading activity...</p>}>
<Activity />
</Suspense>
</section>
<section aria-label="Recommendations">
<Suspense fallback={<p role="status">Loading recommendations...</p>}>
<Recommendations />
</Suspense>
</section>
</main>
);
}
Each section has its own boundary, so its fallback can appear independently. Choose boundaries based on meaningful sections of the user experience rather than wrapping every small component automatically.
What Are React Transitions?
A transition marks a state update as non-urgent. React can prioritize urgent updates, such as typing into an input, over transition updates that may require expensive rendering.
React provides two related APIs: useTransition(), which exposes a pending state for an update, and startTransition(), which marks an update as a transition without requiring a component-level pending flag.
A transition does not make the underlying operation inherently faster. It gives React more flexibility to schedule rendering so urgent interactions are less likely to feel blocked.
Using useTransition()
The useTransition() Hook returns an isPending value and a startTransition function. The pending value indicates that a transition started through this Hook is in progress.
import { useState, useTransition } from 'react';
const items = [
'React',
'JavaScript',
'TypeScript',
'Angular',
'Vue'
];
export default function TechnologyList() {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleChange(event) {
const value = event.target.value;
setQuery(value);
startTransition(() => {
setFilteredItems(
items.filter((item) =>
item.toLowerCase().includes(value.toLowerCase())
)
);
});
}
return (
<section>
<label htmlFor="technology-search">Search technologies</label>
<input
id="technology-search"
value={query}
onChange={handleChange}
placeholder="Search..."
/>
{isPending && (
<p role="status">Updating results...</p>
)}
<ul>
{filteredItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</section>
);
}
In this example, the input's state updates urgently, allowing the typed value to appear immediately. Filtering the list is marked as a transition, allowing React to prioritize the input update when rendering becomes expensive. The pending indicator gives users feedback while the transition is being processed.
The list above is intentionally small. For very large datasets, consider filtering on the server, using pagination, or applying list virtualization in addition to scheduling improvements.
Using startTransition()
Use the standalone startTransition() API when you want to mark an update as non-urgent but do not need the pending state returned by useTransition().
import { startTransition, useState } from 'react';
export default function PageSelector() {
const [page, setPage] = useState('home');
function openDashboard() {
startTransition(() => {
setPage('dashboard');
});
}
return (
<main>
<button onClick={openDashboard}>
Open dashboard
</button>
{page === 'home' ? (
<h2>Home</h2>
) : (
<DashboardContent />
)}
</main>
);
}
function DashboardContent() {
return <h2>Dashboard</h2>;
}
Here, the page state update is marked as non-urgent. The example demonstrates the API, although the benefit is most noticeable when the update triggers substantial rendering or interacts with Suspense-enabled content. For a small component, a transition may provide little measurable improvement.
Suspense and Transitions Together
Suspense and transitions are particularly useful together when navigation changes which content must be loaded. A normal update that causes a previously visible Suspense boundary to suspend may show its fallback. If the update is marked as a transition, React can often keep the already revealed content visible while preparing the next screen.
The following example illustrates a transition that switches between two lazily loaded sections:
import { lazy, Suspense, useState, useTransition } from 'react';
const Overview = lazy(() => import('./Overview'));
const Reports = lazy(() => import('./Reports'));
export default function Dashboard() {
const [page, setPage] = useState('overview');
const [isPending, startTransition] = useTransition();
function navigate(nextPage) {
startTransition(() => {
setPage(nextPage);
});
}
return (
<main>
<nav aria-label="Dashboard navigation">
<button onClick={() => navigate('overview')}>
Overview
</button>
<button onClick={() => navigate('reports')}>
Reports
</button>
</nav>
{isPending && (
<p role="status">Preparing selected page...</p>
)}
<Suspense fallback={<p role="status">Loading content...</p>}>
{page === 'overview' ? <Overview /> : <Reports />}
</Suspense>
</main>
);
}
Because the page update is a transition, React can keep already revealed content visible while preparing the new section when the rendering suspends. The pending indicator can communicate that navigation is in progress. The exact behavior depends on the boundary placement and which content has already been revealed.
This is a simplified example of state-based navigation. For URL-based navigation and more advanced loading behavior, see the React Router guide.
When Should You Use a Transition?
Transitions are useful for updates that can happen in the background from the user's perspective. They are not a replacement for ordinary state updates, and not every update should be treated as non-urgent.
| Interaction | Recommended approach | Reason |
|---|---|---|
| Typing into an input | Urgent state update | The typed value should respond immediately. |
| Rendering an expensive filtered list | Consider a transition | Allows urgent interactions to take priority. |
| Switching between large content sections | Consider a transition with Suspense | Can preserve revealed content while the next view is prepared. |
| Submitting a form or confirming a purchase | Explicit action and pending state | The operation needs clear feedback and correct submission behavior. |
Important Rules and Limitations
-
Do not transition controlled input updates: State that directly controls an input's value must update urgently. Keep that update outside
startTransition(). - Transitions are scheduling hints: They do not reduce the amount of work by themselves or make network requests inherently faster.
-
Suspense does not catch every asynchronous task: An ordinary fetch started in
useEffect()does not automatically activate a Suspense fallback. -
State updates after await need care: In asynchronous transition workflows, state updates scheduled after an
awaitmay need anotherstartTransition()call to mark them as transition updates. -
Pending state is not a general network indicator:
isPendingrepresents transition work associated with the Hook, not every request or background task in the application. - Provide accessible feedback: Loading indicators should communicate what is happening without unnecessarily removing useful content or disrupting keyboard users.
Common Mistakes
- Assuming Suspense automatically handles every fetch request.
- Wrapping every state update in a transition, even when immediate feedback is required.
- Using one large Suspense boundary that replaces the entire page when only one section needs loading.
- Removing existing content too early when a transition could preserve it while the next view is prepared.
- Using a pending indicator without explaining which operation is in progress.
- Expecting transitions to replace code splitting, caching, pagination, or other performance techniques.
- Ignoring errors from lazy imports or supported Suspense-enabled data sources.
Best Practices
- Use Suspense boundaries around meaningful sections of the interface.
- Choose fallbacks that match the expected content layout and provide clear status feedback.
- Use
useTransition()when the interface benefits from an explicit pending indicator. - Use
startTransition()when you need to mark an update as non-urgent without tracking its pending state locally. - Keep urgent input updates separate from expensive rendering updates.
- Combine transitions with lazy loading for large, infrequently visited sections.
- Handle loading failures with appropriate error boundaries and recovery options.
- Measure real performance and interaction responsiveness before adding complexity.
Conclusion
React Suspense and transitions help coordinate loading states and prioritize updates. Suspense provides fallback content for supported operations that suspend during rendering, while useTransition() and startTransition() mark updates as non-urgent. Together, they can make navigation and expensive interface updates feel smoother when applied to the right parts of an application.
Continue learning with the React lazy loading guide, React performance optimization, and React useEffect guide.