React 18 introduced concurrent features that fundamentally change how rendering works. These features let React prepare multiple versions of the UI at once, interrupt work in progress, and prioritize urgent updates over non-urgent ones. The result is more responsive applications without giving up the declarative programming model that makes React productive.
Concurrency in React is not an all-or-nothing mode. Unlike the abandoned “Concurrent Mode” concept from earlier experimental builds, React 18+ makes concurrent features opt-in. You adopt them feature by feature, where they provide the most value. The underlying Fiber architecture makes this possible: React’s render phase can be paused and resumed, so the reconciler can switch between different units of work as priorities change.
useTransition Hook
The useTransition hook lets you mark a state update as non-urgent. This prevents the update from blocking the main thread during critical user interactions like typing.
import { useTransition } from "react";
function SearchPage() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");
function handleChange(e) {
// Urgent: update the input value immediately
const value = e.target.value;
setQuery(value);
// Non-urgent: filter the large dataset
startTransition(() => {
setFilter(value);
});
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<SearchResults filter={filter} />
</div>
);
}
The isPending flag provides a way to show loading indicators without blocking the UI. When React is working on the transition, isPending is true, and the user can continue typing because the transition can be interrupted by a more urgent state update.
Transitions differ fundamentally from debouncing with setTimeout. A transition does not delay execution at all. It deprioritizes the rendering work, allowing React to pause it, check for higher-priority work, and resume when the thread is free. Debouncing always introduces a fixed delay; transitions introduce zero delay when the CPU is idle.
useDeferredValue Hook
Where useTransition wraps a state setter, useDeferredValue wraps a value. This is useful when you cannot easily wrap the state update itself but still want to keep the UI responsive.
import { useDeferredValue, useMemo } from "react";
function SearchResults({ query, data }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const filtered = useMemo(
() => expensiveFilter(data, deferredQuery),
[deferredQuery, data]
);
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
{filtered.map((item) => (
<ListItem key={item.id} item={item} />
))}
</div>
);
}
The deferred value updates immediately when the CPU is idle, never after a fixed delay. This creates a “stale state” pattern: users see the previous results (slightly dimmed) while the new results compute, rather than a blank loading spinner. This pattern delivers a perceptually smoother experience because the UI never goes empty.
| Approach | Latency | User Experience |
|---|---|---|
| Synchronous render | Full on every keystroke | UI freezes during filter |
| Debounced (300ms) | Fixed 300ms delay | Input feels laggy |
useDeferredValue | Zero delay if idle | Responsive input, smooth transition |
Suspense with Concurrent Rendering
Suspense becomes far more powerful under concurrent rendering. In the synchronous model, when a component suspends, the entire tree above the Suspense boundary falls back to the loading state. With concurrent features, sibling components remain interactive while a suspended component is being resolved.
function Page() {
return (
<Suspense fallback={<PageSkeleton />}>
<Sidebar />
<Suspense fallback={<DetailSkeleton />}>
<MainContent />
</Suspense>
</Suspense>
);
}
Nesting Suspense boundaries lets you control the granularity of loading states. The Sidebar can render immediately while MainContent streams in. Combined with startTransition, you can show the previous UI while the next screen loads, eliminating the “flash of loading” that plagues traditional navigation.
startTransition Outside Hooks
The startTransition function is also available as a standalone import for use outside React components. This is valuable for state management libraries, router event handlers, and any code path where hooks are unavailable.
import { startTransition } from "react";
import { useNavigate } from "react-router-dom";
function navigateWithTransition(path) {
startTransition(() => {
// Wrap the navigation in a transition
// so the previous view stays visible
navigate(path);
});
}
You can wrap Zustand or Redux store updates in startTransition to deprioritize non-urgent state changes. This is especially useful when store updates trigger expensive derived computations across many subscribers.
Building Responsive Search Components
A practical search component combines all the concurrent features together. The input state is urgent. The filter computation uses useDeferredValue. Navigation to a detail page uses startTransition on the router. The search results themselves are wrapped in Suspense boundaries.
Here is a complete pattern:
function SearchContainer() {
const [input, setInput] = useState("");
const deferredInput = useDeferredValue(input);
function handleClear() {
setInput("");
// The deferred value will catch up naturally
}
return (
<Suspense fallback={<ResultsSkeleton />}>
<SearchInput
value={input}
onChange={setInput}
onClear={handleClear}
/>
<SearchResults query={deferredInput} />
</Suspense>
);
}
When the user types rapidly, only the last keystroke triggers a full render. All intermediate keystrokes interrupt the previous transition. This avoids the “janky search” problem where typing feels sluggish because every keystroke triggers an expensive filter pass.
Advanced Patterns and Best Practices
For layered priority control, combine useTransition with useDeferredValue. Use useTransition for navigation-level state changes (tab switching, page transitions) and useDeferredValue for derived computations within a view.
To test concurrent features, use React Testing Library with act wrappers:
import { act, render, screen } from "@testing-library/react";
await act(async () => {
render(<SearchPage />);
});
// Assert on the pending state
expect(screen.getByText("Loading...")).toBeInTheDocument();
Profile before optimizing. React DevTools Profiler shows whether renders are being interrupted and how much time each render phase consumes. Premature use of concurrent features adds complexity without measurable benefit — always measure first, then apply transitions and deferred values where they address real bottlenecks.
Compatibility and Migration
Concurrent features require React 18+ and are fully opt-in. No existing component breaks when you upgrade. The recommended migration path starts with high-latency user interactions: search inputs, filtering large lists, and tab switching. Once those are smooth, expand to navigation transitions and data loading patterns.
Some third-party libraries that mutate state synchronously may not respect transition boundaries. Test library compatibility before adopting transitions in those contexts.
Summary
Concurrent features give you three powerful tools: useTransition for deprioritizing state updates, useDeferredValue for deferring derived computations, and concurrent Suspense for graceful loading states. Each solves a specific class of responsiveness problem. Profile first, choose the right tool, and deliver UIs that feel instant even under heavy computation.
