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