Concurrent rendering, Suspense and transitions inside
Watch React 19.3.1 pause a transition, throw it away for a click and start again, keep the old screen while Suspense waits, and stop tearing, in its source.
- 01Tell pausing from restartingRead renderRootConcurrent and say when React goes on from workInProgress and when prepareFreshStack throws the draft away.
- 02Predict what interrupts a transitionUse the update's lane and getNextLanes to say whether a click, a timer, or another transition restarts a render in progress.
- 03Follow Suspense and stores inside ReactTrace use() throwing, the ping that retries, why a transition commits nothing, and the extra render useSyncExternalStore forces.
A render React starts, drops, and starts again
The work loop lesson showed that a transition renders in slices of about 5 ms. This lesson shows what happens when something else needs the main thread in the middle of that render. Press Refresh the list in the shop below, and read the log under it.
import { memo, startTransition, useLayoutEffect, useRef, useState } from "react";import { clearLog, note, whenRowsStart } from "./log.js"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function TeaRow({ n }) { busy(2); // a slow row: 2 ms of work note(`row ${n}`); return <li>Tea {n + 1}</li>;} const TeaList = memo(function TeaList({ list }) { note(`render TeaList, list ${list}`); useLayoutEffect(() => note(`commit TeaList, list ${list}`), [list]); const rows = Array.from({ length: 40 }, (_, n) => <TeaRow key={n} n={n} />); return <ol>{rows}</ol>;}); export default function App() { const [list, setList] = useState(1); const [cart, setCart] = useState(0); const addButton = useRef(null); note(`render App: list ${list}, cart ${cart}`); function refresh() { clearLog(); startTransition(() => setList(list + 1)); // A shopper clicks Add to cart while the new list renders. whenRowsStart(() => addButton.current.click()); } return ( <> <button onClick={refresh}>Refresh the list</button> <button ref={addButton} onClick={() => setCart(cart + 1)}> Add to cart ({cart}) </button> <TeaList list={list} /> </> );}Each of the 40 rows does 2 ms of busy work on line 10 and writes row n to the log on line 11. The log.js tab keeps the log: lines 5 to 22 there join rows into ranges and keep one line when StrictMode calls a component twice. Line 30 refreshes the list in a transition. Line 32 uses whenRowsStart, lines 33 to 37 of log.js, to click Add to cart the first time the browser gets a turn after a row has rendered: a shopper clicking while the new list renders. Read the log from the top:
render App: list 2, cart 0,render TeaList, list 2, thenrow 0(on some computersrows 0 to 1). The transition starts list 2 and gets one or two rows in.render App: list 1, cart 1. The click's render. Its cart is 1, but its list is 1: the transition's update isn't part of this render.TeaListdoesn't log, becausememoskips it when itslistprop is the same.render App: list 2, cart 1,render TeaList, list 2,rows 0 to 39. The transition again. It doesn't go on from row 1. It starts again at the top, and renders row 0 a second time.commit TeaList, list 2. Only this last attempt reaches the page.
So React built part of a new list, dropped it to handle a click, and built it again from the start. Nothing on the page showed the half-built list. That is what concurrent rendering means in React: a render is a draft that React can build in pieces, set aside, or throw away, and only a finished draft is committed. This lesson reads the code in ReactFiberWorkLoop.js that makes each of those choices, then the same choices for Suspense, useDeferredValue and useSyncExternalStore.
You're writing a long letter on scrap paper. When someone rings the doorbell, you put the pen down, answer, and carry on from the same word. When a phone call changes what the letter must say, you throw the draft away and start a fresh one. When you need a fact you don't have yet, you wait, and the old letter stays on the noticeboard. Nobody ever sees the scrap paper: only a finished copy is posted.
- In real life: Drafting the letter on scrap paper, not on the letter itself
- In React: Building the work-in-progress tree while the current tree stays on the page
- In real life: Putting the pen down to answer the door, and picking it up where you stopped
- In React: Yielding after a slice: workInProgress keeps its place
- In real life: The phone call changes the facts, so you bin the draft and start a new one
- In React: An urgent update: prepareFreshStack throws the draft away
- In real life: Waiting for a fact you need before posting, and keeping the old letter up meanwhile
- In React: A transition that suspends: no commit until the data arrives
- In real life: Posting only a finished, clean copy
- In React: The commit puts a whole tree on the page in one go
A concurrent render is a draft. React builds it in the work-in-progress tree, in slices, while the current tree stays on the page. Between slices, React can go on with the same draft, throw it away for a more urgent update and start again from the root, or finish it and decide not to commit it yet. Only the commit changes the page.
Pausing keeps the draft
Each slice of a transition is a call to renderRootConcurrent. The first thing it decides is whether this slice goes on with the draft it has, in ReactFiberWorkLoop.js, lines 2785 to 2814:
// If the root or lanes have changed, throw out the existing stack
// and prepare a fresh one. Otherwise we'll continue where we left off.
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
// ... (DevTools bookkeeping left out)
resetRenderTimer();
prepareFreshStack(root, lanes);
} else {
// This is a continuation of an existing work-in-progress.
//
// If we were previously in prerendering mode, check if we received any new
// data during an interleaved event.
workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes);
}- Line 3 compares two variables of the work loop with this call's arguments.
workInProgressRootis the root the draft is for, andworkInProgressRootRenderLanesthe lanes, the bits that say which updates the draft includes. - Lines 5 and 6: something is different, so
prepareFreshStackstarts a new draft at the root. That's the first slice of every render, and the restart you saw in the overview. - Lines 7 to 13: the same root and the same lanes. Nothing is reset, so the loop goes on from
workInProgress, the fiber where the last slice stopped. (Line 12 is about prerendering, which the suspending section comes back to.)
Watch a pause that isn't a restart. This list has 12 rows of 1 ms each, and line 27 asks log.js to add a line every time the browser gets a turn. Press Refresh the list.
import { memo, startTransition, useLayoutEffect, useState } from "react";import { clearLog, note, noteBrowserTurns } from "./log.js"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function TeaRow({ n }) { busy(1); // 1 ms of work note(`row ${n}`); return <li>Tea {n + 1}</li>;} const TeaList = memo(function TeaList({ list }) { note(`render TeaList, list ${list}`); useLayoutEffect(() => note(`commit TeaList, list ${list}`), [list]); return <ol>{Array.from({ length: 12 }, (_, n) => <TeaRow key={n} n={n} />)}</ol>;}); export default function App() { const [list, setList] = useState(1); function refresh() { clearLog(); startTransition(() => setList(list + 1)); noteBrowserTurns(); } return ( <> <button onClick={refresh}>Refresh the list</button> <TeaList list={list} /> </> );}The log reads something like rows 0 to 2, the browser's turn, rows 3 to 5, the browser's turn, and so on to commit TeaList, list 2. Each row takes about 2 ms (1 ms, twice in StrictMode), so a 5 ms slice holds two or three rows. After each turn, the next slice starts at the next row, never back at row 0: the lanes didn't change, so line 3 of the excerpt was false every time. The exact ranges depend on your computer.
An urgent update throws the draft away
Back to the overview. The click on Add to cart reached React's listener on the root, which gives a click SyncLane, the most urgent lane. (The event system lesson follows the click there.) React then schedules the root again, in a microtask, in ReactFiberRootScheduler.js, lines 442 to 456:
if (
includesSyncLane(nextLanes) &&
// If we're prerendering, then we should use the concurrent work loop
// even if the lanes are synchronous, so that prerendering never blocks
// the main thread.
!checkIfRootIsPrerendering(root, nextLanes)
) {
// Synchronous work is always flushed at the end of the microtask, so we
// don't need to schedule an additional task.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
}
root.callbackPriority = SyncLane;
root.callbackNode = null;
return SyncLane;Line 2: the root's next lanes include SyncLane. Lines 10 to 12 cancel the scheduler task that would have run the transition's next slice, and line 15 says the work is sync: React renders it at the end of this microtask, before the browser does anything else. Which lanes are "next" comes from getNextLanes, and its last check decides whether a render in progress may be dropped, in ReactFiberLane.js, lines 334 to 358:
// If we're already in the middle of a render, switching lanes will interrupt
// it and we'll lose our progress. We should only do this if the new lanes are
// higher priority.
if (
wipLanes !== NoLanes &&
wipLanes !== nextLanes &&
// If we already suspended with a delay, then interrupting is fine. Don't
// bother waiting until the root is complete.
(wipLanes & suspendedLanes) === NoLanes
) {
const nextLane = getHighestPriorityLane(nextLanes);
const wipLane = getHighestPriorityLane(wipLanes);
if (
// Tests whether the next lane is equal or lower priority than the wip
// one. This works because the bits decrease in priority as you go left.
nextLane >= wipLane ||
// Default priority updates should not interrupt transition updates. The
// only difference between default updates and transition updates is that
// default updates do not support refresh transitions.
(nextLane === DefaultLane && (wipLane & TransitionLanes) !== NoLanes)
) {
// Keep working on the existing in-progress tree. Do not interrupt.
return wipLanes;
}
}- Lines 4 to 10: a render is in progress (
wipLanes), and the lanes waiting are different ones. - Lines 11 and 12 take the most urgent lane of each. Lanes are bits, and a smaller bit is more urgent:
SyncLaneis 2,InputContinuousLane8,DefaultLane32, and the first transition lane 256. - Line 16: if the waiting lane is the same or less urgent, keep working. Line 20 adds one more case:
DefaultLane, the lane of a timer or a fetch result, doesn't interrupt a transition either. - Line 23 returns the lanes in progress, so the draft survives. Only a more urgent lane gets past this check.
For the click, performWorkOnRoot renders SyncLane with renderRootSync, whose first lines compare the lanes the same way. They differ, so it calls prepareFreshStack, which begins at lines 2254 to 2258:
resetWorkInProgressStack();
workInProgressRoot = root;
const rootWorkInProgress = createWorkInProgress(root.current, null);
workInProgress = rootWorkInProgress;
workInProgressRootRenderLanes = lanes;Line 1 throws the old draft away. Lines 2 to 5 start a new one from root.current, the tree on the page, for the click's lanes. Here is line 1's function, resetWorkInProgressStack:
function resetWorkInProgressStack() {
if (workInProgress === null) return;
let interruptedWork;
if (workInProgressSuspendedReason === NotSuspended) {
// Normal case. Work-in-progress hasn't started yet. Unwind all
// its parents.
interruptedWork = workInProgress.return;
} else {
// Work-in-progress is in suspended state. Reset the work loop and unwind
// both the suspended fiber and all its parents.
resetSuspendedWorkLoopOnUnwind(workInProgress);
interruptedWork = workInProgress;
}
while (interruptedWork !== null) {
const current = interruptedWork.alternate;
unwindInterruptedWork(
current,
interruptedWork,
workInProgressRootRenderLanes,
);
interruptedWork = interruptedWork.return;
}
workInProgress = null;
}- Line 2: no draft, nothing to do.
- Lines 4 to 13 pick where to start unwinding: the parent of the fiber the loop would have begun next, or the fiber itself if it was waiting on data.
- Lines 14 to 22 climb through
returnto the root.unwindInterruptedWorkundoes what each begun fiber pushed while rendering, such as the context values it provided, so the next render starts clean. - Line 23 sets
workInProgresstonull. The draft's fibers aren't on the page, and nothing points at them now. The rows it rendered are simply lost.
prepareFreshStack then copies root.current into a new draft for the click.The click's render and commit come next. The transition's lane is still pending on the root, so the commit schedules a new task, and its first renderRootConcurrent has no draft: back to prepareFreshStack and the root. Step through the overview's log with the functions that run for each line:
step 0 of 8- On the page
list 1, cart 0- The draft
none- React's code
- Nothing runs yet.
0 linesThe overview's log lines appear here, one step at a time.
The page shows list 1 and an empty cart. Press Step to click Refresh the list.
Now test getNextLanes' rule with four kinds of update. Each button refreshes the list, and when the rows start, sends one update to the cart: a click on Add to cart (line 35), a mousemove event on the cart badge (line 36), a plain setCart from the timer (line 37), or a setCart in another transition (line 38).
import { memo, startTransition, useLayoutEffect, useRef, useState } from "react";import { clearLog, note, whenRowsStart } from "./log.js"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function TeaRow({ n }) { busy(2); // a slow row: 2 ms of work note(`row ${n}`); return <li>Tea {n + 1}</li>;} const TeaList = memo(function TeaList({ list }) { note(`render TeaList, list ${list}`); useLayoutEffect(() => note(`commit TeaList, list ${list}`), [list]); const rows = Array.from({ length: 40 }, (_, n) => <TeaRow key={n} n={n} />); return <ol>{rows}</ol>;}); export default function App() { const [list, setList] = useState(1); const [cart, setCart] = useState(0); const addButton = useRef(null); const badge = useRef(null); note(`render App: list ${list}, cart ${cart}`); function refreshThen(update) { clearLog(); startTransition(() => setList(list + 1)); whenRowsStart(update); } const click = () => addButton.current.click(); const mouseMove = () => badge.current.dispatchEvent(new MouseEvent("mousemove", { bubbles: true })); const timer = () => setCart((n) => n + 1); const transition = () => startTransition(() => setCart((n) => n + 1)); return ( <> <p> Refresh the list, and meanwhile:{" "} <button onClick={() => refreshThen(click)}>a click</button> <button onClick={() => refreshThen(mouseMove)}>a mouse move</button> <button onClick={() => refreshThen(timer)}>a timer</button> <button onClick={() => refreshThen(transition)}>a transition</button> </p> <button ref={addButton} onClick={() => setCart(cart + 1)}>Add to cart</button> <span ref={badge} onMouseMove={() => setCart((n) => n + 1)}> Cart: {cart}</span> <TeaList list={list} /> </> );}After a click and a mouse move, the log shows rows 0 to 39 after a second render TeaList: the draft was thrown away. After a timer and a transition, it shows rows 0 to 39 once, then commit TeaList, and only then the cart's render. The transition kept going, and the cart waited until the whole list was done. How to read the table: each row is an update that arrives while a transition renders.
| What arrives | Its lane | What happens to the transition's render |
|---|---|---|
| A click or a key | SyncLane | Thrown away. The click renders and commits first, then the transition starts again from the root. |
| A mouse move or a wheel turn | InputContinuousLane | Thrown away the same way, because its lane is more urgent than any transition lane. |
A setTimeout update or a fetch result | DefaultLane | Kept. getNextLanes doesn't let DefaultLane interrupt a transition, so this update renders after the commit. |
Another startTransition | A transition lane | Kept. The new lane isn't more urgent, so React finishes the render it has, then renders the new update. |
A component that waits throws
A render can also stop because a component needs data that hasn't arrived. This product page reads reviews with use inside a Suspense boundary. The api.js tab returns one promise per product, which resolves after 800 ms. Watch the log as the page loads, then click Black tea.
import { Suspense, use, useLayoutEffect, useState } from "react";import { clearLog, note } from "./log.js";import { fetchReviews } from "./api.js"; function Reviews({ product }) { const promise = fetchReviews(product); note(`render Reviews ${product}: status ${promise.status ?? "not set"}`); const reviews = use(promise); useLayoutEffect(() => note(`commit Reviews ${product}`), [product]); return <ul>{reviews.map((review) => <li key={review}>{review}</li>)}</ul>;} function Loading() { useLayoutEffect(() => note("commit Loading"), []); return <p>Loading reviews...</p>;} const products = ["Green tea", "Black tea", "Mint tea"]; export default function App() { const [product, setProduct] = useState("Green tea"); note(`render App: ${product}`); function show(next) { clearLog(); setProduct(next); } return ( <> {products.map((name) => ( <button key={name} onClick={() => show(name)} aria-pressed={name === product}> {name} </button> ))} <Suspense fallback={<Loading />}> <Reviews product={product} /> </Suspense> </> );}Line 7 logs the promise's status field just before line 8 calls use. A plain promise has no such field, so the first line says status not set. Then commit Loading: the fallback. Then the same component renders again with status pending. Someone set that field. When the reviews arrive, Reviews renders with status fulfilled and commits. Here is who sets it, trackUsedThenable, which use calls, in ReactFiberThenable.js:
switch (thenable.status) {
case 'fulfilled': {
// ... (comments left out)
const fulfilledValue: T = thenable.value;
return fulfilledValue;
}
// ... (the rejected case, and a promise React has seen before, left out)
const pendingThenable: PendingThenable<T> = thenable as any;
pendingThenable.status = 'pending';
pendingThenable.then(
fulfilledValue => {
if (thenable.status === 'pending') {
const fulfilledThenable: FulfilledThenable<T> = thenable as any;
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = fulfilledValue;
}
},
// ... (the same for a rejected promise, and a last check for one that already settled, left out)
suspendedThenable = thenable;
// ... (development-only warnings left out)
throw SuspenseException;- Lines 1 to 6: a promise React already marked
fulfilledgives its value straight away. That's why the last render of Reviews didn't suspend. - Lines 8 to 17: a promise with no status gets
status = 'pending', and athencallback that writes'fulfilled'and the value onto the promise when it resolves. React adds these fields to your promise object. - Lines 19 and 21: still pending, so React remembers the promise in
suspendedThenableand throwsSuspenseException, a special error, not the promise. Throwing stops your component in the middle, at line 8 of the example.
The work loop catches it. handleThrow gets the promise back with getSuspendedThenable, and throwException looks for the nearest Suspense boundary, in ReactFiberThrow.js:
// Mark the nearest Suspense boundary to switch to rendering a fallback.
const suspenseBoundary = getSuspenseHandler();
if (suspenseBoundary !== null) {
// ... (the switch on the boundary's kind left out)
if (getShellBoundary() === null) {
// Suspended in the "shell" of the app. This is an undesirable
// loading state. We should avoid committing this tree.
renderDidSuspendDelayIfPossible();
// ... (the other branch, and the code that marks the boundary to show its fallback, left out)
const retryQueue: RetryQueue | null =
suspenseBoundary.updateQueue as any;
if (retryQueue === null) {
suspenseBoundary.updateQueue = new Set([wakeable]);
} else {
retryQueue.add(wakeable);
}
// ... (comments left out)
if (disableLegacyMode || suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}- Lines 1 to 3:
getSuspenseHandlerreturns the closest Suspense boundary above the component, here the one on line 36 of the example. - Lines 5 to 8 note when a fallback here would hide content already on the page. For a click, React commits the fallback anyway; the next section shows what a transition does with this note.
- Lines 10 to 16 add the promise to the boundary's
updateQueue, its retry queue. When the fallback commits, React puts a listener on each promise there; when one resolves, a retry renders the boundary again, at a retry lane. - Lines 18 to 20:
attachPingListeneralso calls the promise'sthen. When it resolves, the root is pinged: its lanes are marked as worth trying again.
For a click, the boundary then switches to its fallback, and the render commits it: commit Loading. The line status pending comes right after. In React 19, after showing a fallback, React renders the suspended part again at once, without committing, to start any other data it will need. React's code calls this prerendering, and it's the "show the fallback first" behavior from the Suspense lesson. When the reviews arrive, the retry renders only the boundary and what's inside it. There is no render App line before status fulfilled.
A transition that suspends keeps the old screen
The same page, with the product chosen in a transition. Click Black tea and read the log.
import { Suspense, use, useLayoutEffect, useState, useTransition } from "react";import { clearLog, note } from "./log.js";import { fetchReviews } from "./api.js"; function Reviews({ product }) { const promise = fetchReviews(product); note(`render Reviews ${product}: status ${promise.status ?? "not set"}`); const reviews = use(promise); useLayoutEffect(() => note(`commit Reviews ${product}`), [product]); return <ul>{reviews.map((review) => <li key={review}>{review}</li>)}</ul>;} function Loading() { useLayoutEffect(() => note("commit Loading"), []); return <p>Loading reviews...</p>;} const products = ["Green tea", "Black tea", "Mint tea"]; export default function App() { const [product, setProduct] = useState("Green tea"); const [isPending, startTransition] = useTransition(); note(`render App: ${product}${isPending ? " (pending)" : ""}`); function show(next) { clearLog(); startTransition(() => setProduct(next)); } return ( <> {products.map((name) => ( <button key={name} onClick={() => show(name)} aria-pressed={name === product}> {name} </button> ))} <div style={{ opacity: isPending ? 0.5 : 1 }}> <Suspense fallback={<Loading />}> <Reviews product={product} /> </Suspense> </div> </> );}Line 27 switches the product in a transition, and line 37 dims the old reviews while isPending is true. There is no commit Loading this time. The log reads:
render App: Green tea (pending)andrender Reviews Green tea: an urgent render that only setsisPending, committed at once.render App: Black teaandrender Reviews Black tea: status not set: the transition. Reviews suspends, and nothing is committed. The Green tea reviews stay, dimmed.reviews for Black tea arrive, thenrender App: Black teaagain: the whole transition renders again from the root, Reviews readsstatus fulfilled, andcommit Reviews Black tea.
The difference is in throwException's lines 5 to 8: getShellBoundary() === null. React calls the part of the page that is already showing content the shell. While it renders a Suspense boundary, it records one as the shell boundary only when that boundary isn't showing content, in ReactFiberSuspenseContext.js, lines 89 to 101:
if (shellBoundary === null) {
if (current === null || isCurrentTreeHidden()) {
// This boundary is not visible in the current UI.
shellBoundary = handler;
} else {
const prevState: SuspenseState = current.memoizedState;
// $FlowFixMe[invalid-compare]
if (prevState !== null) {
// This boundary is showing a fallback in the current UI.
shellBoundary = handler;
}
}
}Line 2: a new boundary (no current fiber) or a hidden one. Line 8: one already showing its fallback. Either way, a fallback there hides nothing the user can see now. Our boundary shows Green tea's reviews, so shellBoundary stays null, and throwException calls renderDidSuspendDelayIfPossible, which sets the render's result to RootSuspendedWithDelay. When the render ends, finishConcurrentRender reads that result:
case RootSuspendedWithDelay: {
if (!includesOnlyTransitions(lanes) && !includesOnlyRetries(lanes)) {
// Commit the placeholder.
break;
}
}
// Fallthrough
case RootSuspendedAtTheShell: {
// This is a transition, so we should exit without committing a
// placeholder and without scheduling a timeout. Delay indefinitely
// until we receive more data.
// ... (profiling left out)
const didAttemptEntireTree = !workInProgressRootDidSkipSuspendedSiblings;
markRootSuspended(
root,
lanes,
workInProgressDeferredLane,
didAttemptEntireTree,
);
return;
}- Lines 2 to 5: for an urgent lane,
breakgoes on to the commit, fallback and all. - Lines 8 to 21: for a transition, the comment says it: exit without committing.
markRootSuspendednotes that these lanes are waiting for data, and line 20 returns. The finished draft is never committed.
When the reviews arrive, the ping listener from throwException runs pingSuspendedRoot:
function pingSuspendedRoot(
root: FiberRoot,
wakeable: Wakeable,
pingedLanes: Lanes,
) {
const pingCache = root.pingCache;
if (pingCache !== null) {
// The wakeable resolved, so we no longer need to memoize, because it will
// never be thrown again.
pingCache.delete(wakeable);
}
markRootPinged(root, pingedLanes);
// ... (profiling, and a restart check for a render that is still running, left out)
ensureRootIsScheduled(root);
}Line 13, markRootPinged, marks the waiting lanes as pinged, and line 15 schedules the root. getNextLanes picks pinged lanes even though they suspended, so the transition renders again from prepareFreshStack, and this time use finds status fulfilled.
use throws. A transition whose boundary is already on screen commits nothing and keeps the old screen until the ping. Anything else commits the fallback, and a retry renders the boundary again when the data arrives.Try the other branch. Press Edit & run and change line 38 to <Suspense key={product} fallback={<Loading />}>. The key makes each product a new boundary, which has no current fiber, so it becomes the shell boundary. Click Mint tea: the log shows commit Loading even in a transition. How to read the table: each row asks one question of both versions of the page.
| The question | An urgent update | A transition |
|---|---|---|
| What does the page show while the data loads? | The fallback, Loading reviews.... | The old reviews, dimmed by isPending. |
| What does React commit when the component suspends? | The fallback, with the old content hidden. | Nothing. finishConcurrentRender returns before the commit. |
| What listens for the data? | A ping listener, and the boundary's retry queue. | A ping listener for the render's lanes. |
| What renders when the data arrives? | A retry: the boundary and what's inside it, not App. | The whole update again, from the root, so App renders again. |
useDeferredValue: an urgent render, then a deferred one
useDeferredValue uses the same machinery without any startTransition in your code. Type g in the box below.
import { memo, useDeferredValue, useLayoutEffect, useState } from "react";import { note } from "./log.js"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function TeaRow({ n }) { busy(2); // a slow row: 2 ms of work note(`row ${n}`); return <li>Tea {n + 1}</li>;} const Results = memo(function Results({ query }) { note(`render Results "${query}"`); useLayoutEffect(() => note(`commit Results "${query}"`), [query]); return <ol>{Array.from({ length: 30 }, (_, n) => <TeaRow key={n} n={n} />)}</ol>;}); export default function App() { const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); note(`render App: query "${query}", deferred "${deferredQuery}"`); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" /> <Results query={deferredQuery} /> </> );}The log shows render App: query "g", deferred "", then render App: query "g", deferred "g", render Results "g", the rows, and commit Results "g". Line 23 returned the old value in the first render and the new value in the second. It decides in ReactFiberHooks.js, lines 3082 to 3107:
const shouldDeferValue =
!includesOnlyNonUrgentLanes(renderLanes) && !isRenderingDeferredWork();
if (shouldDeferValue) {
// This is an urgent update. Since the value has changed, keep using the
// previous value and spawn a deferred render to update it later.
// Schedule a deferred render
const deferredLane = requestDeferredLane();
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
deferredLane,
);
markSkippedUpdateLanes(deferredLane);
// Reuse the previous value. We do not need to mark this as an update,
// because we did not render a new value.
return prevValue;
} else {
// This is not an urgent update, so we can use the latest value regardless
// of what it is. No need to defer it.
// Mark this as an update to prevent the fiber from bailing out.
markWorkInProgressReceivedUpdate();
hook.memoizedState = value;
return value;
}- Lines 1 and 2: defer only in an urgent render (your key gave
SyncLane), and not in the deferred render itself. - Line 8:
requestDeferredLanepicks a transition lane for the second render. Lines 9 to 12 put that lane on this fiber, so the root has more work after this commit. - Line 17 returns
prevValue, the value from the last commit, which the hook keeps in itsmemoizedState.Resultsgets the same prop, andmemoskips it, so the urgent render is fast. - Lines 18 to 26: in the deferred render, the new value is returned and saved.
The deferred render has a transition lane, so everything from the interrupting section applies. Type gre quickly: the log shows a render Results "gr" that gets only a row or two before the next key's render App: query "gre", deferred "g". The key's SyncLane threw the deferred draft away, and the urgent render returned "g", the last committed value, not "gr".
Tearing, and the render useSyncExternalStore repeats
A pause between slices is a gap where other code runs. The external stores lesson showed what goes wrong when that code changes data React reads while rendering: tearing, one screen showing two versions of the same data. Here the store.js tab keeps a price, and line 30 raises it when the rows start. Click Show prices.
import { startTransition, useLayoutEffect, useState, useSyncExternalStore } from "react";import { clearLog, note, whenRowsStart } from "./log.js";import { getPrice, raisePrice, subscribe } from "./store.js"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function PriceRow({ n }) { busy(4); // a slow row const price = useSyncExternalStore(subscribe, getPrice); note(`row ${n} reads $${price}`); return <li>Tea {n + 1}: ${price}</li>;} function PriceList() { note("render PriceList"); useLayoutEffect(() => note("commit PriceList"), []); return <ol>{[0, 1, 2, 3, 4, 5].map((n) => <PriceRow key={n} n={n} />)}</ol>;} export default function App() { const [shown, setShown] = useState(false); function showPrices() { clearLog(); startTransition(() => setShown(true)); // The price changes while the rows render. whenRowsStart(raisePrice); } return ( <> <button onClick={showPrices} disabled={shown}>Show prices</button> {shown && <PriceList />} </> );}The log shows row 0 reads $3, the price changes to $4, rows 1 to 5 reading $4, and then render PriceList a second time, with every row reading $4, before commit PriceList. The page shows $4 six times. The first render was finished and torn, and React rendered it again before committing. The rows read the price with useSyncExternalStore on line 12, which, while mounting, does this in ReactFiberHooks.js:
// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
// stores were mutated.
// ... (hydration notes and a sanity check left out)
const rootRenderLanes = getWorkInProgressRootRenderLanes();
if (!includesBlockingLane(rootRenderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}Line 6: a render that isn't blocking, that is, one that can pause. Line 7 notes the store's getSnapshot and the value this row read, on the fiber. After the render, performWorkOnRoot checks every note, in lines 1222 to 1244:
const finishedWork: Fiber = root.current.alternate as any;
if (
renderWasConcurrent &&
!isRenderConsistentWithExternalStores(finishedWork)
) {
// ... (profiling left out)
// A store was mutated in an interleaved event. Render again,
// synchronously, to block further mutations.
exitStatus = renderRootSync(root, lanes, false);
// We assume the tree is now consistent because we didn't yield to any
// concurrent events.
renderWasConcurrent = false;
// Need to check the exit status again.
continue;
}- Lines 2 to 5: if the render was sliced,
isRenderConsistentWithExternalStoreswalks the finished tree and calls each notedgetSnapshotagain. Row 0 read 3, andgetPrice()now returns 4. - Line 9 renders the same lanes again with
renderRootSync: no slices, so no gap where the store could change. That's the secondrender PriceList. - Line 14 goes round again, and this time the tree is committed.
useSyncExternalStore, React checks every snapshot before the commit, finds the change, and renders again in one go.Press Edit & run and change line 12 to const price = getPrice();. Click Show prices: one render, and the page shows Tea 1: $3 next to five rows at $4. React had no note to check, so it committed the torn render.
Concurrent doesn't mean parallel
"Concurrent" can sound like threads: two pieces of work running at the same moment on two processor cores. React doesn't do that. Everything in this lesson ran on the browser's one main thread, taking turns: a slice of React, a turn for the browser, another slice. And React can only take a turn between two fibers, because shouldYield is checked before each unit of work. A component's function is plain JavaScript; nothing can stop it halfway. Compare the same 150 ms of work in one component and in sixty:
import { memo, startTransition, useLayoutEffect, useState } from "react"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} const BigChart = memo(function BigChart() { busy(150); // one slow component: 150 ms return <p>One big sales chart</p>;}); function Bar() { busy(2.5); // one small component: 2.5 ms return <i>|</i>;} const SmallBars = memo(function SmallBars() { return <p>{Array.from({ length: 60 }, (_, n) => <Bar key={n} />)}</p>;}); // Counts the browser's turns while React renders, and the longest wait.let turns = 0;let longest = 0;let watching = false;function watchTurns() { turns = 0; longest = 0; watching = true; let last = performance.now(); const turn = () => { if (!watching) return; const now = performance.now(); longest = Math.max(longest, now - last); last = now; turns += 1; setTimeout(turn); }; setTimeout(turn);} export default function App() { const [chart, setChart] = useState(null); const [report, setReport] = useState("Pick a chart."); useLayoutEffect(() => { if (!watching) return; watching = false; setReport(`Turns the browser got: ${turns}. Longest wait: ${Math.round(longest)} ms.`); }, [chart]); function draw(next) { setReport("Drawing..."); watchTurns(); startTransition(() => setChart(next)); } return ( <> <button onClick={() => draw("big")}>One big chart</button> <button onClick={() => draw("small")}>Sixty small bars</button> <p>{report}</p> {chart === "big" && <BigChart />} {chart === "small" && <SmallBars />} </> );}Both charts render in a transition (line 55). Lines 26 to 40 start a timer that counts the browser's turns and the longest wait between two of them, and line 49 reports both after the commit. One big chart reports one or two turns and a wait of about 300 ms (150 ms, twice in StrictMode): the browser was frozen for the whole of BigChart. Sixty small bars reports about sixty turns and short waits. Same work, same transition; the only difference is how many places React may stop. To make a slow screen responsive, split the slow part into smaller components, or do less work.
The commit can't be split either: commitRoot changes the page in one go. So a transition keeps typing smooth only when its render is made of many small fibers and its commit is quick.
Finding thrown-away renders in the Performance panel
In a real app you can't add a log to every row. React's development build already reports its renders to the browser's Performance panel, in a group of tracks named Scheduler. It does that with console.timeStamp, which Chrome draws on the timeline. This version of the overview's shop imports a tracks.js tab first (line 2), which wraps console.timeStamp and copies React's entries into the log. Press Refresh the list.
import { memo, startTransition, useLayoutEffect, useRef, useState } from "react";import "./tracks.js";import { clearLog, note, whenRowsStart } from "./log.js"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function TeaRow({ n }) { busy(2); // a slow row: 2 ms of work note(`row ${n}`); return <li>Tea {n + 1}</li>;} const TeaList = memo(function TeaList({ list }) { note(`render TeaList, list ${list}`); useLayoutEffect(() => note(`commit TeaList, list ${list}`), [list]); const rows = Array.from({ length: 40 }, (_, n) => <TeaRow key={n} n={n} />); return <ol>{rows}</ol>;}); export default function App() { const [list, setList] = useState(1); const [cart, setCart] = useState(0); const addButton = useRef(null); note(`render App: list ${list}, cart ${cart}`); function refresh() { clearLog(); startTransition(() => setList(list + 1)); // A shopper clicks Add to cart while the new list renders. whenRowsStart(() => addButton.current.click()); } return ( <> <button onClick={refresh}>Refresh the list</button> <button ref={addButton} onClick={() => setCart(cart + 1)}> Add to cart ({cart}) </button> <TeaList list={list} /> </> );}In tracks.js, line 4 keeps the real console.timeStamp, and line 9 still calls it, so the Performance panel gets the entries too. Lines 6 and 7 log the Scheduler entries with their track. Right after row 0, the log shows Transition track: Interrupted Render: React noted the dropped draft when prepareFreshStack started the click's render. Then Blocking track: Render and Blocking track: Commit for the click, and Transition track: Render and Transition track: Commit for the second attempt. There are a few more, such as Event: click for the click that started each update.
To see the same thing on the timeline, open the browser's developer tools, start a recording in the Performance panel, press Refresh the list, and stop. The Scheduler group has a Blocking track and a Transition track, and the transition's first attempt is a short bar labeled Interrupted Render. Many of those bars in your own app mean work is being thrown away: often fine, but a sign to check whether something keeps interrupting a slow transition. React's production build leaves these tracks out.
- A transition rendering 40 small rows
- The background render useDeferredValue starts
- A transition render that suspends in a boundary already on screen
- The render after a click
- The render after a setTimeout update
- One component's function, even inside a transition
- The commit that puts a finished render on the page
- The second render after useSyncExternalStore's check fails
Put each piece of work in the group that says what React can do once it has started.
Common mix-ups
- "Concurrent rendering uses several threads." No. React takes turns with the browser on the one main thread.
- "React can pause in the middle of a component." No. It checks
shouldYieldbetween fibers. A slow component freezes the page until it returns. - "An interrupted render picks up where it stopped." No. A pause keeps the draft; an interruption throws it away, and the next attempt starts at the root.
- "Any update interrupts a transition." No. Only a more urgent lane does. Timers, fetch results and other transitions wait for it to finish.
- "A suspending component throws its promise." No.
usethrowsSuspenseExceptionand keeps the promise aside for the work loop. - "A transition never shows a fallback." No. A boundary that is new, or already showing a fallback, can show its fallback in a transition.
- "Concurrent rendering makes rendering faster." No. The same components run, and thrown-away drafts add work. What it buys is a page that answers clicks and keys while it renders.
Practice exercises
This is the overview's shop, but the update in whenRowsStart is a plain setCart from a timer. Predict how many times rows 0 to 39 appears after Refresh the list, then press Edit & run.
import { memo, startTransition, useLayoutEffect, useRef, useState } from "react";
import { clearLog, note, whenRowsStart } from "./log.js";
function busy(ms) {
const end = performance.now() + ms;
while (performance.now() < end) {}
}
function TeaRow({ n }) {
busy(2); // a slow row: 2 ms of work
note(`row ${n}`);
return <li>Tea {n + 1}</li>;
}
const TeaList = memo(function TeaList({ list }) {
note(`render TeaList, list ${list}`);
useLayoutEffect(() => note(`commit TeaList, list ${list}`), [list]);
const rows = Array.from({ length: 40 }, (_, n) => <TeaRow key={n} n={n} />);
return <ol>{rows}</ol>;
});
export default function App() {
const [list, setList] = useState(1);
const [cart, setCart] = useState(0);
const addButton = useRef(null);
note(`render App: list ${list}, cart ${cart}`);
function refresh() {
clearLog();
startTransition(() => setList(list + 1));
// This time the update comes from a timer, not a click.
whenRowsStart(() => setCart((n) => n + 1));
}
return (
<>
<button onClick={refresh}>Refresh the list</button>
<button ref={addButton} onClick={() => setCart(cart + 1)}>
Add to cart ({cart})
</button>
<TeaList list={list} />
</>
);
}import { memo, startTransition, useLayoutEffect, useRef, useState } from "react";
import { clearLog, note, whenRowsStart } from "./log.js";
function busy(ms) {
const end = performance.now() + ms;
while (performance.now() < end) {}
}
function TeaRow({ n }) {
busy(2); // a slow row: 2 ms of work
note(`row ${n}`);
return <li>Tea {n + 1}</li>;
}
const TeaList = memo(function TeaList({ list }) {
note(`render TeaList, list ${list}`);
useLayoutEffect(() => note(`commit TeaList, list ${list}`), [list]);
const rows = Array.from({ length: 40 }, (_, n) => <TeaRow key={n} n={n} />);
return <ol>{rows}</ol>;
});
export default function App() {
const [list, setList] = useState(1);
const [cart, setCart] = useState(0);
const addButton = useRef(null);
note(`render App: list ${list}, cart ${cart}`);
function refresh() {
clearLog();
startTransition(() => setList(list + 1));
// This time the update comes from a timer, not a click.
whenRowsStart(() => setCart((n) => n + 1));
}
return (
<>
<button onClick={refresh}>Refresh the list</button>
<button ref={addButton} onClick={() => setCart(cart + 1)}>
Add to cart ({cart})
</button>
<TeaList list={list} />
</>
);
}The update runs from a timer, so it gets DefaultLane, and getNextLanes doesn't let it interrupt a transition. The draft finishes: rows 0 to 39 appears once, then commit TeaList, list 2, then the cart's render.
The second line prints reviewsPromise.status, a field your code never set. Predict what it says once the reviews show.
import { Suspense, use } from "react";
const reviewsPromise = new Promise((resolve) => {
setTimeout(() => resolve(["Lovely", "Fast delivery"]), 500);
});
function Reviews() {
const reviews = use(reviewsPromise);
return (
<>
<p>{reviews.length} reviews</p>
<p>status: {reviewsPromise.status}</p>
</>
);
}
export default function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Reviews />
</Suspense>
);
}import { Suspense, use } from "react";
const reviewsPromise = new Promise((resolve) => {
setTimeout(() => resolve(["Lovely", "Fast delivery"]), 500);
});
function Reviews() {
const reviews = use(reviewsPromise);
return (
<>
<p>{reviews.length} reviews</p>
<p>status: {reviewsPromise.status}</p>
</>
);
}
export default function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Reviews />
</Suspense>
);
}The first use call marks the promise status: 'pending' and adds a then callback. When it resolves, that callback writes status: 'fulfilled' and value, and the retry renders Reviews, which reads status: fulfilled.
Clicking a product shows Loading reviews.... Change the code so the old reviews stay until the new ones arrive. Which log line disappears?
import { Suspense, use, useLayoutEffect, useState } from "react";import { clearLog, note } from "./log.js";import { fetchReviews } from "./api.js"; function Reviews({ product }) { const promise = fetchReviews(product); note(`render Reviews ${product}: status ${promise.status ?? "not set"}`); const reviews = use(promise); useLayoutEffect(() => note(`commit Reviews ${product}`), [product]); return <ul>{reviews.map((review) => <li key={review}>{review}</li>)}</ul>;} function Loading() { useLayoutEffect(() => note("commit Loading"), []); return <p>Loading reviews...</p>;} const products = ["Green tea", "Black tea", "Mint tea"]; export default function App() { const [product, setProduct] = useState("Green tea"); note(`render App: ${product}`); function show(next) { clearLog(); setProduct(next); } return ( <> {products.map((name) => ( <button key={name} onClick={() => show(name)} aria-pressed={name === product}> {name} </button> ))} <Suspense fallback={<Loading />}> <Reviews product={product} /> </Suspense> </> );}import { Suspense, startTransition, use, useLayoutEffect, useState } from "react";import { clearLog, note } from "./log.js";import { fetchReviews } from "./api.js"; function Reviews({ product }) { const promise = fetchReviews(product); note(`render Reviews ${product}: status ${promise.status ?? "not set"}`); const reviews = use(promise); useLayoutEffect(() => note(`commit Reviews ${product}`), [product]); return <ul>{reviews.map((review) => <li key={review}>{review}</li>)}</ul>;} function Loading() { useLayoutEffect(() => note("commit Loading"), []); return <p>Loading reviews...</p>;} const products = ["Green tea", "Black tea", "Mint tea"]; export default function App() { const [product, setProduct] = useState("Green tea"); note(`render App: ${product}`); function show(next) { clearLog(); startTransition(() => setProduct(next)); } return ( <> {products.map((name) => ( <button key={name} onClick={() => show(name)} aria-pressed={name === product}> {name} </button> ))} <Suspense fallback={<Loading />}> <Reviews product={product} /> </Suspense> </> );}In a transition, the boundary is already showing reviews, so throwException delays the render and finishConcurrentRender doesn't commit. The old reviews stay until the ping, and commit Loading never appears.
Finish interrupts with the two tests from getNextLanes' lines 16 and 20. What does it print for a click, a mouse move, a timer and a second transition?
const SyncLane = 0b10; // 2: a click or a key
const InputContinuousLane = 0b1000; // 8: a mouse move
const DefaultLane = 0b100000; // 32: a timer
const TransitionLane1 = 0b100000000; // 256: the first transition lane
const TransitionLane2 = 0b1000000000; // 512: the next one
const TransitionLanes = 0b0000000001111111111111100000000;
// TODO: return true when an update in nextLane should throw away
// a render that is working on wipLane, as getNextLanes decides.
function interrupts(wipLane, nextLane) {
}
for (const lane of [SyncLane, InputContinuousLane, DefaultLane, TransitionLane2]) {
console.log(interrupts(TransitionLane1, lane));
}const SyncLane = 0b10; // 2: a click or a key
const InputContinuousLane = 0b1000; // 8: a mouse move
const DefaultLane = 0b100000; // 32: a timer
const TransitionLane1 = 0b100000000; // 256: the first transition lane
const TransitionLane2 = 0b1000000000; // 512: the next one
const TransitionLanes = 0b0000000001111111111111100000000;
function interrupts(wipLane, nextLane) {
const keepWorking =
nextLane >= wipLane ||
(nextLane === DefaultLane && (wipLane & TransitionLanes) !== 0);
return !keepWorking;
}
for (const lane of [SyncLane, InputContinuousLane, DefaultLane, TransitionLane2]) {
console.log(interrupts(TransitionLane1, lane));
}SyncLane (2) and InputContinuousLane (8) are smaller than TransitionLane1 (256), so they interrupt. DefaultLane is caught by the second test, and TransitionLane2 (512) is larger: true true false false.
Click Show prices: Tea 1 says $3 and the rest $4. Fix the rows so one render never shows two prices. What does Tea 1 show then?
import { startTransition, useLayoutEffect, useState, useSyncExternalStore } from "react";import { clearLog, note, whenRowsStart } from "./log.js";import { getPrice, raisePrice, subscribe } from "./store.js"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function PriceRow({ n }) { busy(4); // a slow row const price = getPrice(); note(`row ${n} reads $${price}`); return <li>Tea {n + 1}: ${price}</li>;} function PriceList() { note("render PriceList"); useLayoutEffect(() => note("commit PriceList"), []); return <ol>{[0, 1, 2, 3, 4, 5].map((n) => <PriceRow key={n} n={n} />)}</ol>;} export default function App() { const [shown, setShown] = useState(false); function showPrices() { clearLog(); startTransition(() => setShown(true)); // The price changes while the rows render. whenRowsStart(raisePrice); } return ( <> <button onClick={showPrices} disabled={shown}>Show prices</button> {shown && <PriceList />} </> );}import { startTransition, useLayoutEffect, useState, useSyncExternalStore } from "react";import { clearLog, note, whenRowsStart } from "./log.js";import { getPrice, raisePrice, subscribe } from "./store.js"; function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {}} function PriceRow({ n }) { busy(4); // a slow row const price = useSyncExternalStore(subscribe, getPrice); note(`row ${n} reads $${price}`); return <li>Tea {n + 1}: ${price}</li>;} function PriceList() { note("render PriceList"); useLayoutEffect(() => note("commit PriceList"), []); return <ol>{[0, 1, 2, 3, 4, 5].map((n) => <PriceRow key={n} n={n} />)}</ol>;} export default function App() { const [shown, setShown] = useState(false); function showPrices() { clearLog(); startTransition(() => setShown(true)); // The price changes while the rows render. whenRowsStart(raisePrice); } return ( <> <button onClick={showPrices} disabled={shown}>Show prices</button> {shown && <PriceList />} </> );}With useSyncExternalStore, each row notes its snapshot. Before the commit, React sees that getPrice() changed, and renders all rows again with renderRootSync. Every row, Tea 1 included, shows $4.
renderSlice renders at most two rows per call. Make the second call go on from where the first stopped, as renderRootConcurrent does. What does it print?
let workInProgress = null; // the next row to render, or null
let renderLanes = null; // the lanes the draft is for
const log = [];
function prepareFreshStack(lanes) {
workInProgress = 0;
renderLanes = lanes;
log.push(`start ${lanes}`);
}
function performUnitOfWork() {
log.push(`row ${workInProgress}`);
workInProgress = workInProgress === 5 ? null : workInProgress + 1;
}
// One slice of work: at most two rows.
function renderSlice(lanes) {
// TODO: like renderRootConcurrent, start a fresh draft only when
// there is no draft yet, or the lanes changed.
prepareFreshStack(lanes);
let units = 0;
while (workInProgress !== null && units < 2) {
performUnitOfWork();
units += 1;
}
}
renderSlice("transition");
renderSlice("transition");
console.log(log.join(", "));let workInProgress = null; // the next row to render, or null
let renderLanes = null; // the lanes the draft is for
const log = [];
function prepareFreshStack(lanes) {
workInProgress = 0;
renderLanes = lanes;
log.push(`start ${lanes}`);
}
function performUnitOfWork() {
log.push(`row ${workInProgress}`);
workInProgress = workInProgress === 5 ? null : workInProgress + 1;
}
// One slice of work: at most two rows.
function renderSlice(lanes) {
if (workInProgress === null || renderLanes !== lanes) {
prepareFreshStack(lanes);
}
let units = 0;
while (workInProgress !== null && units < 2) {
performUnitOfWork();
units += 1;
}
}
renderSlice("transition");
renderSlice("transition");
console.log(log.join(", "));Like renderRootConcurrent's line 3, the second slice sees the same lanes and a draft in progress, so it goes on from row 2: start transition, row 0, row 1, row 2, row 3.
Check your understanding
Question 1 of 8In the overview's log, why does the click's render say
list 1?Choose an answer to see the explanation.
Question 2 of 8A transition is rendering. Which update, arriving in a browser turn, throws its render away?
Choose an answer to see the explanation.
Question 3 of 8renderRootConcurrent runs again with the same root and the same lanes as the slice before. What does it do?
Choose an answer to see the explanation.
Question 4 of 8What does
use(promise)throw while the promise is still pending?Choose an answer to see the explanation.
Question 5 of 8A transition switches the product, and its reviews suspend inside a Suspense boundary that is already on screen. What is committed first?
Choose an answer to see the explanation.
Question 6 of 8What does this lane check print?
Read the code, then predictconst SyncLane = 2; const DefaultLane = 32; const TransitionLane1 = 256; function keepWorking(wipLane, nextLane) { return nextLane >= wipLane || (nextLane === DefaultLane && wipLane === TransitionLane1); } console.log(keepWorking(TransitionLane1, SyncLane), keepWorking(TransitionLane1, DefaultLane));Choose an answer to see the explanation.
Question 7 of 8A store read with
useSyncExternalStorechanged during a sliced render. Why does React render again?Choose an answer to see the explanation.
Question 8 of 8What does concurrent rendering mean in React 19?
Choose an answer to see the explanation.
Key takeaways
- A concurrent render is a draft in the work-in-progress tree; the page changes only when a draft is committed.
- Between slices,
renderRootConcurrentgoes on fromworkInProgresswhen the root and lanes are the same, and callsprepareFreshStackwhen they changed. - A more urgent lane, such as a click's
SyncLane, throws a transition's draft away;DefaultLaneand other transitions wait for it. usemarks the promise and throwsSuspenseException. Urgent updates commit the fallback and retry the boundary; a transition in the shell commits nothing until the ping.useDeferredValuereturns the old value in the urgent render and asks for a second render on a transition lane.- After a sliced render, React checks every
useSyncExternalStoresnapshot and renders again withrenderRootSyncif one changed. - It all runs on one thread, and React can only stop between fibers.
React renders concurrently by drafting, not by running in parallel.
It pauses between fibers, throws drafts away for urgent work, and commits only a finished, consistent tree.
Coming next: React's event system. It follows the click from this lesson from the browser to your handler, and shows where its SyncLane comes from.