useTransition + Suspense: React Patterns
useTransition and Suspense are a powerful pair. Suspense lets you declare what to show while data is loading, and useTransition keeps the UI interactive while waiting for data. Together, they create a seamless experience where the app never freezes, old data stays visible, and new data appears with a smooth transition. This article teaches you the patterns for combining them—the right way to handle async data in React 19.
The key insight: Suspense shows a fallback while a promise is pending, and useTransition keeps everything else on the page responsive during that wait.
The useTransition + Suspense Combo
Here's the basic pattern:
import { useState, useTransition, Suspense } from 'react';
export function UserProfilePage() {
const [userId, setUserId] = useState(1);
const [isPending, startTransition] = useTransition();
const handleChangeUser = (newId) => {
startTransition(() => {
setUserId(newId);
});
};
return (
<div>
<h1>User Profile</h1>
<button onClick={() => handleChangeUser(1)}>User 1</button>
<button onClick={() => handleChangeUser(2)}>User 2</button>
<button onClick={() => handleChangeUser(3)}>User 3</button>
{/* Suspense shows the fallback while the profile is loading */}
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile userId={userId} />
</Suspense>
{/* Meanwhile, this sidebar stays interactive */}
<Sidebar isPending={isPending} />
</div>
);
}
// This component throws a promise while data is loading
function UserProfile({ userId }) {
const user = use(fetchUser(userId));
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
</div>
);
}
function Sidebar({ isPending }) {
return (
<aside style={{ opacity: isPending ? 0.6 : 1 }}>
<p>Other sidebar content</p>
</aside>
);
}
// Mock fetch
async function fetchUser(id) {
await new Promise(resolve => setTimeout(resolve, 1000));
return {
id,
name: `User ${id}`,
email: `user${id}@example.com`,
};
}
// Wrapper for using promises in components
function use(promise) {
let data, error;
let status = 'pending';
const suspender = promise
.then(
(res) => {
data = res;
status = 'resolved';
},
(err) => {
error = err;
status = 'rejected';
}
);
if (status === 'pending') {
throw suspender;
} else if (status === 'rejected') {
throw error;
}
return data;
}
Walk through this:
- User clicks "User 2".
handleChangeUserwrapssetUserId(2)instartTransition.- React starts rendering the new profile, which throws a promise (the fetch).
Suspensecatches the promise and shows the fallback: "Loading profile...".- The sidebar remains interactive and doesn't dim until
isPendingis true. - Once the fetch completes, the promise resolves, and
UserProfilerenders with the new data. - The fallback disappears and the profile appears.
Transitioning Between Async States
The pattern above works, but you can enhance it by showing different states based on isPending:
import { useState, useTransition, Suspense } from 'react';
export function EnhancedUserPage() {
const [userId, setUserId] = useState(1);
const [isPending, startTransition] = useTransition();
const handleChangeUser = (newId) => {
startTransition(() => {
setUserId(newId);
});
};
return (
<div>
<h1>User Profile</h1>
<div style={{ marginBottom: '20px' }}>
<button
onClick={() => handleChangeUser(1)}
disabled={isPending}
>
User 1
</button>
<button
onClick={() => handleChangeUser(2)}
disabled={isPending}
>
User 2
</button>
</div>
{/* Show different UI based on isPending */}
{isPending && (
<div style={{ padding: '10px', backgroundColor: '#f0f0f0', marginBottom: '10px' }}>
Switching user...
</div>
)}
{/* Suspense handles the promise rejection/resolution */}
<Suspense
fallback={
<div style={{ padding: '20px', backgroundColor: '#e8f4f8' }}>
Loading profile data...
</div>
}
>
<UserProfile userId={userId} />
</Suspense>
</div>
);
}
function UserProfile({ userId }) {
const user = use(fetchUser(userId));
return (
<div style={{ padding: '20px', border: '1px solid #ccc' }}>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Joined: {new Date(user.joinedDate).toLocaleDateString()}</p>
</div>
);
}
async function fetchUser(id) {
await new Promise(resolve => setTimeout(resolve, 1500));
return {
id,
name: `User ${id}`,
email: `user${id}@example.com`,
joinedDate: '2025-01-01',
};
}
Now the UI shows layered feedback: first "Switching user...", then "Loading profile data...", then the actual profile.
Handling Errors with Suspense
Suspense doesn't handle errors directly. Use an Error Boundary to catch errors thrown by components inside Suspense:
import { useState, useTransition, Suspense } from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error) {
console.error('Error caught:', error);
}
render() {
if (this.state.hasError) {
return (
<div style={{ color: 'red', padding: '20px' }}>
<h2>Failed to load data</h2>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
export function SafeUserProfile() {
const [userId, setUserId] = useState(1);
const [isPending, startTransition] = useTransition();
const handleChangeUser = (newId) => {
startTransition(() => {
setUserId(newId);
});
};
return (
<ErrorBoundary>
<div>
<h1>User Profile (with Error Handling)</h1>
<button onClick={() => handleChangeUser(1)}>User 1</button>
<button onClick={() => handleChangeUser(2)}>User 2</button>
{isPending && <p>Loading...</p>}
<Suspense fallback={<p>Fetching user...</p>}>
<UserProfile userId={userId} />
</Suspense>
</div>
</ErrorBoundary>
);
}
function UserProfile({ userId }) {
const user = use(fetchUser(userId));
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
async function fetchUser(id) {
// Simulate occasional failures
if (Math.random() < 0.2) {
throw new Error('Network error');
}
await new Promise(resolve => setTimeout(resolve, 1000));
return {
id,
name: `User ${id}`,
email: `user${id}@example.com`,
};
}
Now if the fetch fails, the Error Boundary catches it and shows a "Failed to load" message.
Multiple Suspense Boundaries
You can have multiple Suspense boundaries in a single component, each with its own fallback:
import { Suspense, useState, useTransition } from 'react';
export function DashboardWithMultipleSuspense() {
const [userId, setUserId] = useState(1);
const [isPending, startTransition] = useTransition();
const handleChangeUser = (newId) => {
startTransition(() => {
setUserId(newId);
});
};
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => handleChangeUser(1)}>User 1</button>
{/* First boundary: user profile */}
<Suspense fallback={<div>Loading user...</div>}>
<UserSection userId={userId} />
</Suspense>
{/* Second boundary: user posts */}
<Suspense fallback={<div>Loading posts...</div>}>
<PostsSection userId={userId} />
</Suspense>
{/* Third boundary: user comments */}
<Suspense fallback={<div>Loading comments...</div>}>
<CommentsSection userId={userId} />
</Suspense>
</div>
);
}
function UserSection({ userId }) {
const user = use(fetchUser(userId));
return <div><h2>{user.name}</h2></div>;
}
function PostsSection({ userId }) {
const posts = use(fetchPosts(userId));
return (
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
);
}
function CommentsSection({ userId }) {
const comments = use(fetchComments(userId));
return (
<ul>
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
);
}
async function fetchUser(id) {
await new Promise(r => setTimeout(r, 500));
return { id, name: `User ${id}` };
}
async function fetchPosts(id) {
await new Promise(r => setTimeout(r, 1000));
return Array.from({ length: 3 }, (_, i) => ({
id: i,
title: `Post ${i}`,
}));
}
async function fetchComments(id) {
await new Promise(r => setTimeout(r, 1500));
return Array.from({ length: 5 }, (_, i) => ({
id: i,
text: `Comment ${i}`,
}));
}
Each section shows its own loading state independently. The user section loads first (500 ms), then posts (1000 ms), then comments (1500 ms).
Key Takeaways
useTransitionmarks a state update as non-urgent;Suspensehandles the loading state while data fetches.- Wrap the async component in
Suspenseand provide afallbackto show while data loads. - Use
isPendingto show additional feedback (dimming, disabling buttons) while the transition is in progress. - Catch errors with an Error Boundary surrounding the Suspense.
- Multiple Suspense boundaries allow independent loading states for different sections.
Frequently Asked Questions
Do I need useTransition if I'm using Suspense?
Not always. If you don't need to control when the loading starts, Suspense alone is fine. But useTransition gives you more control and lets you disable buttons or show feedback during the transition.
What happens if a component inside Suspense doesn't throw a promise?
It renders normally. Suspense only shows the fallback if a promise is thrown (pending) inside the boundary.
Can I use async/await directly in a component?
No. Components can't be async. Use a library like React Query or SWR to fetch data, or implement a custom hook that throws promises during loading.
Is Suspense only for data fetching?
Suspense can be used for any async work—code splitting, preloading images, etc. Any promise thrown inside a Suspense boundary will trigger the fallback.