cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Say 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.
  • 02
    Predict the moves in a listFollow reconcileChildrenArray's two passes, its map of old children, and lastPlacedIndex, and say which rows get Placement.
  • 03
    Explain 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.

A grocery list that reports its DOM changesPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

Left, the old fibers in order: Apples at 0, Bread at 1, Cheese at 2, Dates at 3. Right, the new elements: Dates, Apples, Bread, Cheese. Arrows pair each new element with the old fiber that has the same key. Dates is marked stays; Apples, Bread and Cheese are marked moves.old fibersnew elements0 Apples1 Bread2 Cheese3 DatesDatesApplesBreadCheesestaysmovesmovesmoves
React pairs each new element with the old fiber that has the same key. The pairs keep their DOM nodes. Then it works out which of them must move: here, Apples, Bread and Cheese, while Dates stays where it was.
Real-life analogyPutting books back in a new order

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
The main idea

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:

The end of updateFunctionComponent, ReactFiberBeginWork.js lines 1549 to 1561JavaScript
  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:

  1. Lines 1 to 4 skip the children when nothing changed: a bailout. The last big section of this lesson comes back to it.
  2. Lines 6 to 8 only matter during hydration, when React takes over HTML from the server.
  3. Line 11 marks the fiber as having rendered. The comment says why: React DevTools reads the flag.
  4. Line 12 is reconciliation. nextChildren is what your component returned, and reconcileChildren compares it with this fiber's children.
  5. 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.

reconcileChildren, ReactFiberBeginWork.js lines 339 to 370, comments left outJavaScript
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,
    );
  }
}
  1. Line 7 asks whether this fiber has a current twin. If not, the component is appearing for the first time, and lines 8 to 13 use mountChildFibers.
  2. Otherwise lines 15 to 20 use reconcileChildFibers, with current.child, the first old child, and nextChildren, the new elements. Either way, the result becomes workInProgress.child.

The two functions are the same code. The end of ReactChildFiber.js builds both from one factory:

Two child reconcilers, ReactChildFiber.js lines 2110 to 2112JavaScript
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.

Which function compares what a component returns
What the component returnsThe function that compares itWhat it does
One element, like <ul>…</ul>reconcileSingleElementLooks 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(…)reconcileChildrenArrayMatches old and new children by key in two passes, and marks moves, insertions and deletions.
A fragment <>…</> with no keyIts children, as an arrayA fragment at the top of what you return is skipped: React reconciles the children inside it.
Text or a number, like "Total: 7"reconcileSingleTextNodeKeeps the first old child if it's text, gives it the new text, and deletes the rest.
null, false, true or undefineddeleteRemainingChildrenTreats 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.

A card that changes its class or its tagPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

reconcileSingleElement, simplified from ReactChildFiber.js lines 1703 to 1806JavaScript
  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;
  }
  1. Line 7 reads the new element's key. Here there is none, so it's null, and an old child with no key matches it.
  2. Lines 9 and 25 walk the old children, from the first one through each sibling. Usually there is only one.
  3. Line 10 compares keys. When they match, line 12 compares types: elementType is "div" for a <div>, or the function itself for a component like NoteBox.
  4. 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.
  5. Lines 19 to 21: the key matched but the type didn't. React deletes this child and every one after it, and stops looking.
  6. Line 23: a different key. This old child isn't the one, so it's deleted, and the loop tries the next.
  7. Lines 28 to 31: nothing matched, so createFiberFromElement makes 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:

useFiber, ReactChildFiber.js lines 502 to 509JavaScript
  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:

deleteChild, ReactChildFiber.js lines 434 to 446JavaScript
  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);
    }
  }
  1. Lines 2 to 5: a mounting subtree has nothing to delete, so this does nothing.
  2. Lines 6 to 9: the parent fiber keeps a deletions array. The first deletion creates it and sets the parent's ChildDeletion flag, so the commit knows to look.
  3. 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.

Left, same type: a div with a textarea becomes a div with a textarea; an arrow joins them, labelled kept, same fiber and DOM node. Right, different type: the old div and textarea are crossed out, and an arrow leads to a new section with a new textarea, labelled old subtree deleted, new fibers and DOM nodes.Same type<div><textarea><div><textarea>kept: same fiber, same DOM nodenew props; typed text staysDifferent type<div><textarea><section><textarea>old subtree deletednew fibers and DOM nodes
When the type at a place stays the same, React keeps the fiber and its DOM node and gives it new props, so the typed text stays. When the type changes, React deletes the whole old subtree and builds a new one from nothing.

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.

Kept or replaced, and what survives
The changeWhat React doesWhat 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 ternaryReuses 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.

reconcileChildrenArray, simplified from ReactChildFiber.js lines 1177 to 1369JavaScript
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:

  1. Lines 10 to 13 set up: oldFiber is the first old child, newIdx counts through the new elements, and lastPlacedIndex starts at 0.
  2. 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 returns null, 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 calls placeChild, and lines 29 to 34 chain the new fiber after the previous one.
  3. 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.
  4. 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.
  5. 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.
  6. 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 calls placeChild.
  7. 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:

placeChild, ReactChildFiber.js lines 511 to 541JavaScript
  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;
    }
  }
  1. Line 6 saves the child's new position.
  2. Lines 7 to 12: a mounting subtree records no moves. (The Forked flag helps useId during hydration.)
  3. Lines 13 to 15: a reused child has an alternate, the old fiber, and oldIndex is where it was.
  4. Lines 16 to 21: if it was before lastPlacedIndex, it must move. It gets the Placement flag, and lastPlacedIndex stays the same.
  5. Lines 22 to 25: otherwise it can stay where it is, and its old index becomes the new lastPlacedIndex.
  6. Lines 26 to 30: a new child is an insertion: Placement, plus PlacementDEV, 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):

mapRemainingChildren, simplified from ReactChildFiber.js lines 467 to 500JavaScript
  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.

The new order after Move Dates to the top: Dates, Apples, Bread, Cheese. Under each: its old index, 3, 0, 1 and 2. Dates: 3 isn't below 0, stays, and lastPlacedIndex becomes 3. Apples: 0 is below 3, moves. Bread: 1 is below 3, moves. Cheese: 2 is below 3, moves.The new order, read left to rightDatesold index 33 isn't below 0staysApplesold index 00 is below 3movesBreadold index 11 is below 3movesCheeseold index 22 is below 3moveslastPlacedIndex = 3
React reads the new list from left to right. Dates was at old index 3, which isn't below 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.

Playground: step through reconcileChildrenArray
Old fibers
  1. 0Appleswaiting
  2. 1Breadwaiting
  3. 2Cheesewaiting
  4. 3Dateswaiting
New elements
  1. 0Dateswaiting
  2. 1Appleswaiting
  3. 2Breadwaiting
  4. 3Cheesewaiting
lastPlacedIndex
0
existingChildren
not made yet
8  let previousNewFiber: Fiber | null = null;9 10  let oldFiber = currentFirstChild;11  let lastPlacedIndex = 0;12  let newIdx = 0;13  let nextOldFiber = null;14  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
Step 1 of 9
Start at the first child of each list

Line 11 sets lastPlacedIndex to 0. oldFiber points at the first old child, Apples, and newIdx at the first new one, Dates.

This replays a small model of reconcileChildrenArray, written for this lesson, on keyed list items of the same type. It isn't a view inside React. The lesson's test checks every move, insertion and deletion it shows against the DOM changes React 19.3.1 makes for the same lists. Pick a change, then press Next step.

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.

Reorder a keyed list and count the movesPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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:

The comment at the top of reconcileChildrenArray, ReactChildFiber.js lines 1183 to 1186JavaScript
    // 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.

One list passed in from App, one made by LayoutPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

A tree. At the top, App, labelled no update. Below it, Layout, labelled state changed, renders. Below Layout, two children: ProductList made by App, labelled same props object, bails out; and ProductList made by Layout, labelled new props object, renders.Appno updateLayoutstate changed: rendersProductList made by AppProductList made by Layoutsame props object: bails outnew props object: renders
Only 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:

cloneChildFibers, ReactChildFiber.js lines 2120 to 2146JavaScript
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;
}
  1. 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.
  2. Lines 9 to 11: no children, nothing to copy.
  3. Lines 13 to 15 copy the first child with createWorkInProgress, the same function useFiber calls. But the props it passes are the child's old pendingProps: the same object as before.
  4. 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.

A chat that loads older messages at the topPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

Kept, moved, or replaced?
  • <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?
Try it yourself
0 of 8 correct

Sort each change: does React keep the element where it is, move it, or delete it and create a new one?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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, beginWork compares 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.

Exercise 1 · Warm-upPredict the move

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>
    </>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upDoes the note survive?

    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>
        </>
      );
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeMake the numbers follow their rows

      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>    </>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeStop the card from being rebuilt

        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>    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWrite placeChild's rule

          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?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          // 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(", "));

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeSkip the list without memo

            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 />;}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For list questions, write the old indexes under the new order, then go left to right with lastPlacedIndex.

              The reconciliation quiz · 8 questionsScore: first tries count
              1. 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 predictJSX
                const [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.

              2. 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.

              3. Question 3 of 8In reconcileChildrenArray, what does lastPlacedIndex hold?

                Choose an answer to see the explanation.

              4. 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.

              5. Question 5 of 8What is useFiber in ReactChildFiber.js?

                Choose an answer to see the explanation.

              6. Question 6 of 8This app starts, then you press Toggle. Which components log during that click?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { 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.

              7. Question 7 of 8This list is keyed by position. What does React do to the DOM when you press Reverse?

                Read the code, then predictJSX
                const [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.

              8. 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: reconcileChildren compares what a component returned with its old child fibers. A mounting subtree uses mountChildFibers, which records no moves.
              • Same key and same type: useFiber reuses the old fiber and its DOM node with new props. Anything else: createFiberFromElement makes a new fiber, and deleteChild puts the old one on the parent's deletions.
              • A different type replaces the whole subtree. React never compares what's inside it, which keeps the pass fast.
              • reconcileChildrenArray walks both lists while the keys match, then maps the rest of the old children by key. A reused child whose old index is below lastPlacedIndex gets Placement, so moving the last row to the top moves all the others.
              • beginWork bails 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.

              CompleteFrontend Clear concepts. Working examples.