Good to knowReact Internals · 09

Child Reconciliation Algorithm

The keyed list diff: lockstep fast path, the key Map and the lastPlacedIndex move heuristic.

This section:trigger→schedule→render→commit→effects
On this page · 7 sections
  1. Interview Q&A
  2. The problem: pairing old children with new ones
  3. The algorithm
  4. Why it works: the lastPlacedIndex idea
  5. Child reconciliation, step by step
  6. How other frameworks differ
  7. Rules and caveats

The list-diffing algorithm React runs when a component returns several children, read from reconcileChildrenArray, updateSlot, mapRemainingChildren and placeChild in the React 19.2.5 build (react-dom-client.development.js), whose source is ReactChildFiber.js. Part of React Internals. The rules it implements (type and key) are explained in Reconciliation. My own notes and clarifications are marked with > 💬.

Interview Q&A

How does React diff a list of children?

In up to three passes over the new children, all linear. (1) Walk old and new in lockstep while keys match, which is the common case of nothing moving. (2) If one side runs out, bulk-delete or bulk-insert the rest. (3) Otherwise put the remaining old fibers in a Map by key (or index), look each new child up in it, and decide per child whether it can stay or must move. Old fibers left in the map are deleted.

How does React decide which nodes to move?

It keeps lastPlacedIndex, the highest old index among the reused children so far. A reused child whose old index is lower than that has jumped backwards relative to a sibling, so it’s marked to move. Otherwise it stays and becomes the new lastPlacedIndex. New children are always inserted. In our example, moving the last item to the front costs n−1 moves, while moving the first item to the end costs one.

Why are index keys a problem?

With index keys (or no keys, which falls back to the index), inserting at the front makes old key 0 match the new first item. React reuses the old first item’s fiber, with its state and DOM node, for a different piece of data. Uncontrolled inputs, focus and local state end up on the wrong row.

Is React's list diff optimal?

No. It doesn’t compute the minimum number of moves. Vue 3 and Inferno use a longest-increasing-subsequence step for that. React keeps a simpler single left-to-right pass, because fibers only have forward (sibling) pointers.

The problem: pairing old children with new ones

A fiber’s children are a singly linked list: parent.child is the first, then .sibling to the next. The component has just returned a new array of elements. React must decide, for each new element:

It must also decide which old fibers have no match → delete.

Doing this with nested loops is O(n²), and optimal moves need more bookkeeping. React does it in O(n) with a Map, accepting some extra moves in rare cases.

The algorithm

Simplified from reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes):

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren) {
	let resultingFirstChild = null
	let previousNewFiber = null
	let oldFiber = currentFirstChild
	let lastPlacedIndex = 0
	let newIdx = 0
	let nextOldFiber = null

	// PASS 1: walk both lists in lockstep while keys line up
	for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
		nextOldFiber = oldFiber.sibling
		// updateSlot returns null if the KEYS differ;
		// if keys match but TYPES differ it returns a brand-new fiber
		const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx])
		if (newFiber === null) break                    // keys diverged → leave the fast path
		if (oldFiber && newFiber.alternate === null) {
			deleteChild(returnFiber, oldFiber)          // same key, different type → replace
		}
		lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx)
		link(newFiber)
		oldFiber = nextOldFiber
	}

	// PASS 2a: the new list ended → everything left in the old list goes
	if (newIdx === newChildren.length) {
		deleteRemainingChildren(returnFiber, oldFiber)
		return resultingFirstChild
	}

	// PASS 2b: the old list ended → everything left in the new list is an insert
	if (oldFiber === null) {
		for (; newIdx < newChildren.length; newIdx++) {
			const newFiber = createChild(returnFiber, newChildren[newIdx])
			lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx)
			link(newFiber)
		}
		return resultingFirstChild
	}

	// PASS 3: keys diverged in the middle → match by key through a Map
	const existingChildren = mapRemainingChildren(oldFiber)  // key ?? index → fiber
	for (; newIdx < newChildren.length; newIdx++) {
		const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx])
		if (newFiber !== null) {
			if (newFiber.alternate !== null) {
				existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key)  // consumed
			}
			lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx)
			link(newFiber)
		}
	}
	existingChildren.forEach((child) => deleteChild(returnFiber, child))  // leftovers
	return resultingFirstChild
}

The move decision, from the actual source (flag constants named):

function placeChild(newFiber, lastPlacedIndex, newIndex) {
	newFiber.index = newIndex
	const current = newFiber.alternate
	if (current !== null) {
		const oldIndex = current.index
		if (oldIndex < lastPlacedIndex) {
			newFiber.flags |= Placement   // this is a MOVE
			return lastPlacedIndex
		} else {
			return oldIndex               // this item can STAY where it is
		}
	} else {
		newFiber.flags |= Placement       // this is an INSERTION
		return lastPlacedIndex
	}
}

And the map, verbatim apart from formatting:

function mapRemainingChildren(currentFirstChild) {
	for (var existingChildren = new Map(); null !== currentFirstChild; )
		null !== currentFirstChild.key
			? existingChildren.set(currentFirstChild.key, currentFirstChild)
			: existingChildren.set(currentFirstChild.index, currentFirstChild),
			(currentFirstChild = currentFirstChild.sibling)
	return existingChildren
}

Unkeyed children are stored under their index. That’s the whole reason “no key” behaves exactly like key={index}.

Why it works: the lastPlacedIndex idea

Think of the old list as positions 0…n−1. Walking the new list left to right, React keeps the largest old index it has decided to keep in place. As long as each reused child’s old index is increasing, those children are already in the right relative order and none of them has to move. The first child whose old index is smaller than lastPlacedIndex was before something that is now before it, so it moves.

A move is implemented as a DOM insert. In the commit, a fiber with Placement is inserted with insertBefore its next sibling that is not itself being placed (getHostSibling), or appended if there is none. Inserting an already-attached node moves it.

The React source comments on the tradeoff: this algorithm “can’t optimize by searching from both ends since we don’t have backpointers on fibers.” A fiber has sibling but no previousSibling, so React never walks the list backwards.

Child reconciliation, step by step

Notation: letters are keys, and the number in parentheses is the old index. lp = lastPlacedIndex after that child.

Append
old fibers
aidx 0
bidx 1
cidx 2
new elements
a#0
b#1
c#2
d#3
startlastPlacedIndex = 0mapa → fiberb → fiberc → fiber
DOM
<li>a
<li>b
<li>c
<li>d
  1. render phaseupdateSlot(old 'a', new 'a') · placeChild(…, 0)Pass 1 (lockstep): keys match → reuse the fiber. Old index 0 ≥ lastPlacedIndex → stay.
  2. render phaseupdateSlot(old 'b', new 'b') · placeChild(…, 1)Pass 1 (lockstep): keys match → reuse the fiber. Old index 1 ≥ lastPlacedIndex → stay.
  3. render phaseupdateSlot(old 'c', new 'c') · placeChild(…, 2)Pass 1 (lockstep): keys match → reuse the fiber. Old index 2 ≥ lastPlacedIndex → stay.
  4. render phasecreateChild(parent, 'd') · placeChild → PlacementPass 2b: the old list ended, so d is a new fiber, flagged Placement (insert).
  5. commit phasecommitPlacement / removeChild for each flagged fiberCommit applies the flags: 1 DOM operation.
current stepbeing comparedcreatedreused / keptupdated / moveddeleted

0 / 0
Colors: yellow stay, orange move, green insert, pink delete. A reused fiber moves only if its old index is smaller than lastPlacedIndex.

Append: [a, b, c] → [a, b, c, d]

Pass New child Match Old idx lp Result
1 a a 0 0 stay
1 b b 1 1 stay
1 c c 2 2 stay
2b d – – 2 insert

1 DOM operation. The fast path handles it.

Remove from the middle: [a, b, c] → [a, c]

Pass New child Match Old idx lp Result
1 a a 0 0 stay
1 c key ≠ b – – break → pass 3, map = {b, c}
3 c c 2 2 stay
end – b left in map – – delete b

1 DOM operation.

Prepend: [a, b, c] → [x, a, b, c]

Pass New child Match Old idx lp Result
1 x key ≠ a – – break → pass 3, map = {a, b, c}
3 x – – 0 insert (before a)
3 a a 0 0 stay
3 b b 1 1 stay
3 c c 2 2 stay

1 DOM operation. With index keys the same change becomes: key 0 matches old key 0 (the a fiber) → updated to show x, key 1 → shows a, key 2 → shows b, and key 3 is inserted to show c. That’s 3 updates and 1 insert, and all local state is shifted by one row.

Move first to last: [a, b, c, d] → [b, c, d, a]

Pass New child Old idx lp Result
1 b key ≠ a – break, map all
3 b 1 1 stay
3 c 2 2 stay
3 d 3 3 stay
3 a 0 < 3 3 move

1 move.

Move last to first: [a, b, c, d] → [d, a, b, c]

Pass New child Old idx lp Result
1 d key ≠ a – break, map all
3 d 3 3 stay
3 a 0 < 3 3 move
3 b 1 < 3 3 move
3 c 2 < 3 3 move

3 moves, although moving d alone would do. This is the known worst case: because React commits to “stay” greedily from the left, bringing an item from the end to the front moves everything else.

In practice this rarely matters. Moves are cheap next to rendering, and the fibers (and their state) are all reused either way. It matters for things that are sensitive to DOM moves: an <iframe> or <video> that restarts when moved, CSS transitions, or focus. If you have a list where items often jump to the top, the moved items are the ones that lose iframe/video state.

How other frameworks differ

Vue 3 (and Inferno before it) also trims the matching prefix, but it trims the matching suffix too, because its virtual nodes live in arrays that can be indexed from both ends. For the middle it computes the longest increasing subsequence of old indices, which is exactly the largest set of nodes that can stay, and moves only the rest. That’s optimal in moves at O(n log n). React’s greedy lastPlacedIndex is a cheap approximation of the same idea: it keeps an increasing subsequence, just not necessarily the longest one.

Rules and caveats

Sources

Primary source: github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactChildFiber.js