React Concurrent Rendering in Practice: The Right Way to Use useTransition, useDeferredValue, and Suspense
前端工程(Updated Jun 2, 2026)
Concurrent Rendering: React's Interruptible Rendering
Before React 18, rendering was synchronous—once started, it couldn't be interrupted, and long tasks would block UI interaction.
Synchronous rendering:
User input → [====== Long render ======] → UI update
↑ User feels lag
Concurrent rendering:
User input → [== Render ==] → Interrupt → Handle input → [== Continue render ==]
↑ High-priority tasks can interrupt low-priority ones
useTransition: Marking Low-Priority Updates
Basic Usage
import { useTransition, useState } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
// High priority: update input immediately
setQuery(e.target.value);
// Low priority: search results can be deferred
startTransition(() => {
setResults(filterLargeList(e.target.value));
});
}
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending && <Spinner />}
<ResultList results={results} />
</div>
);
}
Practical: Large List Filtering
function ProductFilter({ products }: { products: Product[] }) {
const [filterText, setFilterText] = useState('');
const [filteredProducts, setFilteredProducts] = useState(products);
const [isPending, startTransition] = useTransition();
function handleFilterChange(text: string) {
setFilterText(text);
startTransition(() => {
// 10,000 products filtering—low priority
const filtered = products.filter(p =>
p.name.toLowerCase().includes(text.toLowerCase()) ||
p.category.toLowerCase().includes(text.toLowerCase()) ||
p.tags.some(t => t.includes(text))
);
setFilteredProducts(filtered);
});
}
return (
<div>
<SearchInput
value={filterText}
onChange={handleFilterChange}
// Don't reduce input responsiveness when isPending
/>
<div style={{ opacity: isPending ? 0.7 : 1, transition: 'opacity 0.2s' }}>
<ProductGrid products={filteredProducts} />
</div>
</div>
);
}
Correct Usage of isPending
// ❌ Using isPending to show a full-screen loading (flickers)
{isPending ? <FullPageSpinner /> : <Content />}
// ✅ Using isPending for subtle visual feedback
<div style={{
opacity: isPending ? 0.7 : 1,
transition: 'opacity 0.15s',
pointerEvents: isPending ? 'none' : 'auto',
}}>
<Content />
</div>
useDeferredValue: Deferred Value
Relationship with useTransition
// useTransition version
const [isPending, startTransition] = useTransition();
function handleChange(value) {
setQuery(value);
startTransition(() => setDeferredQuery(value));
}
// useDeferredValue version (equivalent, more concise)
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
Practical: Search Suggestions
function SearchWithSuggestions() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<div>
<SearchInput value={query} onChange={setQuery} />
{/* Suggestions use deferred value, won't block input */}
<Suggestions query={deferredQuery} />
</div>
);
}
function Suggestions({ query }: { query: string }) {
const [suggestions, setSuggestions] = useState<string[]>([]);
useEffect(() => {
if (!query) { setSuggestions([]); return; }
fetchSuggestions(query).then(setSuggestions);
}, [query]);
return (
<ul>
{suggestions.map(s => <li key={s}>{s}</li>)}
</ul>
);
}
useDeferredValue + memo = Performance Powerhouse
const deferredQuery = useDeferredValue(query);
// memo ensures re-render only when deferredQuery changes
const MemoizedResults = memo(Results);
return (
<div>
<SearchInput value={query} onChange={setQuery} />
<MemoizedResults query={deferredQuery} />
</div>
);
Suspense: Declarative Async Loading
Basics: Loading States
const resource = fetchData();
function ProfilePage() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<ProfileDetails resource={resource} />
<Suspense fallback={<PostsSkeleton />}>
<ProfilePosts resource={resource} />
</Suspense>
</Suspense>
);
}
function ProfileDetails({ resource }) {
// Throws Promise until data is ready
const user = resource.user.read();
return <h1>{user.name}</h1>;
}
Nested Suspense: Waterfall vs Parallel
// ❌ Waterfall: each Suspense waits for the previous one
<Suspense fallback={<Skeleton />}>
<A />
<Suspense fallback={<Skeleton />}>
<B />
<Suspense fallback={<Skeleton />}>
<C />
</Suspense>
</Suspense>
</Suspense>
// ✅ Parallel: A/B/C load simultaneously
<Suspense fallback={<Skeleton />}>
<A />
</Suspense>
<Suspense fallback={<Skeleton />}>
<B />
</Suspense>
<Suspense fallback={<Skeleton />}>
<C />
</Suspense>
Suspense + React Router v7
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Streaming SSR + Suspense
Server-Side
// app.tsx
function App() {
return (
<Layout>
<Suspense fallback={<ProductSkeleton />}>
<ProductList /> {/* Async data */}
</Suspense>
</Layout>
);
}
Streaming HTML Response
HTTP response stream:
1. Immediately send Layout + ProductSkeleton
<html><body><nav>...</nav><div class="skeleton">...</div>
2. Once ProductList data is ready, stream replacement
<template id="suspense-boundary">
<div class="products">...</div>
</template>
<script>
// Replace skeleton with real content
document.getElementById('suspense-boundary').replaceWith(...)
</script>
</body></html>
Next.js App Router Streaming SSR
// app/products/page.tsx
export default async function ProductsPage() {
// This is a Server Component, auto-Suspense
const products = await fetchProducts();
return <ProductGrid products={products} />;
}
// app/layout.tsx
export default function Layout({ children }) {
return (
<html>
<body>
<Nav />
<Suspense fallback={<Loading />}>
{children}
</Suspense>
</body>
</html>
);
}
useActionState: Concurrent Form Handling
import { useActionState } from 'react';
async function submitForm(prevState: Result, formData: FormData): Promise<Result> {
const response = await fetch('/api/submit', {
method: 'POST',
body: formData,
});
return response.json();
}
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitForm, {
status: 'idle',
});
return (
<form action={formAction}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
{state.status === 'error' && <p className="error">{state.message}</p>}
{state.status === 'success' && <p className="success">Submitted successfully!</p>}
</form>
);
}
Common Pitfalls
Pitfall 1: setState Inside startTransition Is Not Immediate
// ❌ Expecting immediate update, but it's deferred
startTransition(() => {
setCount(c => c + 1);
});
console.log(count); // Still the old value!
// ✅ Put immediate updates outside transition
setCount(c => c + 1);
Pitfall 2: Suspense Boundaries Too Coarse
// ❌ One Suspense for the entire page, any data not ready shows full-screen loading
<Suspense fallback={<FullPageLoading />}>
<Header />
<Sidebar />
<MainContent />
<Footer />
</Suspense>
// ✅ Fine-grained Suspense, each section loads independently
<Header />
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<MainSkeleton />}>
<MainContent />
</Suspense>
<Footer />
Pitfall 3: Overusing useTransition
// ❌ Marking all updates as transitions (unnecessary)
startTransition(() => {
setA(value);
setB(value);
setC(value);
});
// ✅ Only use transition for updates that "can wait"
setA(value); // Immediate
startTransition(() => {
setB(value); // Can be deferred
setC(value); // Can be deferred
});
Performance Comparison
| Scenario | Synchronous Rendering | Concurrent Rendering |
|---|---|---|
| Large list filtering (10K items) | Input lags 200ms | Input smooth, list deferred 50ms |
| Page navigation | White screen wait 1s | Skeleton + streaming fill |
| Parallel data loading | Waterfall 3s | Parallel 1s |
| Urgent update interrupting render | Not supported | Auto-interrupt |
Summary
React's three core concurrent rendering APIs each have their role: useTransition marks deferrable updates, useDeferredValue creates deferred values, and Suspense enables declarative async loading. The key principle: urgent updates (input, clicks) process immediately, non-urgent updates (filtering, search, navigation) mark as transitions. Combined with streaming SSR, concurrent rendering enables a qualitative leap in React application user experience.
Try these browser-local tools — no sign-up required →
#React#并发渲染#Suspense#useTransition#流式SSR