Difficulty: Advanced
What are React 18's concurrent features? Explain startTransition, useTransition, and useDeferredValue.
React 18 was a landmark release that introduced concurrent rendering -- a fundamental change to how React works under the hood. Let me explain what this means, why it matters, and walk you through each major feature in a way that makes sense.
First, some background. In React 17 and earlier, rendering was synchronous. When you triggered a state update, React would start at the top of the affected component tree and work its way down, computing the new UI. Until it finished the entire tree, the browser's main thread was blocked. No user interactions, no animations, no scrolling -- the browser was frozen. For small trees, this was imperceptible. For large trees with thousands of components, it could cause noticeable lag, especially on slower devices.
Concurrent rendering means React can now work on multiple versions of the UI simultaneously. It can start rendering an update, pause in the middle to handle something more urgent (like a user keystroke), and then resume where it left off. It can even abandon work entirely if a newer update makes it irrelevant. This keeps the app responsive even during heavy computations.
The key concept is update priority. Not all state updates are equally urgent. When a user types in a search input, updating the input field is urgent -- it must feel instant or the app feels broken. But updating the search results list (which might involve filtering thousands of items) is less urgent -- a slight delay is acceptable. Concurrent features let you communicate these priorities to React.
To opt into concurrent rendering, you need to use the new createRoot API: createRoot(container).render(<App />). This replaces the old ReactDOM.render(<App />, container). Without createRoot, all React 18 concurrent features are disabled and your app behaves exactly like React 17.
Automatic batching is the one feature that benefits everyone immediately, with zero code changes. In React 17, state updates were only batched (grouped into a single re-render) inside React event handlers. If you called setState twice inside an onClick, React batched them into one re-render. But if you called setState inside a setTimeout, a fetch .then(), or a native event listener, each call triggered a separate re-render. React 18 batches ALL state updates, everywhere. This means fewer re-renders and better performance by default.
useTransition and startTransition let you mark state updates as non-urgent. The classic example is a search input with a filtered list. You call setQuery (urgent -- updates the input immediately) and wrap setResults in startTransition (non-urgent -- can wait). React processes the urgent update first, keeping the input responsive, and renders the filtered list in the background. If the user types another character before the list finishes rendering, React abandons the in-progress work and starts over with the new query. The isPending flag from useTransition lets you show a subtle loading indicator while the transition is in progress.
useDeferredValue is the value-based counterpart to useTransition. Instead of wrapping a setState call, you wrap a value. It returns a deferred copy that lags behind the real value during heavy renders. This is useful when you receive a value from props or context and cannot control the setState call. The component receiving the deferred value must be wrapped in React.memo for this to be effective -- otherwise React will re-render it with the real value anyway.
Suspense with transitions is a powerful combination. Normally, when a lazy-loaded component or a Suspense-enabled data fetch triggers loading, React shows the Suspense fallback (usually a spinner), replacing the current content. With startTransition, React keeps showing the current content while the new content loads in the background. The user sees the existing page with a subtle loading indicator instead of a jarring switch to a spinner.
Streaming SSR is a server-side feature that works with Suspense. Instead of rendering the entire page to HTML on the server and sending it all at once, React 18 can stream HTML progressively. Fast parts are sent immediately, and slow parts (wrapped in Suspense) are streamed as they become ready. Each section can hydrate independently, so users can interact with fast sections while slow sections are still loading.
Selective hydration builds on streaming SSR. Instead of hydrating the entire page at once, React can hydrate individual Suspense boundaries independently. If a user clicks on a section that has not been hydrated yet, React prioritizes hydrating that section first. This makes large pages feel interactive much sooner.
import { useState, useTransition, memo } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
const value = e.target.value;
// Urgent: update the input immediately
setQuery(value);
// Non-urgent: filter results can wait
startTransition(() => {
const filtered = allItems.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setResults(filtered);
});
}
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending && <Spinner />}
<ResultsList results={results} />
</div>
);
}
// ResultsList is expensive - renders 10,000 items
const ResultsList = memo(function ResultsList({ results }) {
return (
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
// Without transition: typing feels laggy because React
// blocks on rendering 10,000 items before showing keystroke
// With transition: input updates instantly, list updates
// when React has free time. Each keystroke interrupts
// the previous list render.
useTransition splits the update into urgent (input value) and non-urgent (filtered results). The isPending flag lets you show a loading indicator for the deferred content.
import { useState, useDeferredValue, memo } from 'react';
function SearchResults() {
const [query, setQuery] = useState('');
// deferredQuery lags behind query during heavy renders
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{/* Dim stale results while new ones are computing */}
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<ExpensiveList query={deferredQuery} />
</div>
</div>
);
}
// Must be memoized for useDeferredValue to be effective
const ExpensiveList = memo(function ExpensiveList({ query }) {
// Simulate expensive render
const items = computeExpensiveFilter(query);
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
// useDeferredValue vs useTransition:
// - useTransition: wraps the setState call (you control the update)
// - useDeferredValue: wraps the value (when you can't control the update,
// e.g., value comes from props or external source)
useDeferredValue keeps the old value while React renders the new one in the background. The component must be memoized so React can skip it when the deferred value hasn't changed yet.
import { useState, useTransition, Suspense, lazy } from 'react';
const PostsTab = lazy(() => import('./PostsTab'));
const CommentsTab = lazy(() => import('./CommentsTab'));
const PhotosTab = lazy(() => import('./PhotosTab'));
function TabContainer() {
const [tab, setTab] = useState('posts');
const [isPending, startTransition] = useTransition();
function selectTab(nextTab) {
// Without transition: shows Suspense fallback immediately
// setTab(nextTab);
// With transition: keeps showing current tab until new one is ready
startTransition(() => {
setTab(nextTab);
});
}
return (
<div>
<nav>
<button onClick={() => selectTab('posts')}
style={{ fontWeight: tab === 'posts' ? 'bold' : 'normal' }}>
Posts
</button>
<button onClick={() => selectTab('comments')}
style={{ fontWeight: tab === 'comments' ? 'bold' : 'normal' }}>
Comments
</button>
<button onClick={() => selectTab('photos')}
style={{ fontWeight: tab === 'photos' ? 'bold' : 'normal' }}>
Photos
</button>
</nav>
{isPending && <div className="loading-bar" />}
<Suspense fallback={<Spinner />}>
{tab === 'posts' && <PostsTab />}
{tab === 'comments' && <CommentsTab />}
{tab === 'photos' && <PhotosTab />}
</Suspense>
</div>
);
}
// Without transition: clicking a tab shows <Spinner /> (bad UX)
// With transition: current tab stays visible while new tab loads
// isPending shows a subtle loading indicator (good UX)
Wrapping Suspense-triggering updates in startTransition prevents the fallback from appearing. Users see the current content with a loading indicator instead of a blank spinner.
Concurrent Rendering, startTransition, useTransition, useDeferredValue, Automatic Batching