Reconciliation

The hard problem: what happens when you delete item #2?

The problem

You have a list of 5 items rendered in the DOM:

<ul>
  <li>Apple</li>
  <li>Banana</li>   <!-- user deletes this one -->
  <li>Cherry</li>
  <li>Date</li>
  <li>Elderberry</li>
</ul>

The user deletes Banana. Now what? You have two options, and only one is acceptable.

Two approaches

❌ NAIVE

Destroy and rebuild

Delete the entire <ul>. Loop through the new array. Create fresh <li> elements for each item.

Problems:

  • Slow (creating DOM nodes is expensive)
  • Loses focus (if user was typing in an input)
  • Loses scroll position
  • Resets animations/transitions
  • Triggers unnecessary layout/paint
✓ SMART

Surgical update

Figure out that only the second <li> needs to be removed. Call removeChild() on just that element. Leave everything else alone.

Challenge: How do you know which DOM node corresponds to which data item?

The key insight

Identity matters.

When your array changes, you need to answer: "Which DOM node IS the Banana node?" If you rendered by index, and Banana was at index 1, after deletion Cherry is now at index 1. Is the DOM node at position 1 the "Cherry node" or the "former Banana node"?

The solution: give each item a stable key.

// Without keys (identity by index)
['Apple', 'Banana', 'Cherry']  // DOM: [li#0, li#1, li#2]
['Apple', 'Cherry']            // DOM: [li#0, li#1] ← which is which?

// With keys (identity by key)
[{id:1, 'Apple'}, {id:2, 'Banana'}, {id:3, 'Cherry'}]
// DOM: [li@key=1, li@key=2, li@key=3]

[{id:1, 'Apple'}, {id:3, 'Cherry'}]
// DOM: [li@key=1, li@key=3] ← key=2 was removed

This is why React, Vue, and every framework yells at you when you forget the key prop on list items.

qrazy's q-for

Here's how you write a list in qrazy:

<ul>
  <li q-for="(item, index) in items" q-key="item.id">
    <span q-text="item.text"></span>
    <button q-click="items.splice(index, 1)">Delete</button>
  </li>
</ul>

The element with q-for becomes a template. qrazy hides it, then clones it for each item in the array.

The algorithm

Here's qrazy's reconciliation algorithm in pseudocode:

function renderList(array):
    newKeys = Set()

    for each (item, index) in array:
        key = q-key expression or index
        newKeys.add(key)

        if instances.has(key):
            # Update existing DOM node in place
            instance = instances.get(key)
            instance.state[itemVar] = item
            instance.state[indexVar] = index
            runUpdaters(instance.el)
        else:
            # Clone template, bind, insert
            clone = template.cloneNode(true)
            scopedState = { ...parentState, [itemVar]: item }
            bindElement(clone, scopedState)
            insertBefore(template, clone)
            instances.set(key, { el: clone, state: scopedState })

    for each (key, instance) in instances:
        if not newKeys.has(key):
            # Remove from DOM
            instance.el.remove()
            instances.delete(key)

The magic is the instances Map. It maps keys to their DOM elements. When an item is deleted, we check which keys are missing and remove those specific DOM nodes.

Template cloning

Each list item needs its own DOM subtree. We create these by cloning the template:

// The original element becomes a hidden template
template.style.display = 'none';
template.removeAttribute('q-for');  // prevent re-processing

// For each item, create a clone
const clone = template.cloneNode(true);  // deep clone
clone.style.display = '';

// Bind with scoped state (includes item + index)
const scopedState = { ...parentState, item, index };
bindElement(clone, scopedState);

The scoped state is key. Each clone gets its own item and index variables, separate from other items.

Array method interception

When you call items.push({...}), qrazy needs to know. We intercept the array methods:

if (['push', 'pop', 'splice', 'shift', 'unshift'].includes(prop)) {
  return (...args) => {
    const result = Array.prototype[prop].apply(target, args);
    onUpdate('length');  // ← triggers re-render
    return result;
  };
}

This is why items.splice(index, 1) works in q-click. The splice runs, the interception fires, the list re-renders.

What we don't do

qrazy's reconciliation is simple. Here's what the big frameworks do that we skip:

React's Fiber architecture: Breaks reconciliation into interruptible chunks. Can pause mid-update to handle user input, then resume. Essential for smooth 60fps on large trees.

Svelte's compile-time analysis: Knows at build time exactly which DOM nodes depend on which variables. Generates surgical update code, no runtime diffing needed.

Virtual DOM diffing: React/Vue build an in-memory tree, diff it against the previous tree, then apply minimal patches. qrazy skips the virtual DOM entirely—we track real DOM nodes directly.

These optimizations matter at scale. For learning, our simpler approach is easier to understand.

Interactive demo

Add and remove items. Watch the DOM update surgically:

Try it: List reconciliation

key=

No items. Add one above.

Items: | Next ID:

Open DevTools → Elements. Add an item, watch a single <div> appear. Delete one, watch only that node disappear. The other nodes don't get touched.

First principles

Reconciliation is where "programming" becomes "computer science." You're solving a graph problem: given an old tree and a new tree, what's the minimum set of operations to transform one into the other?

The general tree diff problem is O(n³). Frameworks cheat by adding constraints: same-level comparison only, keyed identity. This reduces it to O(n).

The key insight: identity isn't position. Items move, get deleted, get added. The DOM node for "Banana" should stay the same node even if Banana moves from index 1 to index 3. Keys make this possible.