
Concurrent Rendering in React: What Changed and Why
Type into a search box that filters ten thousand rows and, in an older React app, the input freezes. Every keystroke kicks off a render of the whole list, and until that render finishes, the browser can't paint the character you just typed. The work isn't wrong, it's just in the way. React had no way to say "this update matters more, do it first."
Concurrent rendering is the change that fixed this. Starting with React 18, React can prepare a new version of the UI in the background, pause halfway through to handle something more urgent, and throw away work that's no longer needed. It's not a single feature you call. It's a new capability of the renderer that a handful of APIs (startTransition, useDeferredValue, Suspense) let you use.
In this post you'll see what changed between the old synchronous model and the concurrent one, how to opt in, how the main APIs behave in practice, and what concurrent rendering expects from your components so it can work correctly.
The Old Model: Rendering Was All or Nothing
Before React 18, once React started rendering an update, it finished. Rendering ran as one uninterrupted chunk of JavaScript on the main thread. If that chunk took 200 milliseconds, the browser couldn't respond to clicks, keystrokes, or scrolling for 200 milliseconds.
React's internal architecture had already moved toward something better. The Fiber reconciler, introduced in React 16, split rendering into small units of work, one per component. But in practice, every update was still processed synchronously from start to finish. Fiber made interruption possible. React 18 made it real.
Here's the classic problem in code:
import { useState } from "react";
function SlowList({ query }: { query: string }) {
const items: string[] = [];
for (let i = 0; i < 20000; i++) {
const label = `Item ${i}`;
if (label.includes(query)) items.push(label);
}
return (
<ul>
{items.slice(0, 500).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
export default function Search() {
const [query, setQuery] = useState("");
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<SlowList query={query} />
</>
);
}
The input value and the list both depend on query. Each keystroke produces one update, and React has to render SlowList before the input can show the new character. On a slow device, typing feels sticky.
What Concurrent Rendering Actually Means
Concurrent doesn't mean parallel. React still runs on one thread. It means React can work on more than one version of the UI at a time and switch between them.
In practice, concurrent rendering gives React three new abilities:
- Interruptible rendering. React renders in small slices and yields back to the browser roughly every 5 milliseconds, so input and paint can happen in between.
- Prioritized updates. Updates are tagged with a priority (React calls these lanes). A keystroke is urgent. Re-rendering a filtered list can wait.
- Discardable work. If a newer update makes an in-progress render obsolete, React drops it and starts over with the latest state. Nothing half-finished is ever shown.
The key guarantee that makes this safe is that the commit phase is still synchronous. React may render a tree several times, pause, and restart, but when it finally applies changes to the DOM, it does it all at once. You never see a half-updated UI.
How to Opt In
Concurrent features are enabled by the root API. If you use createRoot, you have them:
// main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
The old ReactDOM.render API was deprecated in React 18 and removed in React 19. If you're on React 19, every app uses the concurrent root.
Here's the part that surprises people: switching to createRoot doesn't make every update concurrent. By default, updates from event handlers are still treated as urgent and rendered synchronously, just like before. Concurrent rendering only kicks in when you mark an update as non-urgent. That was a deliberate choice so that upgrading wouldn't change behavior for existing apps.
Marking Updates as Non-Urgent with startTransition
A transition is an update you're telling React it can interrupt. The input's state stays urgent. The expensive part becomes a transition:
import { useState, useTransition } from "react";
export default function Search() {
const [input, setInput] = useState("");
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setInput(value); // urgent: update the input now
startTransition(() => {
setQuery(value); // non-urgent: can be interrupted
});
}
return (
<>
<input value={input} onChange={handleChange} />
{isPending && <p>Updating results...</p>}
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<SlowList query={query} />
</div>
</>
);
}
Now when you type "abc" quickly, React handles each setInput right away, so the input stays responsive. It starts rendering SlowList for "a", gets interrupted by "ab", discards the "a" render, starts again, and so on. Only the final render for "abc" is committed. isPending is true while a transition render is in progress, so you can show a hint without hiding the old results.
You can also call startTransition directly, imported from react, when you're outside a component or don't need isPending:
import { startTransition } from "react";
startTransition(() => {
setTab("reports");
});
Async Transitions in React 19
React 19 extended transitions to async functions. Any state update after an await inside a transition is part of that transition, and isPending stays true until the whole async function finishes:
import { useState, useTransition } from "react";
import { saveProfile } from "./api";
export function SaveButton({ name }: { name: string }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleClick() {
startTransition(async () => {
const result = await saveProfile(name);
if (!result.ok) {
startTransition(() => setError(result.message));
}
});
}
return (
<>
<button onClick={handleClick} disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
{error && <p role="alert">{error}</p>}
</>
);
}
Notice the nested startTransition around setError. In React 19, updates after an await lose their transition context because JavaScript doesn't preserve it across async boundaries, so you wrap them again. This is the same mechanism that powers form Actions and useActionState.
Deferring a Value with useDeferredValue
Sometimes you don't control the state update. Maybe the value comes in as a prop. useDeferredValue gives you a lagging copy of a value that React updates in a transition:
import { memo, useDeferredValue, useState } from "react";
const Results = memo(function Results({ query }: { query: string }) {
// imagine an expensive render here
return <SlowList query={query} />;
});
export default function Search() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<Results query={deferredQuery} />
</div>
</>
);
}
On each keystroke, React first re-renders with the new query but the old deferredQuery. Because Results is wrapped in memo and its prop hasn't changed, that render is cheap. Then React tries a background render with the new deferredQuery, which can be interrupted by the next keystroke.
The memo wrapper matters. Without it, Results re-renders during the urgent pass anyway and you lose most of the benefit. There's more on both hooks in useTransition and useDeferredValue for smoother UIs.
React 19 added an optional second argument, an initial value, used on the first render:
const deferredQuery = useDeferredValue(query, "");
Suspense Works Better with Concurrency
Suspense predates React 18, but concurrent rendering changes how it behaves during updates. Without transitions, if an update causes a component to suspend, React immediately replaces the visible content with the nearest fallback. If the user is looking at a list and clicks a tab, the list vanishes and a spinner appears.
Inside a transition, React keeps showing the old UI while the new one loads in the background:
import { Suspense, useState, useTransition } from "react";
import { Tabs } from "./Tabs";
import { TabPanel } from "./TabPanel";
export default function Dashboard() {
const [tab, setTab] = useState("overview");
const [isPending, startTransition] = useTransition();
function selectTab(next: string) {
startTransition(() => setTab(next));
}
return (
<>
<Tabs active={tab} onSelect={selectTab} pending={isPending} />
<Suspense fallback={<p>Loading...</p>}>
<TabPanel tab={tab} />
</Suspense>
</>
);
}
On the first load, the fallback shows normally. When switching tabs, the current panel stays visible, isPending turns true, and the new panel swaps in once its data is ready. Routers like React Router v7 wrap navigations in transitions for exactly this reason.
Streaming Server Rendering
Concurrent rendering also changed the server. renderToPipeableStream (Node) and renderToReadableStream (web streams) can send HTML in chunks. Parts of the page wrapped in <Suspense> stream in later, and selective hydration lets React hydrate whichever section the user interacts with first, rather than hydrating the whole page top to bottom before anything is clickable.
You rarely call these APIs yourself. Frameworks like Next.js and React Router's framework mode use them under the hood. The point is that the same interruptible renderer powers both client updates and server streaming.
What Concurrent Rendering Expects from Your Code
Because React can now render a component, discard the result, and render it again, your render functions must be pure. Rendering the same props and state must produce the same output, with no side effects.
These patterns break under concurrency:
// Bad: mutating something outside React during render
let renderCount = 0;
function Counter() {
renderCount++; // may run several times per visible update
return <p>Rendered {renderCount} times</p>;
}
// Bad: side effect during render
function Tracker({ page }: { page: string }) {
analytics.track("view", page); // may fire for renders that never commit
return null;
}
Move side effects into useEffect or event handlers. Effects only run after a commit, so they never fire for discarded renders:
import { useEffect } from "react";
import { analytics } from "./analytics";
function Tracker({ page }: { page: string }) {
useEffect(() => {
analytics.track("view", page);
}, [page]);
return null;
}
This is also why Strict Mode double-invokes renders and effects in development. It's simulating what concurrent rendering may do in production, so impure code surfaces early.
Tearing and External Stores
A subtler problem is tearing. If a component reads from a mutable external store (a global variable, a non-React state library) and React pauses mid-render, the store might change before React resumes. Some components render with the old value and some with the new one, and the UI shows two inconsistent states at once.
The fix is useSyncExternalStore, which React uses to detect a mismatch and re-render synchronously:
import { useSyncExternalStore } from "react";
function subscribe(callback: () => void) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
export function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true // server snapshot
);
}
Modern versions of Redux, Zustand, and similar libraries already use this hook internally, so you only need it when you build your own subscription.
What Didn't Change
It's worth being clear about what concurrent rendering is not:
- It's not multithreading. All rendering still happens on the main thread. Truly CPU-heavy work that can't be split up should go to a Web Worker.
- It doesn't make slow components fast. A transition keeps the UI responsive while slow work happens, but the work still takes the same time. Profile and optimize the component too.
- It doesn't apply to controlled inputs. You can't put the input's own state update in a transition. Text inputs must update synchronously, or the cursor jumps and characters get lost.
- Effects and the DOM commit are not interruptible. Only the render phase is.
Common Mistakes with Concurrent Rendering
- Wrapping the input's own state in
startTransition. The input then lags behind typing. Split state into an urgent value for the input and a transitional value for the expensive part. - Using
useDeferredValuewithoutmemo. The child re-renders in the urgent pass anyway, so the deferral does nothing useful. - Expecting transitions to replace debouncing for network calls. A transition can discard renders, but it doesn't cancel fetches. If each keystroke triggers a request, you still need debouncing or request cancellation.
- Side effects during render. Logging, analytics, mutating refs or globals in the render body can run for renders that never commit.
- Reading mutable external state directly. Use
useSyncExternalStoreor a library that does, to avoid tearing. - Forgetting the nested
startTransitionafterawait. In React 19 async transitions, state updates after anawaitneed to be wrapped again to stay non-urgent.
Frequently Asked Questions (FAQ) About Concurrent Rendering in React
Not as a separate mode. Early experiments called it Concurrent Mode, but React 18 shipped it as concurrent features instead. Using createRoot enables the concurrent renderer, and individual updates only render concurrently when you use startTransition, useTransition, useDeferredValue, or Suspense during a transition.
It makes the app feel faster by keeping urgent interactions responsive, but it doesn't reduce the total amount of work. A component that takes 300 milliseconds to render still takes 300 milliseconds. Concurrent rendering just stops that work from blocking typing, clicking, and painting in the meantime.
useTransition wraps the state update itself, so you use it when you own the setter. useDeferredValue wraps a value, so you use it when the value comes from props or a hook you don't control. Both produce a non-urgent render that React can interrupt.
In development, Strict Mode intentionally renders components twice to catch impure code. In production with transitions, React may also start a render, abandon it because a newer update arrived, and render again. This is expected, which is why render functions must be pure.
Probably not. Current versions of Redux, Zustand, Jotai, and TanStack Query are built to be safe with concurrent rendering, mostly through useSyncExternalStore. Only very old versions or hand-rolled global stores that read mutable values during render are at risk of tearing.
No. The value bound to a controlled input must update synchronously, otherwise characters can be lost. Keep the input state urgent and put the expensive derived update, such as filtering or a chart re-render, in a transition.
Conclusion
Concurrent rendering changed React from a renderer that always finished what it started into one that can pause, prioritize, and discard work. createRoot enables it, and startTransition, useTransition, useDeferredValue, and Suspense let you decide which updates can wait. The commit is still atomic, so users never see half-finished UI, but your render functions must be pure because React may run them more than once.
Start by finding the interactions in your app that feel sluggish, usually typing into a filter or switching between heavy views. Split the urgent state from the expensive state, wrap the expensive update in a transition, and add an isPending hint. Then profile the slow component itself, because the best transition is one you no longer need.


