React18-Concurrency
Concurrent Rendering (rendering that can be interrupted) is a React mode where it can start rendering a new UI version, pause it if something more urgent comes up (a keystroke, a click), then resume or discard the old work - instead of.
On this page
title: React 18 - Concurrency (Automatic Batching, Transitions, Suspense Streaming SSR)
1) What is Concurrent Rendering? How is it different from old React?
Definition (short version)
Concurrent Rendering (rendering that can be interrupted) is a React mode where React can start rendering a new UI version, pause it if something more urgent comes up (a keystroke, a click), then resume or discard the old work - instead of running one render pass "from start to finish" and locking the main thread until it's done.
Terminology note: Concurrency here does not mean React runs multiple threads in parallel (multi-threading). React still runs on a single main thread; "concurrent" means React interleaves multiple streams of render work by priority.
How did "old" React render?
Before understanding React 18, it's worth splitting things into 3 generations (don't conflate "old React" with just React 15):
| Generation | Root API | Rendering characteristics |
|---|---|---|
| React ≤ 15 | ReactDOM.render | Stack Reconciler algorithm: walks the component tree in one pass, hard to split up, hard to pause. |
| React 16–17 | ReactDOM.render (legacy) | Already has Fiber (a small unit of work), but by default still renders synchronously - an update usually runs the entire render + commit before doing anything else. |
| React 18+ | createRoot().render() | Concurrent features ON by default: rendering can be interruptible, prioritized via lanes, with broader batching. |
The "old" behavior developers commonly ran into (legacy / synchronous mindset):
- User clicks or types →
setState. - React renders the whole related tree continuously on the main thread.
- Once rendering finishes → commit (writes to the DOM) all at once.
- During steps 2–3, if the tree is large → the UI freezes, input lags.
[Update] ──► [Render phase: runs to completion, no pausing] ──► [Commit phase] ──► [Screen updates]
↑
Main thread held for a long time
What does React 18 change at the "visible" layer?
| Aspect | Old React (legacy sync) | React 18 (Concurrent) |
|---|---|---|
| Heavy renders | Usually blocks until done | Can yield to input/animation midway |
| Multiple consecutive updates | Limited batching (mostly inside React events) | Automatic batching is broader (Promise, setTimeout…) |
| "Non-urgent" updates | Still competes for priority like a normal update | startTransition / useDeferredValue → low-priority lane, can be interrupted |
| Unfinished UI | Hard to show a controlled intermediate state | Suspense + isPending → fallback / temporary old UI |
| SSR | Usually waits for the full page HTML | Streaming SSR - sends the shell first, slower parts later |
Key point: React 18 does not change the "component + state + props" model. It changes how the Scheduler orchestrates render work after state changes.
How does the underlying mechanism change / get "turned on"?
1) Fiber - a foundation from React 16, but React 18 is the first to "use it fully"
Fiber is the small unit of work in the reconciler tree. Each component corresponds to a Fiber node; React processes node by node instead of "swallowing" the whole tree in one go like the Stack Reconciler.
Thanks to Fiber, React can:
- Pause between nodes.
- Resume at the next node.
- Discard the work-in-progress tree if a more important update comes in.
Fiber has existed since React 16; Concurrent Rendering is how the Fiber mode is turned on to allow interrupting + prioritizing, via
createRootand various APIs (useTransition, …).
2) Two phases: Render (interruptible) vs Commit (uninterruptible)
React always splits an update into 2 phases:
RENDER PHASE COMMIT PHASE
(pure, can be interrupted) (synchronous, atomic)
┌────────────────────────────────────────┐ ┌──────────────────────────┐
│ Call components → create React elements │ │ Write to the real DOM │
│ Diff → decide what changed │ │ Run layout effects │
│ Doesn't touch the DOM the user sees │ │ Run passive effects │
└────────────────────────────────────────┘ └──────────────────────────┘
- Old React (sync): The render phase runs straight through to the end, then commits.
- React 18 (concurrent): The render phase can be split up (time slicing) - process a few Fibers, check if there's time left, and if not, yield the main thread (so the browser can handle input, paint).
The commit phase is still synchronous - so the user never sees a "half-finished" UI (half old list, half new list on the DOM).
3) Two trees: Current vs Work-in-progress (double buffering)
React always maintains:
- Current tree - matches what's currently displayed.
- Work-in-progress (WIP) tree - the draft being built during the render phase.
During concurrent rendering:
- If the user keeps typing → React can discard the old WIP, clone from Current, and build a new WIP with the latest state.
- Only once the WIP "wins" (isn't interrupted by an urgent update) does it get swapped into Current at commit time.
This is why React 18 can "prepare multiple UI versions" - it's really just drafts in memory, not drawing multiple versions on screen at once.
4) Lanes - the update priority system (React 18)
Instead of every setState being treated equally, React 18 assigns each update to a lane (a priority bitmask):
- High lane: clicks, keyboard, hover that need an immediate response.
- Low lane: updates wrapped in
startTransition, values fromuseDeferredValue.
The Scheduler picks the higher lane first; a low lane can be delayed or canceled if a high lane comes in.
User types "a" (urgent lane) ──► render the input immediately
│
└── filter 10k items (transition lane) ──► start rendering the list
│
User types "ab" (urgent) ──► CUTS OFF the old list render ──► renders the input + new list
5) Time slicing - splitting work up per frame
Time slicing = Fiber + Scheduler cooperating with the browser:
- Each render "slice" only runs within a short time budget (a few ms).
- Budget runs out →
yield→ the browser paints / handles events. - Next frame → continues with the next Fiber.
→ Things feel "smooth" because the main thread isn't continuously monopolized by one long render.
6) createRoot - the switch that turns on Concurrent Features
// React 18+: concurrent features ON (default for this tree)
import { createRoot } from "react-dom/client";
createRoot(document.getElementById("root")).render(<App />);
// Legacy (React 17 and earlier): removed in React 19
// ReactDOM.render(<App />, root); // sync root, no full concurrent support
Same component, different root API → different Scheduler behavior (interrupting or not, async batching, client-side Suspense data, …).
An example: "same code, different feel"
// Not wrapped in a transition: the update can still block if the render is heavy
setQuery(value);
setFiltered(heavyFilter(bigList, value)); // renders the large list immediately
// React 18 concurrent: mark the heavy part as non-urgent
setQuery(value); // urgent - the input responds immediately
startTransition(() => {
setFiltered(heavyFilter(bigList, value)); // transition lane - can be interrupted
});
Old React: both setState calls still run, but there's no concept of a lane → rendering the heavy list still competes for the main thread on equal footing with keystrokes.
React 18: startTransition tells the Scheduler it's "okay to delay / drop" the list render if the user keeps typing.
Summary: what changed "under the hood"?
| Layer | Old React (sync mindset) | React 18 Concurrent |
|---|---|---|
| Reconciler | Stack (≤15) or Fiber but sync (16–17 legacy) | Fiber + interruptible rendering |
| Scheduling | One update → runs to completion | Lanes + urgent/transition priority |
| Time | One continuous block | Time slicing / yielding between Fibers |
| UI tree | Mostly a single WIP branch | Current / WIP, WIP can be discarded |
| DOM | Commit after a synchronous render | Commit is still atomic; new rendering is more flexible |
| API surface | ReactDOM.render | createRoot + Transitions + Suspense SSR |
Connection to the next sections in this document
Concurrent Rendering isn't a single API - it's the foundation; the following sections are how you take advantage of that foundation:
- Automatic Batching - the Scheduler groups updates (fewer renders).
- Transitions - you label an update as low-priority (
useTransition,useDeferredValue). - Suspense + Streaming SSR - display / send HTML piece by piece while work isn't done yet.
See also in the repo: 8. React Fiber.md, 0. Reconciliation.md.
2) Automatic Batching
Definition
Automatic Batching is a React 18 mechanism: no matter where you call setState in a row (inside a Promise, setTimeout, a native event…), React will automatically group multiple updates into a single render (or as few renders as possible).
Role / why it matters
- Reduces the number of unnecessary renders.
- Reduces UI "thrash" and improves performance.
- Makes async code more pleasant to write: you don't have to "guess" whether React will batch or not.
Quick comparison
- Before React 18: batching was reliably guaranteed only within a React event handler.
- React 18: batching extends to most contexts (Promise/microtask, timers, native events…).
Example
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount((c) => c + 1);
setCount((c) => c + 1);
}
return <button onClick={handleClick}>Count: {count}</button>;
}
In React 18, calling the setter multiple times in a row within the same "processing turn" will usually be batched together. This is even more useful when updates happen inside an async boundary, such as:
setTimeout(() => {
setA(1);
setB(2);
}, 0);
Important notes
- Batching doesn't make the state setter "synchronous" in the way you might think.
setStateis still a scheduling operation. - If you need to compute something based on the previous state, use the functional update form (
setX(prev => ...)) to avoid a stale closure.
3) Transitions (useTransition & useDeferredValue)
Definition
Transitions are a mechanism that lets you split UI updates into 2 groups:
- Urgent: must respond immediately (keystrokes, clicks, input value).
- Transition (non-urgent): can wait a bit (filtering a list, updating search results, switching to a tab with heavy content…).
React will prioritize rendering the urgent update first; the transition can be delayed or "replaced" by an urgent update.
3.1) useTransition
Role
useTransition lets you wrap "non-urgent" updates with startTransition(() => ...) and gives you back:
isPending: whether it's currently in a transition state (typically used to show a spinner/lightly disable the UI).startTransition: the function that wraps the update.
Syntax
const [isPending, startTransition] = useTransition();
Example: Search with a heavy list
import { useMemo, useState, useTransition } from "react";
function Search() {
const [query, setQuery] = useState("");
const [items, setItems] = useState([]);
const [isPending, startTransition] = useTransition();
const bigData = useMemo(
() => Array.from({ length: 10000 }, (_, i) => `Item ${i}`),
[]
);
function onChange(e) {
// Urgent: update the input text immediately
setQuery(e.target.value);
// Transition: filtering the heavy data can be delayed
startTransition(() => {
const q = e.target.value.toLowerCase();
const filtered = bigData.filter((x) => x.toLowerCase().includes(q));
setItems(filtered);
});
}
return (
<div>
<input value={query} onChange={onChange} />
{isPending && <div>Updating results…</div>}
<ul>
{items.slice(0, 20).map((x) => (
<li key={x}>{x}</li>
))}
</ul>
</div>
);
}
Explanation of "the right way to think about it"
- Input typing will always feel "smooth" because the urgent update is prioritized.
- The UI can display the old results during the transition, then update once it's ready.
3.2) useDeferredValue
Definition
useDeferredValue(value) returns a "deferred version" of value, typically used to delay rendering a heavy part of the UI that depends on value.
Role
- Reduces the load on the component tree that displays a list/filtering.
- When the user types fast, the UI doesn't get blocked for long by rendering based on that value.
Example
import { useDeferredValue, useMemo, useState } from "react";
function DeferredSearch({ bigData }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => {
const q = deferredQuery.toLowerCase();
return bigData.filter((x) => x.toLowerCase().includes(q));
}, [bigData, deferredQuery]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.slice(0, 20).map((x) => (
<li key={x}>{x}</li>
))}
</ul>
</div>
);
}
4) Suspense on the Server (Streaming SSR)
Definition
Suspense is how you declare: "this piece of UI might not be ready yet, render the fallback until it is."
In SSR streaming, React can:
- Send the page's HTML "shell" immediately.
- For the "slow" parts (components that need to fetch/lazy-load/data), React sends their HTML afterward, once it's ready.
Role / benefits
- Improves time-to-content (perceived performance).
- Avoids making the user wait for the entire page before seeing any UI.
- Well-suited for things like: comments, product lists, secondary widgets…
Example (conceptual)
import { Suspense } from "react";
function Page() {
return (
<div>
<h1>Shop</h1>
<Suspense fallback={<div>Loading products…</div>}>
<ProductList /> {/* a "slow" component */}
</Suspense>
</div>
);
}
In SSR streaming, the fallback can appear in the HTML early, and the real content is "streamed" in afterward.
Explanation: "what conditions are needed?"
- You need a framework/renderer that supports streaming SSR.
- The component inside
Suspensemust "suspend" correctly (e.g., lazy/async data following the Suspense mechanism of your environment).
5) Checklist for using concurrency in the right places
- For input/clicks that must respond quickly: use a normal urgent update.
- For heavy rendering based on a query/filter/computation: use
startTransitionoruseDeferredValue. - For slow data/UI blocks on the server: wrap them with
<Suspense fallback=...>to take advantage of streaming.
