Reconciliation and the diffing algorithm
Read ReactChildFiber.js at v19.3.1: how React matches new elements to old fibers by type and key, diffs lists with lastPlacedIndex, and bails out.
- 01Say what React keeps and what it replacesExplain how reconcileSingleElement keeps a fiber of the same type and key, and deletes the whole subtree when the type or key changes.
- 02Predict the moves in a listFollow reconcileChildrenArray's two passes, its map of old children, and lastPlacedIndex, and say which rows get Placement.
- 03Explain when React skips workSay why a child passed from above doesn't render again, and how cloneChildFibers copies the children of a fiber React skips.
Move one row, and React moves three
Every time state changes, React calls your components again and gets new elements back. Most of them describe things that are already on the page. Before React can change the page, it has to answer one question for every new element: which thing that already exists is this? That matching is called reconciliation, and this lesson reads the code that does it, ReactChildFiber.js, at React 19.3.1.
You have seen what it does from the outside: Render and commit showed that React changes only what differs, Rendering lists showed why list items need keys, and Preserving and resetting state showed that state belongs to a place in the tree. Start with a grocery list whose rows have keys. Type 2 in the box next to Dates, then press the button.
import { useState } from "react";import { useDomReport } from "./watch.js"; const groceries = [ { id: "apple", name: "Apples" }, { id: "bread", name: "Bread" }, { id: "cheese", name: "Cheese" }, { id: "dates", name: "Dates" },]; export default function App() { const [items, setItems] = useState(groceries); const [listRef, report] = useDomReport(); function moveLastToTop() { setItems([items.at(-1), ...items.slice(0, -1)]); } return ( <> <button onClick={moveLastToTop}>Move the last row to the top</button> <ul ref={listRef}> {items.map((item) => ( <li key={item.id}> <label> {item.name} <input size={3} /> </label> </li> ))} </ul> <p>What React changed in the list:</p> <ol className="report"> {report.map((line) => ( <li key={line}>{line}</li> ))} </ol> </> );}Lines 4 to 9 are the groceries, each with an id. Line 12 keeps them in state. Line 13 calls useDomReport, a small hook in the watch.js tab: it watches the list with a MutationObserver, the browser's tool for recording changes to the page, and returns a ref for the list and the report of what changed in the last commit. You don't need to read watch.js; it comes back in later examples. Lines 15 to 17 build a new array with the last item first, using at(-1) and slice. Line 24 keys each row by its id, and line 26 gives each row a box to type in. Lines 32 to 36 show the report.
After the click, Dates sits at the top with your 2 still in its box: React kept that row's <li>, box and all. But read the report: moved <li> Apples, moved <li> Bread, moved <li> Cheese. You moved one row. React moved the other three, and Dates never moved at all. The page ends up the same either way, but it isn't what most people expect, and by the end of this lesson you'll be able to predict it.
You have a shelf of books and a list of the order they should be in. You don't buy new copies: you find each book by its number. You walk along the list from the left, leave a book where it is if it already comes after the last one you left, and pull out and reshelve the ones that don't. Books that aren't on the list go in a box.
- In real life: Each book's catalogue number
- In React: Each element's key
- In real life: The same book, before and after you reorder the shelf
- In React: The same fiber and DOM node, before and after a render
- In real life: Walking along the new order and pulling out each book that is out of place
- In React: placeChild marking a reused fiber with Placement
- In real life: A book you no longer own goes in the donation box
- In React: deleteChild adding an old fiber to the deletions
Reconciliation compares the elements a component just returned with the fibers it already has, one level of children at a time. A new element with the same type and key as an old fiber reuses that fiber and its DOM node, with new props. Anything else is created new, and old fibers nobody claimed are deleted. In a list, keys pair the children up, and a number called lastPlacedIndex decides which ones move. The result is a set of flags that the commit phase carries out.
Where reconciliation runs
A fiber is React's record for one component or DOM element in the tree: its type, its key, its props, its DOM node (stateNode), and links to its first child, its next sibling and its parent, return. React keeps two trees of them: current, what's on the screen, and the work-in-progress tree it builds during a render. Each fiber's alternate points at its copy in the other tree. The Fiber lesson covers these fields; here you only need the idea.
During a render, the work loop visits fibers one at a time and calls beginWork on each. For a function component, beginWork calls your function, then hands what it returned to reconciliation. This is the end of updateFunctionComponent, in ReactFiberBeginWork.js:
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
if (getIsHydrating() && hasId) {
pushMaterializedTreeId(workInProgress);
}
// React DevTools reads this flag.
workInProgress.flags |= PerformedWork;
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;In plain words, line by line:
- Lines 1 to 4 skip the children when nothing changed: a bailout. The last big section of this lesson comes back to it.
- Lines 6 to 8 only matter during hydration, when React takes over HTML from the server.
- Line 11 marks the fiber as having rendered. The comment says why: React DevTools reads the flag.
- Line 12 is reconciliation.
nextChildrenis what your component returned, andreconcileChildrencompares it with this fiber's children. - Line 13 returns the first child. The work loop visits it next, and reconciles its children in turn.
So reconciliation happens one level at a time, as the work loop goes down the tree: a component's children are compared only when React gets to that component. Here is reconcileChildren, from lines 339 to 370 with its comments left out. The source is written in Flow, a type checker like TypeScript: the text after each colon, like : Fiber | null, is a type, and you can read past it.
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}- Line 7 asks whether this fiber has a
currenttwin. If not, the component is appearing for the first time, and lines 8 to 13 usemountChildFibers. - Otherwise lines 15 to 20 use
reconcileChildFibers, withcurrent.child, the first old child, andnextChildren, the new elements. Either way, the result becomesworkInProgress.child.
The two functions are the same code. The end of ReactChildFiber.js builds both from one factory:
export const reconcileChildFibers: ChildReconciler =
createChildReconciler(true);
export const mountChildFibers: ChildReconciler = createChildReconciler(false);createChildReconciler takes one setting, shouldTrackSideEffects. With true, the functions inside it record what the commit must do: which children to insert, move and delete. With false, they skip that. A component that is mounting has nothing old to compare with, and its whole subtree will be inserted in one go from its top, so marking each child would be wasted work.
Inside, one function looks at what the component returned and hands it to the right helper. How to read the table: each row is something a component can return, the function in reconcileChildFibersImpl that compares it with the old children, and what that function does.
| What the component returns | The function that compares it | What it does |
|---|---|---|
One element, like <ul>…</ul> | reconcileSingleElement | Looks for the old child with the same key, keeps it if the type matches, and deletes the other old children. |
An array, like the result of items.map(…) | reconcileChildrenArray | Matches old and new children by key in two passes, and marks moves, insertions and deletions. |
A fragment <>…</> with no key | Its children, as an array | A fragment at the top of what you return is skipped: React reconciles the children inside it. |
Text or a number, like "Total: 7" | reconcileSingleTextNode | Keeps the first old child if it's text, gives it the new text, and deletes the rest. |
null, false, true or undefined | deleteRemainingChildren | Treats it as empty and deletes every old child. |
Same type and key: keep the fiber, change the props
Most of the time a component returns one element where it returned one before. This card can be highlighted, and it can switch its outer tag. Type a note in the box, then press Highlight the card.
import { useState } from "react";import { useDomReport } from "./watch.js"; export default function App() { const [highlight, setHighlight] = useState(false); const [asSection, setAsSection] = useState(false); const [cardRef, report] = useDomReport(); const className = highlight ? "card highlight" : "card"; return ( <> <button onClick={() => setHighlight(!highlight)}>Highlight the card</button> <button onClick={() => setAsSection(!asSection)}>Switch div and section</button> <div ref={cardRef}> {asSection ? ( <section className={className}> <h3>Delivery notes</h3> <textarea rows={2} /> </section> ) : ( <div className={className}> <h3>Delivery notes</h3> <textarea rows={2} /> </div> )} </div> <ol className="report"> {report.map((line) => ( <li key={line}>{line}</li> ))} </ol> </> );}Lines 5 and 6 keep two switches in state, and line 8 picks the class name. Line 12 flips highlight, and line 13 flips asSection. Lines 15 to 25 return a <section> or a <div> with the same heading and box inside. Line 14 is the element watch.js watches.
After Highlight the card, the report says changed the class attribute, and your note is still in the box. React kept the <div>, the heading and the box, and changed one attribute. Here is the code that made that choice. A single element goes to reconcileSingleElement; this version is simplified, with the Fragment branch, the development-only lines and a few special cases (hot reloading, lazy components, and an experimental kind of key) left out.
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
const elementType = element.type;
if (child.elementType === elementType) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
coerceRef(existing, element);
existing.return = returnFiber;
return existing;
}
// Didn't match.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
const created = createFiberFromElement(element, returnFiber.mode, lanes);
coerceRef(created, element);
created.return = returnFiber;
return created;
}- Line 7 reads the new element's
key. Here there is none, so it'snull, and an old child with no key matches it. - Lines 9 and 25 walk the old children, from the first one through each
sibling. Usually there is only one. - Line 10 compares keys. When they match, line 12 compares types:
elementTypeis"div"for a<div>, or the function itself for a component likeNoteBox. - Same key and same type: line 13 deletes any other old children after this one, line 14 reuses the old fiber with the new props, line 15 attaches the new
ref, and line 17 returns it. - Lines 19 to 21: the key matched but the type didn't. React deletes this child and every one after it, and stops looking.
- Line 23: a different key. This old child isn't the one, so it's deleted, and the loop tries the next.
- Lines 28 to 31: nothing matched, so
createFiberFromElementmakes a brand-new fiber from the element's type, key and props.
The reuse happens in useFiber. Despite its name, it isn't a hook, and you never call it:
function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {
// We currently set sibling to null and index to 0 here because it is easy
// to forget to do before returning it. E.g. for the single child case.
const clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}Line 4 calls createWorkInProgress, which takes the old fiber's alternate (or makes one) and gives it the new props as pendingProps. The copy keeps the old fiber's stateNode, so the <div> on the page and the box inside it stay the same DOM nodes. Lines 5 and 6 reset its position and sibling, because the caller sets them again. Later in the render, React notices the new props and marks the fiber with an Update flag; the commit phase then changes the class attribute.
A different type replaces the whole subtree
Now type a new note in the card above and press Switch div and section. The report says removed <div> Delivery notes, then added <section> Delivery notes, and your note is gone. The heading and the box are exactly the same in both branches, but React made new ones anyway.
In reconcileSingleElement, both elements have the key null, so line 10 matches, but line 12 compares "div" with "section". Lines 19 to 21 delete the old child, and lines 28 to 31 create a new fiber. Nothing inside the old <div> is ever compared: the new <section>'s children start with no old fibers at all. Deleting a fiber doesn't touch the page yet. It goes on a list, in deleteChild:
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
if (!shouldTrackSideEffects) {
// Noop.
return;
}
const deletions = returnFiber.deletions;
if (deletions === null) {
returnFiber.deletions = [childToDelete];
returnFiber.flags |= ChildDeletion;
} else {
deletions.push(childToDelete);
}
}- Lines 2 to 5: a mounting subtree has nothing to delete, so this does nothing.
- Lines 6 to 9: the parent fiber keeps a
deletionsarray. The first deletion creates it and sets the parent'sChildDeletionflag, so the commit knows to look. - Line 11: later deletions are added to the same array.
The commit phase removes the old DOM nodes before it inserts new ones, which is why the report lists the removal first. The commit phase lesson follows this list from there.
Why throw away a subtree that looks the same? Because finding the smallest set of changes between two trees is a hard problem. The best general methods take on the order of n³ steps for n elements: a billion steps for a thousand elements. React gives that up for a pass that grows with the number of elements, and makes two assumptions to stay correct for real apps:
- Two elements of different types produce different trees, so React doesn't look inside a replaced one.
- Keys tell React which children are the same between renders, so it doesn't have to guess from their order or content.
The first assumption is behind a rule from Preserving and resetting state: never define a component inside another component. Each render would make a new function, a new type, and a new subtree with empty state. How to read the table: each row is a change at one place in the tree, what reconcileSingleElement does with it, and what survives.
| The change | What React does | What survives |
|---|---|---|
<div className="card"> becomes <div className="card highlight"> | Reuses the fiber with useFiber and gives it the new props. | Everything: the DOM node, typed text, focus, and the state of components inside. |
<NoteBox size="small" /> and <NoteBox size="big" /> in the two branches of a ternary | Reuses the fiber: the place, the type and the key are the same. | Everything. Only the props change. |
<div> becomes <section> | Deletes the old fiber with deleteChild and creates one with createFiberFromElement. | Nothing. The whole subtree, DOM and state, starts again. |
<Profile key="asha" /> becomes <Profile key="ravi" /> | Deletes the old fiber and creates a new one, because the keys differ. | Nothing. This is how a key resets state on purpose. |
Lists: walk while the keys match, then use a map
When a component returns an array, like the result of items.map(…), React compares it with reconcileChildrenArray. The old children are a chain: the first child, then each one's sibling, so React walks them from left to right. This version is simplified: the development-only checks, the hydration lines and a branch for empty slots (like null children) are left out, and // … marks two places where the code links the new fiber into the list, as lines 29 to 34 do.
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<any>,
lanes: Lanes,
): Fiber | null {
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
nextOldFiber = oldFiber.sibling;
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
if (newFiber === null) {
break;
}
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// …
}
return resultingFirstChild;
}
const existingChildren = mapRemainingChildren(oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
const currentFiber = newFiber.alternate;
if (currentFiber !== null) {
existingChildren.delete(
currentFiber.key === null ? newIdx : currentFiber.key,
);
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// …
}
}
existingChildren.forEach(child => deleteChild(returnFiber, child));
return resultingFirstChild;
}In plain words, part by part:
- Lines 10 to 13 set up:
oldFiberis the first old child,newIdxcounts through the new elements, andlastPlacedIndexstarts at 0. - Lines 14 to 36 are the first pass. At each position,
updateSlot(line 16) compares the old fiber there with the new element. Same key: it returns a fiber, reused when the type matches too. Different key: it returnsnull, and line 23 stops the pass. Lines 25 to 27 delete the old fiber when the key matched but a new fiber had to be made. Line 28 callsplaceChild, and lines 29 to 34 chain the new fiber after the previous one. - Lines 38 to 41: if every new element found a match, the new list is done, and anything left in the old list is deleted. Removing rows from the end costs nothing else.
- Lines 43 to 50: if the old list ran out first, every remaining new element is created and inserted. Adding to the end is cheap too.
- Line 52: otherwise, the rest of the old children go into a Map, keyed by their key, or by their position when they have none.
- Lines 54 to 72 are the second pass. For each remaining new element,
updateFromMap(line 55) looks up its key. A fiber found there is reused, and lines 64 to 68 take it out of the map. Then line 69 callsplaceChild. - Line 74: every old child still in the map was never claimed, so it's deleted.
Whether a reused child moves is decided in placeChild, shown here as it is:
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// During hydration, the useId algorithm needs to know which fibers are
// part of a list of children (arrays, iterators).
newFiber.flags |= Forked;
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This is a move. The fiber already existed, so this is not a new
// mount; don't set PlacementDEV, which would cause StrictMode to
// re-run the effects in its subtree as if it had remounted.
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// This item can stay in place.
return oldIndex;
}
} else {
// This is an insertion.
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
}
}- Line 6 saves the child's new position.
- Lines 7 to 12: a mounting subtree records no moves. (The
Forkedflag helpsuseIdduring hydration.) - Lines 13 to 15: a reused child has an
alternate, the old fiber, andoldIndexis where it was. - Lines 16 to 21: if it was before
lastPlacedIndex, it must move. It gets thePlacementflag, andlastPlacedIndexstays the same. - Lines 22 to 25: otherwise it can stay where it is, and its old index becomes the new
lastPlacedIndex. - Lines 26 to 30: a new child is an insertion:
Placement, plusPlacementDEV, which tells StrictMode to run its Effects twice as a new mount.
In plain words, lastPlacedIndex is the furthest-right old position that React has decided to leave alone so far. React reads the new list from left to right. A reused child whose old position is to the left of that point used to come before a child that now comes before it, so it has to move. Anything to the right can stay, and pushes the point further right.
The map comes from mapRemainingChildren, simplified here (the real declaration on line 7 has a long type, and one experimental branch is left out):
function mapRemainingChildren(
currentFirstChild: Fiber,
): Map<string | number | ReactOptimisticKey, Fiber> {
// Add the remaining children to a temporary map so that we can find them by
// keys quickly. Implicit (null) keys get added to this set with their index
// instead.
const existingChildren = new Map();
let existingChild: null | Fiber = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key === null) {
existingChildren.set(existingChild.index, existingChild);
} else {
existingChildren.set(existingChild.key, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}Lines 10 to 17 walk the old children that are left. Line 12 stores a child with no key under its position, and line 14 stores a keyed child under its key. That's why position keys and no keys behave alike: both pair children up by position.
lastPlacedIndex (0), so it stays and lastPlacedIndex becomes 3. Apples, Bread and Cheese all have old indexes below 3, so each one gets Placement: a move.Now step through it yourself. Pick a change to the grocery list, then press Next step. Each step marks the line of reconcileChildrenArray it's about and shows the old fibers, the new elements, lastPlacedIndex and the map.
- 0Appleswaiting
- 1Breadwaiting
- 2Cheesewaiting
- 3Dateswaiting
- 0Dateswaiting
- 1Appleswaiting
- 2Breadwaiting
- 3Cheesewaiting
lastPlacedIndex0existingChildrennot made yet
8let previousNewFiber: Fiber | null = null;910let oldFiber = currentFirstChild;11let lastPlacedIndex = 0;12let newIdx = 0;13let nextOldFiber = null;14for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
Line 11 sets lastPlacedIndex to 0. oldFiber points at the first old child, Apples, and newIdx at the first new one, Dates.
Compare the changes. Move Apples to the bottom costs one move: Bread, Cheese and Dates each keep pushing lastPlacedIndex to the right, and only Apples is left behind. Move Dates to the top costs three: Dates sets lastPlacedIndex to 3 straight away, and everything after it is to its left. Add Eggs at the top costs one insertion and no moves, and Remove Bread costs one deletion.
Counting real DOM moves, with and without keys
The step-through replays a model of the code. This list runs the real React 19.3.1, and watch.js reports every DOM change it makes. Try each button, and compare the report with the step-through.
import { useState } from "react";import { useDomReport } from "./watch.js"; const groceries = [ { id: "apple", name: "Apples" }, { id: "bread", name: "Bread" }, { id: "cheese", name: "Cheese" }, { id: "dates", name: "Dates" },]; export default function App() { const [items, setItems] = useState(groceries); const [byPosition, setByPosition] = useState(false); const [listRef, report] = useDomReport(); return ( <> <button onClick={() => setItems([items.at(-1), ...items.slice(0, -1)])}>Last to top</button> <button onClick={() => setItems([...items.slice(1), items[0]])}>First to bottom</button> <button onClick={() => setItems(items.toReversed())}>Reverse</button> <label> <input type="checkbox" checked={byPosition} onChange={(event) => setByPosition(event.target.checked)} /> Key by position </label> <ul ref={listRef}> {items.map((item, index) => ( <li key={byPosition ? index : item.id}> <label> {item.name} <input size={3} /> </label> </li> ))} </ul> <ol className="report"> {report.map((line) => ( <li key={line}>{line}</li> ))} </ol> </> );}Lines 18 to 20 are three ways to reorder the same four rows: last to top, first to bottom, and toReversed(), which returns a reversed copy. Lines 21 to 24 are a checkbox that switches line 27 from the id to the position, index, as the key.
With ids as keys, Last to top reports three moved rows, First to bottom reports one, and Reverse reports three: exactly what placeChild predicted. The moves the commit makes are the Placement flags, nothing more. Why can't React notice that one move would do? The comment at the top of reconcileChildrenArray answers it:
// This algorithm can't optimize by searching from both ends since we
// don't have backpointers on fibers. I'm trying to see how far we can get
// with that model. If it ends up not being worth the tradeoffs, we can
// add it later.A fiber knows its next sibling but not the one before it, so React can only walk the old children forward. Some other libraries also search from the end, which makes moving the last row to the top cheap. React chose the simpler forward walk: most list changes add or remove rows, or move a few, and a move of a DOM node is fast.
Now tick Key by position. The report lists four removed rows and four added rows: every key changed from an id to a number, so no old fiber matches, and every row is made again. Type a number next to Apples and press Last to top. The report shows four changed text to … lines and no moves, and your number stays in the first row, now next to another name. Keys 0, 1, 2 and 3 all match in the first pass, so React reuses each row where it is and only changes its text. The boxes, and anything else a row holds, stay where they were.
Bailouts: when React skips a component
Reconciliation only runs for the components React calls. The work loop lesson read the check at the start of beginWork: when a fiber gets the same props object as last time and has no update or context change of its own, React skips it without calling it. That's a bailout. Read the console, then press Switch theme.
import { useState } from "react"; function ProductList({ from }) { console.log(`ProductList ${from} renders`); return ( <ul> <li>Apples</li> <li>Bread</li> </ul> );} function Layout({ children }) { const [dark, setDark] = useState(false); console.log("Layout renders"); return ( <div className={dark ? "page dark" : "page"}> <button onClick={() => setDark(!dark)}>Switch theme</button> {children} <ProductList from="made by Layout" /> </div> );} export default function App() { return ( <Layout> <ProductList from="made by App" /> </Layout> );}ProductList (lines 3 to 11) logs every time React calls it. Layout (lines 13 to 23) holds a theme in state, shows the children it was given on line 19, and makes a second list itself on line 20. App (lines 25 to 31) passes the first list in as children. On the first render, the console shows Layout renders, ProductList made by App renders and ProductList made by Layout renders, each with a small 2 beside it, because StrictMode calls components twice in development.
Press Switch theme. The console adds Layout renders and ProductList made by Layout renders, and nothing for the list made by App. Both lists have the same props, so why does only one render? The list made by App was created during App's render, as the element <ProductList from="made by App" />. App didn't render again, so Layout's children is the very same element object, with the very same props object, and the props check in beginWork compares props objects with !==, not their contents. The other list is a new element made by Layout's new render, with a new props object, so it renders, even though from is the same text. memo changes this by comparing the props one by one.
Layout has an update. The list App made arrives with the same props object as last time, so React bails out of it. The list Layout made is a new element with a new props object, so it renders again.For reconciliation, a bailout means the fiber's children are never compared. If nothing below the fiber has work, the whole subtree is skipped. If something does, React copies the children as they are and goes on down, in cloneChildFibers, the last function in ReactChildFiber.js:
export function cloneChildFibers(
current: Fiber | null,
workInProgress: Fiber,
): void {
if (current !== null && workInProgress.child !== current.child) {
throw new Error('Resuming work not yet implemented.');
}
if (workInProgress.child === null) {
return;
}
let currentChild = workInProgress.child;
let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
workInProgress.child = newChild;
newChild.return = workInProgress;
while (currentChild.sibling !== null) {
currentChild = currentChild.sibling;
newChild = newChild.sibling = createWorkInProgress(
currentChild,
currentChild.pendingProps,
);
newChild.return = workInProgress;
}
newChild.sibling = null;
}- Lines 5 to 7: a safety check. The children must still be the old ones; the error message says React can't resume half-done work here.
- Lines 9 to 11: no children, nothing to copy.
- Lines 13 to 15 copy the first child with
createWorkInProgress, the same functionuseFibercalls. But the props it passes are the child's oldpendingProps: the same object as before. - Lines 18 to 25 do the same for every sibling, and line 26 ends the list.
Compare it with reconcileChildren: no new elements, no keys, no flags. Each copied child keeps its old props object, so when the work loop reaches it, the same props check passes again, and the bailout carries on down until it reaches the fiber that has the update. That's how one setState deep in a tree renders only the component that owns it.
One more bailout happens after a component runs. Look back at lines 1 to 4 of updateFunctionComponent in the first section: if a component rendered because of a state update but every state value came out the same, didReceiveUpdate is still false, and React skips reconcileChildren, calling bailoutOnAlreadyFinishedWork instead. When does React render again? covers these rules from the outside.
Loading older chat messages without rewriting the list
A support chat shows the newest messages and loads older ones at the top when you scroll up. Adding to the top of a list is where keys earn their keep. Press Load an older message.
import { useState } from "react";import { useDomReport } from "./watch.js"; const older = [ { id: 1, text: "Hi, is the blue mug in stock?" }, { id: 2, text: "Yes, we have three left." }, { id: 3, text: "Great, can you hold one?" },]; export default function App() { const [messages, setMessages] = useState([ { id: 4, text: "Sure, until Friday." }, { id: 5, text: "Thank you!" }, ]); const [byPosition, setByPosition] = useState(false); const [listRef, report] = useDomReport(); function loadOlder() { const next = older.findLast((message) => message.id < messages[0].id); if (next) setMessages([next, ...messages]); } return ( <> <button onClick={loadOlder} disabled={messages[0].id === 1}> Load an older message </button> <label> <input type="checkbox" checked={byPosition} onChange={(event) => setByPosition(event.target.checked)} /> Key by position </label> <ul ref={listRef}> {messages.map((message, index) => ( <li key={byPosition ? index : message.id}>{message.text}</li> ))} </ul> <ol className="report"> {report.map((line) => ( <li key={line}>{line}</li> ))} </ol> </> );}Lines 4 to 8 are the older messages the server would send. Lines 11 to 14 start the chat with the two newest. On line 19, findLast picks the newest older message still to load, and line 20 puts it at the top. Line 34 keys each message by its id, or by its position when the box on lines 28 to 31 is ticked.
With ids, the report says only added <li> Great, can you hold one?. The first pass stops at once, because key 3 isn't key 4. In the second pass, message 3 is new, and messages 4 and 5 keep their old positions, 0 and 1, in order, so nothing moves. Now tick Key by position, which rebuilds every row because every key changes, and load another message. The report lists a changed text to … line for every row that was already there, then an added row at the bottom: each row was reused by position and given another message's text. In a real chat, anything a row holds, like an open menu or a loaded image, would end up next to the wrong message.
<li key="tea">Tea</li>becomes<li key="tea">Green tea</li>in the same place<input />becomes<input disabled />in the same place<div className="card">becomes<section className="card">in the same place<Profile key="asha" />becomes<Profile key="ravi" />in the same place<Cart />becomes<Wishlist />in the same place- A keyed list Apples, Bread, Cheese becomes Bread, Cheese, Apples. The Apples row?
- A keyed list Apples, Bread, Cheese becomes Cheese, Apples, Bread. The Cheese row?
- A keyed list Apples, Bread, Cheese becomes Cheese, Apples, Bread. The Bread row?
Sort each change: does React keep the element where it is, move it, or delete it and create a new one?
Common mix-ups
- "React compares the old and new trees to find the smallest change." No. It makes one pass, level by level, and assumes that a different type means a different subtree and that keys say which children are the same.
- "With keys, React moves only the row I moved." No. It walks forward and moves every reused child whose old position is below
lastPlacedIndex. Moving the last row to the top moves all the others. - "The two branches of a ternary are two different components." No. If both branches put the same type at the same place with the same key, React keeps the fiber and its state.
- "A new key on the same element just updates it." No. A different key means no old fiber matches, so React deletes the old one and creates a new one with fresh state.
- "React skips a child when its props are equal." Only when they are the same object. Without
memo,beginWorkcompares the props objects with!==, and a new element always brings a new object. - "useFiber is a hook." No. It's an internal helper in ReactChildFiber.js that reuses a fiber with new props.
Practice exercises
The exercises use the same watch.js report. Guess first, then press Edit & run to check.
Line 16 swaps the first two rows. Which row will the report say React moved? Answer first, then press Edit & run and press the button.
import { useState } from "react";
import { useDomReport } from "./watch.js";
const groceries = [
{ id: "apple", name: "Apples" },
{ id: "bread", name: "Bread" },
{ id: "cheese", name: "Cheese" },
{ id: "dates", name: "Dates" },
];
export default function App() {
const [items, setItems] = useState(groceries);
const [listRef, report] = useDomReport();
function swapFirstTwo() {
setItems([items[1], items[0], ...items.slice(2)]);
}
return (
<>
<button onClick={swapFirstTwo}>Swap the first two</button>
<ul ref={listRef}>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
<ol className="report">
{report.map((line) => (
<li key={line}>{line}</li>
))}
</ol>
</>
);
}import { useState } from "react";import { useDomReport } from "./watch.js"; const groceries = [ { id: "apple", name: "Apples" }, { id: "bread", name: "Bread" }, { id: "cheese", name: "Cheese" }, { id: "dates", name: "Dates" },]; export default function App() { const [items, setItems] = useState(groceries); const [listRef, report] = useDomReport(); function swapFirstTwo() { setItems([items[1], items[0], ...items.slice(2)]); } return ( <> <button onClick={swapFirstTwo}>Swap the first two</button> <ul ref={listRef}> {items.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul> <ol className="report"> {report.map((line) => ( <li key={line}>{line}</li> ))} </ol> </> );}The first pass stops at once. In the second pass Bread (old 1) stays and sets lastPlacedIndex to 1. Apples (old 0) is below 1, so it gets Placement. Cheese (2) and Dates (3) stay. The report says moved <li> Apples.
Type something in the box, then press Make it bigger. Is your text still there? Answer first, then check it with Edit & run.
import { useState } from "react";
function NoteBox({ size }) {
return <textarea rows={size === "big" ? 6 : 2} placeholder="Delivery notes" />;
}
export default function App() {
const [expanded, setExpanded] = useState(false);
return (
<>
<button onClick={() => setExpanded(!expanded)}>
{expanded ? "Make it smaller" : "Make it bigger"}
</button>
<div>{expanded ? <NoteBox size="big" /> : <NoteBox size="small" />}</div>
</>
);
}import { useState } from "react"; function NoteBox({ size }) { return <textarea rows={size === "big" ? 6 : 2} placeholder="Delivery notes" />;} export default function App() { const [expanded, setExpanded] = useState(false); return ( <> <button onClick={() => setExpanded(!expanded)}> {expanded ? "Make it smaller" : "Make it bigger"} </button> <div>{expanded ? <NoteBox size="big" /> : <NoteBox size="small" />}</div> </> );}Both branches put a NoteBox with no key as the <div>'s only child. The key (null) and the type match, so React reuses the fiber with size="big". The <textarea> is the same DOM node, so your text stays; only its rows changes.
Numbers typed in this list stay at their position when you press Last to top. Fix it so each number follows its grocery. After your fix, press Last to top once: how many rows does the report say React moved?
import { useState } from "react";import { useDomReport } from "./watch.js"; const groceries = [ { id: "apple", name: "Apples" }, { id: "bread", name: "Bread" }, { id: "cheese", name: "Cheese" }, { id: "dates", name: "Dates" },]; export default function App() { const [items, setItems] = useState(groceries); const [listRef, report] = useDomReport(); return ( <> <button onClick={() => setItems([items.at(-1), ...items.slice(0, -1)])}>Last to top</button> <ul ref={listRef}> {items.map((item, index) => ( <li key={index}> <label> {item.name} <input size={3} /> </label> </li> ))} </ul> <ol className="report"> {report.map((line) => ( <li key={line}>{line}</li> ))} </ol> </> );}import { useState } from "react";import { useDomReport } from "./watch.js"; const groceries = [ { id: "apple", name: "Apples" }, { id: "bread", name: "Bread" }, { id: "cheese", name: "Cheese" }, { id: "dates", name: "Dates" },]; export default function App() { const [items, setItems] = useState(groceries); const [listRef, report] = useDomReport(); return ( <> <button onClick={() => setItems([items.at(-1), ...items.slice(0, -1)])}>Last to top</button> <ul ref={listRef}> {items.map((item, index) => ( <li key={item.id}> <label> {item.name} <input size={3} /> </label> </li> ))} </ul> <ol className="report"> {report.map((line) => ( <li key={line}>{line}</li> ))} </ol> </> );}With key={item.id}, Dates keeps its fiber, its <li> and its box, and the number follows it to the top. placeChild keeps Dates in place and marks Apples, Bread and Cheese with Placement, so the report lists 3 moved rows.
Switching the layout throws away the note you typed. Change the code so the switch keeps the card and its box. What does the report say when you press Switch the layout now?
import { useState } from "react";import { useDomReport } from "./watch.js"; export default function App() { const [compact, setCompact] = useState(false); const [cardRef, report] = useDomReport(); return ( <> <button onClick={() => setCompact(!compact)}>Switch the layout</button> <div ref={cardRef}> {compact ? ( <section className="card compact"> <h3>Delivery notes</h3> <textarea rows={2} /> </section> ) : ( <div className="card"> <h3>Delivery notes</h3> <textarea rows={2} /> </div> )} </div> <ol className="report"> {report.map((line) => ( <li key={line}>{line}</li> ))} </ol> </> );}import { useState } from "react";import { useDomReport } from "./watch.js"; export default function App() { const [compact, setCompact] = useState(false); const [cardRef, report] = useDomReport(); return ( <> <button onClick={() => setCompact(!compact)}>Switch the layout</button> <div ref={cardRef}> <div className={compact ? "card compact" : "card"}> <h3>Delivery notes</h3> <textarea rows={2} /> </div> </div> <ol className="report"> {report.map((line) => ( <li key={line}>{line}</li> ))} </ol> </> );}With one element type in both layouts, reconcileSingleElement keeps the fiber and the DOM nodes, and only the props change. The report says changed the class attribute, and the note you typed stays.
Write movedRows, which returns the keys React moves when a keyed list changes from oldKeys to newKeys. Follow reconcileChildrenArray and placeChild. What does the first console.log print?
// Which rows does React move? Follow reconcileChildrenArray:
// walk while the keys match, then use a map and lastPlacedIndex.
function movedRows(oldKeys, newKeys) {
const moved = [];
// Your code here.
return moved;
}
const before = ["apple", "bread", "cheese", "dates"];
console.log(movedRows(before, ["dates", "apple", "bread", "cheese"]).join(", "));
console.log(movedRows(before, ["bread", "cheese", "dates", "apple"]).join(", "));// Which rows does React move? Follow reconcileChildrenArray:
// walk while the keys match, then use a map and lastPlacedIndex.
function movedRows(oldKeys, newKeys) {
const moved = [];
let lastPlacedIndex = 0;
let newIdx = 0;
// The first pass: walk both lists while the keys match.
while (newIdx < oldKeys.length && newIdx < newKeys.length && oldKeys[newIdx] === newKeys[newIdx]) {
lastPlacedIndex = newIdx;
newIdx++;
}
// The rest of the old list, in a map from key to old position.
const existing = new Map();
for (let i = newIdx; i < oldKeys.length; i++) existing.set(oldKeys[i], i);
// The second pass: placeChild's rule.
for (; newIdx < newKeys.length; newIdx++) {
const oldIndex = existing.get(newKeys[newIdx]);
if (oldIndex === undefined) continue; // A new row is inserted, not moved.
if (oldIndex < lastPlacedIndex) moved.push(newKeys[newIdx]);
else lastPlacedIndex = oldIndex;
}
return moved;
}
const before = ["apple", "bread", "cheese", "dates"];
console.log(movedRows(before, ["dates", "apple", "bread", "cheese"]).join(", "));
console.log(movedRows(before, ["bread", "cheese", "dates", "apple"]).join(", "));The first call prints apple, bread, cheese: dates sets lastPlacedIndex to 3, and the other three are below it. The second prints apple: bread, cheese and dates push lastPlacedIndex to 3, and apple, at 0, is left behind. That's the same answer the real React gave in the reorder example.
Every Switch theme makes ProductList render again. Without memo, change where the list is created so it doesn't. After your change, how many ProductList renders lines does Switch theme add?
import { useState } from "react"; function ProductList() { console.log("ProductList renders"); return ( <ul> <li>Apples</li> <li>Bread</li> </ul> );} function Layout() { const [dark, setDark] = useState(false); return ( <div className={dark ? "page dark" : "page"}> <button onClick={() => setDark(!dark)}>Switch theme</button> <ProductList /> </div> );} export default function App() { return <Layout />;}import { useState } from "react"; function ProductList() { console.log("ProductList renders"); return ( <ul> <li>Apples</li> <li>Bread</li> </ul> );} function Layout({ children }) { const [dark, setDark] = useState(false); return ( <div className={dark ? "page dark" : "page"}> <button onClick={() => setDark(!dark)}>Switch theme</button> {children} </div> );} export default function App() { return ( <Layout> <ProductList /> </Layout> );}Now App creates the <ProductList /> element. When Layout's state changes, App doesn't render, so children is the same element with the same props object. beginWork bails out of ProductList, and Switch theme adds 0 lines to the console.
Check your understanding
For list questions, write the old indexes under the new order, then go left to right with lastPlacedIndex.
Question 1 of 8The list shows apple, bread, cheese, dates, each keyed by its id. Which rows does React move when you press Reorder?
Read the code, then predictJSXconst [items, setItems] = useState(["apple", "bread", "cheese", "dates"]); // … <button onClick={() => setItems(["cheese", "apple", "bread", "dates"])}> Reorder </button> <ul> {items.map((id) => <li key={id}>{id}</li>)} </ul>Choose an answer to see the explanation.
Question 2 of 8At the same place in the tree,
<div className="card">becomes<section className="card">. What does React do?Choose an answer to see the explanation.
Question 3 of 8In reconcileChildrenArray, what does
lastPlacedIndexhold?Choose an answer to see the explanation.
Question 4 of 8Why doesn't reconcileChildrenArray also search from the end of the list, which would make moving the last row to the top cheap?
Choose an answer to see the explanation.
Question 5 of 8What is
useFiberin ReactChildFiber.js?Choose an answer to see the explanation.
Question 6 of 8This app starts, then you press Toggle. Which components log during that click?
Read the code, then predictimport { useState } from "react"; function Shelf({ children }) { const [open, setOpen] = useState(true); console.log("Shelf"); return ( <section> <button onClick={() => setOpen(!open)}>Toggle</button> {children} <Price /> </section> ); } function Price() { console.log("Price"); return <p>$3</p>; } function Label() { console.log("Label"); return <p>Tea</p>; } export default function App() { return ( <Shelf> <Label /> </Shelf> ); }Choose an answer to see the explanation.
Question 7 of 8This list is keyed by position. What does React do to the DOM when you press Reverse?
Read the code, then predictJSXconst [items, setItems] = useState(["Apples", "Bread", "Cheese"]); // … <button onClick={() => setItems(items.toReversed())}>Reverse</button> <ul> {items.map((name, index) => <li key={index}>{name}</li>)} </ul>Choose an answer to see the explanation.
Question 8 of 8On a component's first render, why doesn't React mark each of its children with Placement?
Choose an answer to see the explanation.
Key takeaways
- Reconciliation runs in
beginWork, one level at a time:reconcileChildrencompares what a component returned with its old child fibers. A mounting subtree usesmountChildFibers, which records no moves. - Same key and same type:
useFiberreuses the old fiber and its DOM node with new props. Anything else:createFiberFromElementmakes a new fiber, anddeleteChildputs the old one on the parent'sdeletions. - A different type replaces the whole subtree. React never compares what's inside it, which keeps the pass fast.
reconcileChildrenArraywalks both lists while the keys match, then maps the rest of the old children by key. A reused child whose old index is belowlastPlacedIndexgetsPlacement, so moving the last row to the top moves all the others.beginWorkbails out of a fiber when its props object is the same and it has no update or context change; if no child has work either, the whole subtree is skipped.
Remember the one-liner.
Same type and key keeps the fiber; anything else is rebuilt; in lists, keys pair the children and lastPlacedIndex picks the moves.
Coming next: The commit phase. Reconciliation leaves flags like Placement and ChildDeletion on the fibers; the commit phase reads them and changes the DOM in one go.