React Lanes
React is one of the most popular libraries for creating user interfaces. However, when working with large amounts of data or complex calculations, developers often face performance issues. In this article, we'll explore the concept of React Lanes — a mechanism that allows prioritizing rendering tasks and making the interface more responsive even when performing heavy operations.
What are React Lanes?
Let's consider a typical task for React. Let it be a trivial search through text elements. For this, we'll take a regular input field and a list of elements that are filtered depending on the value entered in this field.
const SearchList = ({ items, filter }) => { // Heavy filtering operation (simulation) const filteredItems = items.filter((item) => item.toLowerCase().includes(filter.toLowerCase()), ); return ( <ul> {filteredItems.map((item, i) => ( <li key={i}>{item}</li> ))} </ul> ); }; const App = () => { const [inputValue, setInputValue] = useState(""); // Generate large list for demonstration const bigList = Array(10_000) .fill(null) .map((_, i) => `Item ${i + 1}`); const handleChange = (e) => { setInputValue(e.target.value); }; return ( <div> <input type="text" value={inputValue} onChange={handleChange} placeholder="Search..." /> <SearchList items={bigList} filter={inputValue} /> </div> ); }
For clarity, let the list consist of a large number of elements. This will obviously provoke a heavy array filtering operation. You can try the resulting application below.
You can experiment with the number of elements depending on the power of your device and the environment in which the application is running. But one way or another, if you quickly enter values into the search string, you can notice some lag when there are many elements in the selection.
This happens because each value change leads to a re-render of the SearchList
component, which in turn must render the required number of child elements.
Synchronous updates
The example above demonstrates typical synchronous work on updating the component tree. I wrote in detail about the update process in the article Detailed React. Reconciliation, Renderers, Fiber, Virtual Tree. The Fiber engine tries to make all necessary changes at once. In other words, the render
phase will not complete until the reconciler has gone through all the scheduled synchronous changes.
Specifically in our example, each change in the text field causes reconciliation of SearchList
, which in turn schedules renders of child components in the same phase. And until all child nodes are processed, the main thread will be blocked by the engine's work. If there are too many components to output, the blocking time becomes significant and manifests as a delay in response to user input.
In the screenshot above, the character "0" was entered into the text field, which gives 2620 child elements. Reconciliation of such a number of elements took 253ms
, considering that delays of more than 100ms are noticeable to the human eye, and the estimated time of a browser frame is 16.6ms (at a refresh rate of 60 fps).
When adding another "0", 181 elements are displayed. Reconciliation already took 146ms
. However, this is still a lot.
Let's continue entering zeros. The next "0" will give only 10 elements, and here we already see an acceptable duration of 10.6ms
.
The fourth zero will leave only a single element, and the task duration was only 2ms
.
Which is not much more than with the value "00000", where there are no elements to display.
Now let's do the reverse procedure and delete one character at a time from the text field.
And finally, an empty string. Duration 699ms
.
Task Priorities
The problem is obvious. Blocking operations have been a problem that needed to be addressed for quite some time. As a solution, one could suggest splitting heavy operations into threads and executing them in parallel. But a JavaScript task cannot be executed in multiple threads.
ReactPriorityLevel
The first attempts to solve the problem were made in 2016. In version 15.2.1, the concept of ReactPriorityLevel
was first introduced. Since tasks cannot be divided into parallel threads, they can at least be arranged in order of importance. The essence of ReactPriorityLevel
is to assign a priority flag to each tree update.
Initially, there were 5 such priorities, from most important to less important:
- SynchronousPriority - for controlled input updates and synchronous operations.
- AnimationPriority - animations must complete the calculation of the next step in the current frame.
- HighPriority - updates that should be executed as quickly as possible to maintain optimal response
- LowPriority - for requests and receiving responses from stores
- OffscreenPriority - for elements that were hidden on the page but become visible again
Expiration Times
In general, the concept of priorities gave a certain result. But not all problems were solved. ReactPriorityLevel
divided tasks into groups, but within a group, tasks still have the same priority. For example, there may well be several animated elements on a page. At the end of 2017, React 16.1 was introduced, which instead of priority flags suggested setting a time by which the task should be processed. The more important the task, the smaller expirationTime
it had and, accordingly, was taken into work earlier than the others.
Lanes
The Expiration Times concept turned out to be quite workable. React 16 lived with it for the next 3 years. The essence of the model was that having priorities A > B > C, you cannot take B into work without executing A. Similarly, you cannot work on C without taking A and B into work.
This approach worked well until Suspense
appeared. Prioritization works as long as all tasks are performed linearly. When a deferred task (such as Suspense
) intervenes in the process, a situation arises where a deferred task with high priority blocks less priority main ones.
In terms of expressing a group of several priorities at once, the Expiration Times model is quite limited. To understand whether to include a task in the scope of work in the current iteration, it is enough to compare the relative priorities:
const isTaskIncludedInBatch = priorityOfTask >= priorityOfBatch;
To solve the Suspense problem, one could use a Set
with priorities. But this would be very costly in terms of performance and would negate all the benefits of prioritization.
As a compromise, one could introduce priority ranges approximately as follows:
const isTaskIncludedInBatch = taskPriority <= highestPriorityInRange && taskPriority >= lowestPriorityInRange;
But even if we turn a blind eye to the fact that in this case it will be necessary to keep two fields, this approach still does not solve all the problems. For example, how to remove a task in the middle of the range? Some solution can certainly be found. But be that as it may, any manipulation of ranges will inevitably affect other groups, and their maintenance and stability will turn into a real nightmare.
To avoid all these problems, the React developers decided to separate the two concepts of prioritization and grouping. And they proposed to express groups of tasks by relative numbers representing a bit mask.
const isTaskIncludedInBatch = (task & batchOfTasks) !== 0;
The type of bit mask representing a task is called Lane. And the bit mask representing a group of tasks is Lanes.
How Lanes Work
In the official release Lanes
first appeared in October 2020 with version React 17.0.0. This was a fairly large refactoring that significantly affected the work of Fiber
.
At the moment, the latest stable version of React is v19.0.0. Most of the work Lanes is encapsulated in the ReactFiberLane
reconciler module.
In fact, Lane
is a 32-bit number where each bit indicates the task's belonging to a certain lane.
/packages/react-reconciler/src/ReactFiberLane.js#L39 export const TotalLanes = 31; export const NoLanes: Lanes = /* */ 0b0000000000000000000000000000000; export const NoLane: Lane = /* */ 0b0000000000000000000000000000000; export const SyncHydrationLane: Lane = /* */ 0b0000000000000000000000000000001; export const SyncLane: Lane = /* */ 0b0000000000000000000000000000010; export const SyncLaneIndex: number = 1; export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000100; export const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000001000; export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000000010000; export const DefaultLane: Lane = /* */ 0b0000000000000000000000000100000; export const SyncUpdateLanes: Lane = SyncLane | InputContinuousLane | DefaultLane; const TransitionHydrationLane: Lane = /* */ 0b0000000000000000000000001000000; const TransitionLanes: Lanes = /* */ 0b0000000001111111111111110000000; const TransitionLane1: Lane = /* */ 0b0000000000000000000000010000000; const TransitionLane2: Lane = /* */ 0b0000000000000000000000100000000; const TransitionLane3: Lane = /* */ 0b0000000000000000000001000000000; const TransitionLane4: Lane = /* */ 0b0000000000000000000010000000000; const TransitionLane5: Lane = /* */ 0b0000000000000000000100000000000; const TransitionLane6: Lane = /* */ 0b0000000000000000001000000000000; const TransitionLane7: Lane = /* */ 0b0000000000000000010000000000000; const TransitionLane8: Lane = /* */ 0b0000000000000000100000000000000; const TransitionLane9: Lane = /* */ 0b0000000000000001000000000000000; const TransitionLane10: Lane = /* */ 0b0000000000000010000000000000000; const TransitionLane11: Lane = /* */ 0b0000000000000100000000000000000; const TransitionLane12: Lane = /* */ 0b0000000000001000000000000000000; const TransitionLane13: Lane = /* */ 0b0000000000010000000000000000000; const TransitionLane14: Lane = /* */ 0b0000000000100000000000000000000; const TransitionLane15: Lane = /* */ 0b0000000001000000000000000000000; const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000; const RetryLane1: Lane = /* */ 0b0000000010000000000000000000000; const RetryLane2: Lane = /* */ 0b0000000100000000000000000000000; const RetryLane3: Lane = /* */ 0b0000001000000000000000000000000; const RetryLane4: Lane = /* */ 0b0000010000000000000000000000000; export const SomeRetryLane: Lane = RetryLane1; export const SelectiveHydrationLane: Lane = /* */ 0b0000100000000000000000000000000; const NonIdleLanes: Lanes = /* */ 0b0000111111111111111111111111111; export const IdleHydrationLane: Lane = /* */ 0b0001000000000000000000000000000; export const IdleLane: Lane = /* */ 0b0010000000000000000000000000000; export const OffscreenLane: Lane = /* */ 0b0100000000000000000000000000000; export const DeferredLane: Lane = /* */ 0b1000000000000000000000000000000;
Conditionally, all lanes can be divided into synchronous and deferred.
Synchronous Lanes
Synchronous lanes include SyncLane
, InputContinuousLane
and DefaultLane
. Tasks placed in these lanes have the highest priority and are executed synchronously in the current phase of the reconciler.
From the names, we can guess that different types of tasks may have different priorities even in the synchronous phase. The most important ones are:
- Root unmounting, which leads to immediate clearing of the React state and initiates an asynchronous removal of the tree elements
- Hot Reload, since JS modules must be immediately replaced with new ones
- Any overrides in Dev Tools must always happen first
- The
useSyncExternalStore
hook synchronously reads data from external storage, this must happen at the very beginning of the reconciliation - The
useOptimistic
hook guarantees synchronous optimistic state update - In the current version of React, there is no official component
<Activity />
(formerly known as<Offscreen />
), but it is present in the engine and may someday become available. This component has three modes: "hidden", "visible" and "manual". Unlike the first two, the manual mode assumes that the developer himself is responsible for hiding/showing the component and manually callsActivity.attach()
andActivity.detach()
. These two methods are also executed synchronously so that React can immediately queue work for mounting/unmounting the component.
All the above processes happen in the SyncLane
.
Event Priorities
The essential dispatcher of reactions in React is events. More precisely, synthetic events. And among all potential events, there are more important and less important ones. Therefore, React separates them all into three conditional groups:
- Discrete - events triggered directly by the user (for example,
MouseEvent
orKeyboardEvent
) and at the same time all events in the sequence are intentional, for example "click". Such events are assigned priorityDiscreteEventPriority
and they will be executed in SyncLane. These events can interrupt background tasks, but cannot be grouped over time (each event happens here and now).
import { useState } from "react"; import { createRoot } from "react-dom/client"; function App() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); }; return <button onClick={handleClick}>Update state in the SyncLane</button>; } const root = createRoot(document.getElementById("root")); root.render(<App />);
At the moment, the list of discrete events looks like this:
beforetoggle cancel click close contextmenu copy cut auxclick dblclick dragend dragstart drop focusin focusout input invalid keydown keypress keyup mousedown mouseup paste pause play pointercancel pointerdown pointerup ratechange reset resize seeked submit toggle touchcancel touchend touchstart volumechange change selectionchange textInput compositionstart compositionend compositionupdate beforeblur afterblur beforeinput blur fullscreenchange focus hashchange popstate select selectstart
- Continuous - events triggered directly by the user, but the user cannot distinguish individual events in the sequence (for example,
mouseover
). Such events are assigned priorityContinuousEventPriority
and they are executed in InputContinuousLane. These events can interrupt background tasks and can be grouped over time.
import { useState } from "react"; import { createRoot } from "react-dom/client"; function App() { const [count, setCount] = useState(0); const handleMouseOver = () => setCount(count + 1); return ( <div onMouseOver={handleMouseOver}> Update state in the InputContinuousLane </div> ); } const root = createRoot(document.getElementById("root")); root.render(<App />);
The list of continuous events:
drag dragenter dragexit dragleave dragover mousemove mouseout mouseover pointermove pointerout pointerover scroll touchmove wheel mouseenter mouseleave pointerenter pointerleave
- Others - events that do not fall into the first two groups (except "message", their priority is determined dynamically depending on the current priority of the scheduler). Such events are assigned priority
DefaultEventPriority
and they are executed in DefaultLane accordingly. The absence of an event is also consideredDefaultEventPriority
.
import { createRoot } from "react-dom/client"; const root = createRoot(document.getElementById("root")); root.render( <div> No events emitted. Common rendering uses the DefaultLane </div> );
Deferred Lanes
As you might guess from the name, they can be executed asynchronously with a delay. They have lower priority compared to synchronous lanes and are executed in the background after all synchronous lanes have been processed. Tasks executed in deferred lanes are considered non-blocking.
TransitionLane
The most obvious way to put a deferred task is through startTransition
.
import { startTransition, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; function App() { const [, setHigh] = useState(0); const [, setLow] = useState(0); useEffect(() => { startTransition(() => { // 2. TransitionLane will be proceeded in a background after a sync lane setLow((prevLow) => prevLow + 1); }); // 1. DefaultLane will be done first setHigh((prevHigh) => prevHigh + 1); }, []); return null; } const root = createRoot(document.getElementById("root")); root.render(<App />);
Tasks placed in TransitionLane
can parallelize. For this, there are 15 lanes TransitionLane1..15
. Unlike synchronous lanes, their effect is more effective to combine into packages than to parallelize.
import { startTransition, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; function App() { const [, setHigh] = useState(0); const [, setLow] = useState(0); useEffect(() => { // TransitionLane1 startTransition(() => { setLow((prevLow) => prevLow + 1); }); // TransitionLane2 setTimeout(() => { startTransition(() => { setLow((prevLow) => prevLow + 1); }); }, 0); // TransitionLane3 setTimeout(() => { startTransition(() => { setLow((prevLow) => prevLow + 1); }); }, 0); // DefaultLane setHigh((prevHigh) => prevHigh + 1); }, []); return null; } const root = createRoot(document.getElementById("root")); root.render(<App />);
RetryLane
Another way to perform a deferred task is Suspense
.
import { Suspense, use } from "react"; import { createRoot } from "react-dom/client"; const cache = new Map(); function fetchData() { if (!cache.has("data")) { cache.set("data", getData()); } return cache.get("data"); } async function getData() { // Add a fake delay to make waiting noticeable. await new Promise((resolve) => { setTimeout(resolve, 1_000); }); return ["item1", "item2", "item3"]; } function AsyncComponent() { const items = use(fetchData()); return ( <ul> {items.map((item) => ( <li key={item}>{item}</li> ))} </ul> ); } function App() { return ( <> <h1>Retry lanes</h1> <Suspense fallback="Loading..."> <AsyncComponent /> </Suspense> </> ); } const root = createRoot(document.getElementById("root")); root.render(<App />);
In simple words, Suspense
first renders the fallback component. Then, when one or more promises inside the Suspended component resolve, Fiber will try to re-render, putting the task in RetryLane
. Like TransitionLane
, RetryLane
can parallelize and has four lanes RetryLane1..4
.
function AsyncComponent() { const items = use(fetchData()); const meta = use(fetchMeta()); return ( <ul> {items.map((item) => ( <li key={item}>{item}</li> ))} </ul> ); } function App() { return ( <> <h1>Retry lanes</h1> <Suspense fallback="Loading..."> <AsyncComponent /> </Suspense> </> ); } const root = createRoot(document.getElementById("root")); root.render(<App />);
The same work happens with components SuspenseList
and Activity
. But they are not included in the latest, today's React release. So it is not worth discussing them in detail now.
IdleLane
The next by priority lane is IdleLane
. The idea of IdleLane
is to perform some tasks only when the engine is idle and not busy with other, more important tasks. This can be compared to the Idle period in the browser scheduler (I wrote more about this in the article Chromium. Web page rendering using Blink, CC and scheduler), but at the Fiber engine and React scheduler level.
At the moment, there is no React DOM API that could cause an update with idle priority, nor native DOM events with idle priority. Therefore, for now, this lane can only be used through internal methods of the engine, not available in the prod build of React.
OffscreenLane
Just above, when I was talking about synchronous lanes, I mentioned a so-called component Offscreen
, which is now called Activity
. Its idea is to determine whether a component is currently in the visible part of the page, or outside its boundaries. If the component is not visible on the screen, then the tasks it has are of low priority. Therefore, for such tasks, the lane OffscreenLane
was allocated.
Unfortunately, this is still another component that has not yet entered the current React release. But it has all the chances to enter one of the next ones. We hope to see it soon.
DeferredLane
It's not hard to guess that the lane DeferredLane
is intended for the useDeferredValue
hook. However, it's not as simple as it might seem.
import { useDeferredValue } from "react"; import { createRoot } from "react-dom/client"; function App() { const text = useDeferredValue("Final"); return <div>{text}</div>; } const root = createRoot(document.getElementById("root")); root.render(<App />);
If the hook does not have a second argument - initialValue
, it has nothing to compare the new value with when mounting and it will be executed synchronously in DefaultLane
. However, if the hook has a previous value with which it can compare the current one, for example when the hook is mounted and updated again or if an initialValue
is specified, we will see the following picture.
import { useDeferredValue } from "react"; import { createRoot } from "react-dom/client"; function App() { const text = useDeferredValue("Final", "Initial"); return <div>{text}</div>; } const root = createRoot(document.getElementById("root")); root.render(<App />);
The work was actually done in TransitionLane
. If we had tried to execute the hook in the Offscreen
component, we would have seen that it was executed in OffscreenLane
. The reason is that DeferredLane
is a technical intermediate lane. It is only for logical separation of deferred tasks from the others. This task does not have its own execution priority and is always mixed with other lanes depending on the situation.
Non-blocking Rendering
Let's now return to our original example and try to get rid of the interface blocking heavy operations.
const SearchList = ({ items, filter }) => { // Heavy filtering operation (simulation) const filteredItems = items.filter((item) => item.toLowerCase().includes(filter.toLowerCase()), ); return ( <ul> {filteredItems.map((item, i) => ( <li key={i}>{item}</li> ))} </ul> ); }; const App = () => { const [inputValue, setInputValue] = useState(""); const deferredValue = useDeferredValue(inputValue); // Generate large list for demonstration const bigList = Array(10_000) .fill(null) .map((_, i) => `Item ${i + 1}`); const handleChange = (e) => { setInputValue(e.target.value); }; return ( <div> <input type="text" value={inputValue} onChange={handleChange} placeholder="Search..." /> <SearchList items={bigList} filter={deferredValue} /> </div> ); }
All we did in SearchList
was to pass deferredValue
instead of inputValue
. This allowed us to put the task of calculating the array of elements in the deferred TransitionLane
and unlock synchronous updates of the input element.
Of course, no miracles from React are expected. Heavy operation remains heavy. And React, being just a JavaScript library, is still executed in the main browser thread. The Lanes system is just a way to prioritize tasks. More important tasks are done first, less important ones are postponed. However, if a heavy deferred task is taken into work, it will still block the main thread.
Practical Recommendations for Using Lanes
Understanding the Lanes system in React can significantly help in optimizing application performance. Here are some practical recommendations:
- Use useDeferredValue for heavy rendering operations. As we saw in the example with search, deferred value allows you to maintain responsiveness of the interface even when working with large amounts of data.
- Apply startTransition for non-priority UI updates. When you need to perform an update that does not require immediate reaction (for example, switching tabs or loading additional content), wrap it in
startTransition
. - Separate synchronous and asynchronous parts of your application. Elements that require immediate reaction (input fields, buttons) should be updated synchronously, and heavy calculations and displaying large lists should be postponed.
- Use Suspense for asynchronous data loading. This will allow React to automatically manage task priorities when loading data from the server.
- Remember about component nesting. The more deeply nested the component, the more time it may take to update. Try to move heavy components closer to the root of the tree.
Future React Lanes
The Lanes system continues to evolve. In future versions of React, we can expect:
- Official API for the
Activity
component, which will allow more flexible management of rendering priorities depending on component visibility. - Possible integration of Web Workers for performing heavy calculations in separate threads.
- Further development of the API for more fine-grained task priority tuning.
- Improvement of integration with profiling tools for more visual display of system priority work.
Conclusion
The Lanes system in React is a powerful tool for optimizing user experience. It does not make your application faster in absolute terms, but it allows more reasonable distribution of computing resources, giving priority to what is important for the user right now.
Proper use of useDeferredValue
, startTransition
and Suspense
can significantly improve the perceived performance of your application, making it more responsive even when performing complex operations. This is especially important for modern web applications that often work with large amounts of data and complex interfaces.
In the end, understanding the internal structure of React and the principles of system priority work allows developers to create more efficient and user-friendly applications, which is the key goal of any frontend development.
EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru
Русская версия: https://blog.frontend-almanac.ru/react-lanes