Understanding React 18 Concurrent Rendering
A deep dive into React 18's concurrent rendering mechanism, including time slicing, priority scheduling, the Tearing problem, and the principles and source code analysis of useSyncExternalStore.
Target audience: Readers with some understanding and experience of React internals and frontend development. Some concepts and knowledge are assumed to be already known and will not be explained in detail.
Before React 18, React used synchronous rendering by default. Rendering of updates was completed in a single, uninterrupted, synchronous transaction. With synchronous rendering, once an update begins rendering, nothing can interrupt it until the user sees the result.
React 18 introduces concurrent rendering, whose key feature is interruptible rendering. React may start rendering an update, pause in the middle, then continue later. It may even abandon an in-progress render entirely. React guarantees that the UI remains consistent even if a render is interrupted. To do this, it waits until the entire tree has been evaluated before performing DOM mutations. With this capability, React can prepare new screens in the background without blocking the main thread. This means even while the UI is performing a large rendering task, it can still respond immediately to user input, creating a fluid user experience.
https://react.dev/blog/2022/03/29/react-v18#what-is-concurrent-react
The concurrent update mechanism is essentially time slicing, where high-priority tasks can interrupt low-priority ones. During rendering, because the continuous rendering process is split into individual sliced rendering segments, there are opportunities to respond to user actions between segments. Which task runs in a given time slice is determined by the task’s priority. When a high-priority update arrives, it interrupts the old update, executes the high-priority update first, and then continues with the low-priority update after completion.
Here is a simple comparison example (examples in this article generally use loop code like while (performance.now() - now < 100) to simulate component lag). Code: Torn React - Juejin
| Synchronous (lagging) | Concurrent Mode (smooth) |
|---|---|
useDeferredValue. You can clearly feel the experience is much smoother. |
This interruptible rendering feature can only be experienced when using specific APIs. Using the right image as an example:
import { useState, useDeferredValue, memo } from 'react';
function Heavy({ value }: { value: string }) {
const start = performance.now();
while (performance.now() - start < 300) {}
return <span>Heavy: {value}</span>;
}
const HeavyMemo = memo(Heavy);
export default function App() {
const [value, setValue] = useState('');
const deferredValue = useDeferredValue(value);
return (
<div>
<input value={value} onChange={e => setValue(e.target.value)} /><br/>
<HeavyMemo value={deferredValue} />
</div>
);
}
Without useDeferredValue, every time you type in the input, HeavyMemo receives new props and re-renders the entire component, making input feel very laggy.
useDeferredValue is a React hook for performance optimization. It marks a value as deferrable (how? We’ll explain later in the article), letting React process more urgent tasks first, such as UI updates triggered by user interactions.
By calling useDeferredValue at the top level of the App component, we get a deferred version of value. In the code above, React prioritizes updating the input (which must be fast) over updating HeavyMemo (in other words, allowing it to update at a slower pace). This prevents HeavyMemo from blocking the rest of the UI. This doesn’t speed up HeavyMemo’s re-render. Instead, it tells React to lower the priority of HeavyMemo’s re-render so it doesn’t block keyboard input. The list will “lag behind” the input, then “catch up.”
The deferred “background” render is interruptible. For example, if you type again in the input, React will abandon that operation and restart with the new value. React always uses the latest provided value. In contrast, with React’s previous synchronous rendering, components rendered in an uninterruptible manner until completion, and the UI only updated after rendering finished. If a component took a long time, the effects of user interactions remained invisible until rendering completed, resulting in lag.
useDeferredValue vs Debounce and Throttle
Debouncing and throttling are common techniques for controlling function call frequency in JavaScript. In some cases, debouncing and throttling can be entirely replaced by useDeferredValue, or they can be used in combination.
useDeferredValue doesn’t require choosing a fixed delay — the background render time is the delay. On powerful devices, re-rendering happens almost instantly and is barely noticeable; on weaker devices, it “lags behind” the input.
useDeferredValue’s deferred re-render is interruptible. If a user presses a key while React is re-rendering a large list, React will abandon that render, handle the key press, and then start re-rendering again — avoiding lag. Debouncing and throttling, on the other hand, are blocking; they merely postpone the moment when rendering blocks the key press. Lag will still occur because if a user presses a key during a synchronous render, they’ll still feel the lag.
Use Cases
- useDeferredValue: Suitable for optimizing rendering in React applications, especially for non-critical updates like non-essential UI updates or async data loading. It lets the app process more important updates first, then handles deferred updates during idle time. (Network requests are not reduced!)
- Debounce and Throttle: Suitable for controlling function call frequency — search input suggestions, window resizing, scroll event listeners, mouse movement handling — reducing the number of function executions and lowering performance overhead. They can also be used for non-rendering optimizations like reducing network requests. (Summary: the work to optimize doesn’t occur during the rendering process)
Will useDeferredValue Components Be Interrupted Forever?
No. This is known as the “task starvation problem.”
When high-priority tasks finish executing and a low-priority task is about to run, if another high-priority task is inserted, the high-priority task runs again. If high-priority tasks keep cutting in line, low-priority tasks never get to execute. We call this the task starvation problem. (Reference [11])
Task priority is ultimately handled by the Scheduler, which uses an expirationTime mechanism to solve the starvation problem. Transition tasks correspond to NormalPriority, with a 5-second expiration time. This means if the task still hasn’t had a chance to execute after 5 seconds, it will switch to synchronous rendering mode and render.
Starting from the Tearing Problem
Screen Tearing is a phenomenon where a display shows two or more frames simultaneously on the same screen. We won’t elaborate on the visual manifestations of screen tearing. In React, there’s a similar phenomenon. Here’s an image for reference (demo link: codesandbox.io/p/sandbox/t…):
In theory, JavaScript itself is single-threaded and shouldn’t have tearing issues.
This phenomenon only occurs in React’s Concurrent mode. In the React 18 major release, React switched to Concurrent mode by default. React’s “concurrent rendering” mode can pause the rendering process to allow other work to proceed. Between these pauses, updates can silently occur and change the data used for rendering, which can cause the UI to display two different values for the same data.
In most cases, concurrent rendering produces a consistent UI, but under specific conditions, an edge case exists that can cause problems. There are no tearing issues when using native React setState and useReducer hooks, but tearing can occur in some state library external state management (such as older versions of react-redux and jotai).
As shown below, suppose a value in the External Store changes and starts a reactive update: the first node renders as blue (the store’s value at that time). Then something happens that changes the External Store’s value to red, but any component rendered after that will get the current External Store value, so the second and third components render as red. At this point, “Tearing” has occurred.
useSyncExternalStore
The useSyncExternalStore hook exists specifically to combat the “Tearing” problem. The external cause of “Tearing” is that (external) state can be updated during rendering while React is completely unaware. Conversely, if your application’s state is entirely within React itself, you won’t encounter the “Tearing” problem — React guarantees this.
Hereafter, useSyncExternalStore will be abbreviated as “uSES.”
Components and custom Hooks that do not access external mutable data during rendering, and only pass information using React props, state, or context, should not have issues, because React natively handles these concurrently. - Concurrent React for Library Maintainers - reactwg/react-18 Discussion 2021-7-8
According to React 18’s Release docs: uSES is a new Hook that allows external stores to support concurrent reads by forcing synchronous mutations. It eliminates the need for useEffect when implementing subscriptions to external data sources and is the recommended Hook API for third-party libraries implementing external stores.
- External stores refer to data sources beyond React’s state, context, and reducer — such as global variables, the DOM, localStorage, etc.
- Synchronous mutations means React completes rendering (mutations) synchronously within a single render pass (where “render” refers to the process of generating a React node tree).
- Concurrent reads: “concurrent” here refers to the concurrent rendering mode, ensuring consistent data reads and preventing “Tearing.” (Partially referenced from [7])
In other words, uSES allows components to safely and effectively read (subscribe to) external data sources under React 18’s Concurrent mode, and to schedule updates when the data source changes.
This might still sound a bit vague — let’s look at the comparison in the next section.
State Library Comparison
Here again we reference an image from the blog Torn React - Juejin, with some necessary discussion copied. React 18’s Concurrent Mode was a significant change for community state libraries, and several state management libraries underwent refactoring to adapt — for example, redux@8.
One important note: in all the examples below, startTransition was used to ensure concurrent updates. All are counter examples where the +1 button is clicked three times in succession. Source code: react tearing in concurrent mode - github gist
| Usage | useState | useReducer | useSyncExternalStore | zustand | jotai | Redux |
|---|---|---|---|---|---|---|
| Lag | No | No | Yes | Yes | No | Yes |
| Tearing | No | No | No | No | Yes | No |
Lag: Whether the UI can still respond to user interactions during re-render. For example, uSES or zustand will freeze when clicking again during a re-render (you can see the button stays in a pressed state).
Tearing: Whether the state update produces inconsistent results. For example, after clicking three times in succession with jotai, different value states appear simultaneously.
An implementation example at AIPA: React 18 Concurrent Rendering State Management Test (Try it yourself — requires intranet access)
Why Do uSES and zustand Lag? What Does uSES Actually Do?
import { startTransition } from 'react';
function Controller() {
const increment = useStore((state) => state.increment);
const concurrentAdd = () => startTransition(increment);
return <button onClick={concurrentAdd}>+1</button>;
}
In the code, the state’s increment operation is wrapped in startTransition, right? Shouldn’t this state change be marked as a Transition, enabling concurrent rendering without lag?
Let’s read the source code (from [7], the author has kept only the core logic, removing development mode instrumentation, error handling, and other secondary content):
// Main implementation
function mountSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
// Redeclare referenced global variables at the top of the function — very good practice
const fiber = currentlyRenderingFiber;
// Get different values depending on SSR vs CSR environment
const nextSnapshot = getIsHydrating() ? getServerSnapshot() : getSnapshot();
// Mount a hook to handle the ExternalStore
const hook = mountWorkInProgressHook();
hook.memoizedState = nextSnapshot;
const inst: StoreInstance<T> = {
value: nextSnapshot,
// Store the getSnapshot method itself for later update-time checks
// to see if getSnapshot has changed
getSnapshot,
};
hook.queue = inst;
// Mount an effect to subscribe to store changes
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
// Schedule an effect to update the mutable instance fields.
// We will update this whenever subscribe, getSnapshot, or value changes.
// Because there's no clean-up function, and we track the deps correctly,
// we can call pushEffect directly, without storing any additional state.
// For the same reason, we don't need to set a static flag, either.
pushEffect(
HookHasEffect | HookPassive,
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
undefined,
null,
);
return nextSnapshot;
}
// Store change subscription implementation
function subscribeToStore<T>(fiber, inst: StoreInstance<T>, subscribe) {
const handleStoreChange = () => {
// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceStoreRerender(fiber);
}
};
// Subscribe to the store and return a clean-up function.
return subscribe(handleStoreChange);
}
// Check whether an update should be triggered
function checkIfSnapshotChanged<T>(inst: StoreInstance<T>): boolean {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !is(prevValue, nextValue); // `is` here is object-is, performing reference comparison only.
} catch (error) {
return true;
}
}
// Update the store value
function updateStoreInstance<T>(
fiber: Fiber,
inst: StoreInstance<T>,
nextSnapshot: T,
getSnapshot: () => T,
) {
// These are updated in the passive phase
inst.value = nextSnapshot;
inst.getSnapshot = getSnapshot;
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapshot and getSnapshot values to bail out. We need to check one more time.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceStoreRerender(fiber);
}
}
function forceStoreRerender(fiber) {
const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
if (root !== null) {
// Pass SyncLane here to indicate this is a synchronous update
scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp);
}
}
The React documentation says (from [10]): If the store is mutated during a non-blocking Transition update, React will fall back to performing that update in a blocking manner. Specifically, for each Transition update, React will call getSnapshot again before applying changes to the DOM. If the returned value differs from the initial call, React will re-execute the update from scratch, this time applying it as a blocking update to ensure every component on screen reflects the same version of the store. This is essentially the explanation of the code above.
Combined with the example above:
- When a component first subscribes to a store using uSES, it executes something similar to the
mountSyncExternalStorefunction above. It gets the initial state — usinggetServerSnapshot()if in an SSR environment (whengetIsHydrating()is true), orgetSnapshot()for client-side rendering. - Simultaneously, it creates an
instinstance (line 16), storing thevalue(current Snapshot state) and thegetSnapshotfunction itself, for subsequent comparison and update operations. - After the first click of the
+1button,startTransition(increment)initiates a Transition update (still non-blocking at this point). - During the non-blocking Transition update, the component’s update operation is deferred. But during this process, if the store mutates (e.g., through an external operation modifying the store’s state), the
handleStoreChangecallback insubscribeToStore(line 43-44) is triggered. handleStoreChangecallscheckIfSnapshotChanged(line 57) to check if the state has changed. It uses the latestgetSnapshotfunction to get the new value and compares it with the previousvalue(stored ininst.value). The comparison uses object-is (theisfunction) for reference comparison. If a change is detected, it callsforceStoreRerender(line 89) to force a component re-render.- In
forceStoreRerender(line 89), it callsenqueueConcurrentRenderForLaneto place the update in the update queue, specifying the update priority (SyncLane means synchronous priority — although the overall update is deferred during the non-blocking Transition update, the store mutation update needs to be handled promptly). - Then it calls
scheduleUpdateOnFiber(line 93) to schedule this update. React’s update scheduling mechanism arranges these updates based on priority. - Although the Transition update is overall non-blocking, if a store mutation occurs during it, that mutation update is marked as high priority (SyncLane), so it executes as soon as possible (usually right after urgent updates are processed).
- After the render phase completes, it enters the passive update phase. At this point, the
updateStoreInstanceadded bypushEffect(line 34) executes as a passive effect. updateStoreInstance(line 69) updates theinstinstance’svalueandgetSnapshotproperties. It first assigns the new state valuenextSnapshotand the newgetSnapshotfunction toinst. Then it callscheckIfSnapshotChanged(line 57) again to check for changes, because the store’s state might have been modified between the render phase and the passive effect phase. If changes are found, it callsforceStoreRerender(line 89) again to ensure the component correctly responds to store changes.- So in simple terms, the second and third clicks happen during rendering and cause store changes. After the first render completes, React calls
getSnapshotagain before applying changes to the DOM. If the returned value differs from the initial call, React re-executes the update from scratch, this time as a blocking update, ensuring every component on screen reflects the same version of the store. - Finally,
scheduleUpdateOnFiberenters thescheduleUpdateOnFibermethod, detects that the update priority is SyncLane, (…other conditions and intermediate steps omitted), then performSyncWorkOnRoot performs a synchronous rendering update — at this point, rendering is synchronous and uninterruptible.
The React team is currently revisiting this and plans to natively support concurrent external stores using the use API. The goal is to allow reading external state during rendering without tearing, and to seamlessly work with all concurrent features React provides. https://react.dev/blog/2025/04/23/react-labs-view-transitions-activity-and-more#concurrent-stores
zustand also uses useSyncExternalStore, so it lags just like useSyncExternalStore (you can use use-zustand to solve this, but Tearing will occur). react-redux adopted useSyncExternalStore in 8.0 for its refactoring, so it also experiences lag. Therefore, some state libraries implemented with useSyncExternalStore cannot benefit from Concurrent Mode.
Why Does Jotai Cause Tearing?
This section directly references [2]
From the examples above, we can see that simply using useState or useReducer in Concurrent Mode neither lags nor causes Tearing. Jotai’s internal implementation uses useReducer, so why does it also cause Tearing? Because using useState or useReducer alone is fine, but Jotai uses useReducer + useEffect for synchronous updates, which can lead to Tearing.
Reference: jotai/src/react/useAtomValue.ts at main - pmndrs/jotai
react-redux 7 also uses useReducer + useEffect, so it similarly causes tearing issues in concurrent mode; however, in react-redux 8, it was replaced with useSyncExternalStore, so tearing is no longer an issue (release, PR)
Jotai can fully enjoy the benefits of Concurrent Mode but may experience occasional Tearing.
Why useSyncExternalStore Is Not Used in Jotai - Daishi Kato’s blog Daishi believes the benefits of Suspense and Concurrent support in Jotai outweigh the drawbacks of temporary tearing.
The Tearing phenomenon is only temporary — the UI will eventually reach consistency.
Comparing Synchronous and Concurrent
| Synchronous | Concurrent |
|---|---|
As you can see, the synchronous mode behavior is essentially the same as uSES.
Concurrent rendering is a huge benefit because users can interact with the page without being blocked by React. With concurrent rendering, React can let clicks happen, making the page feel smooth and interactive to the user.
How Is Concurrent Rendering Interruptible?
Let’s revisit the introduction to concurrent mode:
When state updates occur, each update is assigned a priority. If a higher-priority task arrives during an update, it interrupts the current low-priority task, executes the high-priority task first, then resumes the low-priority task. Additionally, in each browser refresh frame, a certain fixed time is reserved for the browser to render the page. If the current update task takes too long, it is interrupted and continued in the next frame. (Reference [9])
A good diagram:
React Work Loop (Right Side)
-
performWorkUntilDeadline (execute scheduling)
-
startTime = currentTime; (record scheduling start time): At the beginning of each work loop, record the current time (
startTime) for subsequent time management. -
workLoop (work loop): The core loop that continuously retrieves the highest-priority task from the task queue and executes it.
- taskQueue (task queue): Stores pending tasks, sorted by priority.
-
currentTask != null (are there still tasks?): Check whether there are tasks to execute.
- If the task queue is not empty, get the highest-priority task and execute it.
- If the task queue is empty, close the message queue — all tasks have been executed.
-
shouldYieldToHost (should we yield to the host?): Determine whether the current task execution time exceeds the scheduling time.
- If time is exceeded, yield to the host environment, register a macro task, and wait for the next execution (of the workloop itself).
- Otherwise, continue executing the next task.
Browser Event Loop Macro Task Scheduling (Left Side)
This section assumes the reader understands the browser’s event loop.
-
Execute macro task
-
JavaScript is a single-threaded, non-blocking scripting language. The JavaScript main thread and the rendering thread are mutually exclusive.
-
Macro task execution process:
- Execute a macro task (if the execution stack is empty, get one from the task queue).
- If a micro task is encountered during execution, add it to the micro task queue.
- After the macro task completes, immediately execute all micro tasks in the current micro task queue (in sequence).
-
-
After macro task execution completes, check for rendering, then the rendering thread takes over. If the macro task queue is empty, sleep until a macro task appears.
Recommended Reading on Concurrent Principles:
React Source Code Analysis: Priority Lane Model Part 1 - Juejin
React Source Code Analysis: Priority Lane Model Part 2 - Juejin
React Source Code Analysis: Scheduler - Juejin
Introduction to React Concurrent Mode - ByteTech
I personally believe these resources explain it better than I can.
Personal Reflections
Here are a few personal thoughts:
-
React’s workloop uses the MessageChannel API: MessageChannel sends messages in the form of DOM Events, making it a macro task that executes at the beginning of the next event loop iteration. This allows the browser’s event loop to continuously run React’s workloop. Here’s a minimalist implementation of an event loop using MessageChannel: MessageChannel - Juejin MDN reference: MessageChannel - Web API | MDN
-
For updates triggered by user actions that don’t use the Transition API, they are essentially still synchronous updates. This is because discrete user action events (like clicks, text input) have a priority of
ImmediateSchedulerPriorityand execute immediately, entering synchronous rendering mode during scheduling. So when developing with React 18, we need to be mindful of this. Some libraries, like routing libraries, handle this for us — when we click<Link />, they execute non-blocking updates so we don’t get temporarily frozen. tanstack/react-router example For continuous interactions like scrolling and dragging, the corresponding expiration time is 2.5 seconds. -
React 18 brought many changes. You can find related discussions in this Discussion: reactwg/react-18 - Discussions For example:
-
When useEffect is the result of a discrete action (click, input), it fires synchronously. discussions/128
-
Automatic batching in React 18 discussions/21
-
References:
- React v18.0 2022-3-19 https://react.dev/blog/2022/03/29/react-v18#what-is-concurrent-react
- Torn React - Tearing 2024-10-9 https://juejin.cn/post/7423359941854412851#heading-1
- Screen Tearing Wiki https://en.wikipedia.org/wiki/Screen_tearing
- React Tearing Problem 2024-10-27 https://juejin.cn/post/7430473240198545427
- What is tearing? 2021-7-8 https://github.com/reactwg/react-18/discussions/69
- Concurrent React for Library Maintainers 2021-7-8 https://github.com/reactwg/react-18/discussions/70
- React 18’s New API “useSyncExternalStore” 2023-1-16 https://bytetech.info/articles/7189071164596027451
- From Usage to Implementation: Embracing React 18 2022-5-9 https://bytetech.info/articles/7095547016524070926
- Introduction to React Concurrent Mode 2021-10-15 https://bytetech.info/articles/7019128966426394638
- useSyncExternalStore - React https://react.dev/reference/react/useSyncExternalStore#caveats
- React Source Code Analysis: Priority Lane Model Part 1 2021-9-17 https://juejin.cn/post/7008802041602506765