Lists
A list is just an expression returning an array of views (rule 4 of the template language), and matching between updates has two modes: positional by default, identity-based when you key the item’s root:
${() => rows.value.map((r) => html`<li>${r.label}</li>`)} // positional
${() => rows.value.map((r) => html`<tr key="${r.id}">...</tr>`)} // keyed: identity
key is a directive like bind and ref: consumed by the engine, never
rendered as an attribute, never delivered as a component prop. With a
key, the existing DOM, and any component instances inside it with their
state, moves instead of rebuilding.
Background
Rendering a list is straightforward; updating one requires consistent answers to three questions:
- How are old and new entries matched across updates? Without identity matching, every update rebuilds the list from scratch, discarding component state and triggering layout.
- What happens to component state inside a matched entry? An entry rebuilt instead of moved loses focus, scroll position, and any in-flight edit. Identity-based matching exists so instances can move instead.
- What happens when a matched entry’s values changed? In a setup-runs-once model this is not automatic: the instance was built against the old values, so the engine must carry the new ones in without re-running setup.
The third question required engine machinery in v6.
Mechanics
The positional diff is the default, and it is enough for most lists.
Lists that only append, shrink at the end, or change in place reuse every
surviving entry by position. The engine’s own suite pins this: an unkeyed
['a','b'] → ['b','a'] keeps the same first node and just rewrites its
text. No key needed, nothing to configure.
key="$" adds identity. When items can reorder, be inserted or
removed, key the item’s root element. Matching becomes identity-based:
${() => rows.value.map((r) => html`<tr key="${r.id}">...</tr>`)}
key="${r}" // the item object itself is a valid key (Object.is)
Keys must be unique per list; duplicates warn LJS-204 in dev and fall
back to rebuilding (correct, but slow, and duplicate entries lose
component state).
Key the source item, not a normalized copy. A helper that rebuilds
item objects every render (normalize(list).map(...)) makes
object-identity keys churn: every update produces fresh objects, no key
ever matches, and the list fully rebuilds on every change. That is worse
than no keys at all. Normalize per item, at render (asOption(raw))
and key by the raw entry, or key by a stable id.
Key scope is one list position. Keys match within a single list slot only. An item moving between two lists (a kanban card crossing columns) rebuilds on the other side. If cross-list identity matters, render one flat keyed list and place items visually (CSS grid), or accept the rebuild.
Matched entries with changed values are patched, not rebuilt. This is the engine’s answer to the third question:
- Plain-element entries update in place: bindings re-run, nodes are kept.
- Entries containing components are patched: new prop values flow into the instance’s live prop states, fresh inline closures swap into the event handlers, and children update through their own bindings. DOM, internal state and focus survive; setup never re-runs.
- The instance is rebuilt only on a structural change: a different
component in the tag, a shared
Stateprop swapped for another one,bind/ref/exposechanges, or an undeclared prop changing.
Patching applies to positional lists too. Keys add identity across reorder/insert/remove, patching handles the value changes either way.
touch() reaches into components. When a list re-runs under
touch() and an item is passed by reference, as in
<${Card} item="${r}" />, the card’s prop state is touched too, so the
card re-reads the mutated contents. The propagation is recursive through
nested components (a card passing item.value further down). Ownership
holds: a shared State passed as a prop is never touched by
propagation; its owner touches it. This is v5’s loop-scope visibility
(self.refresh() reaching loop items) achieved without v5’s cost of
writing framework fields onto your data objects.
In practice: the receipts
The 200-row shuffle. The engine suite (tests/keys.test.ts) reverses
a 200-row keyed list and asserts, node by node, that every element
after the shuffle is the element from before it, mapped by id: all 200
moved, none rebuilt. The same suite pins component-state survival: a
counter clicked twice, reordered, is the same DOM node with its count
intact.
The datagrid keys by row identity. Its virtualized row view is keyed
by the data object itself (the same identity its selection Set uses)
so a scroll keeps the overlapping window rows, and a sort or filter
moves surviving rows (and any component a cell render() mounted, with
its state) instead of rebuilding them. From the shipped source:
// Keyed by ROW IDENTITY (the data object, the same identity the
// selection Set uses): a scroll keeps the overlapping window rows,
// a sort/filter MOVES surviving rows ... instead of rebuilding them
return html`<div key="${row}" class="lm-datagrid-row ..." role="row" ...>`;
The contextmenu deleted its view cache. Before live patching, the
contextmenu kept a WeakMap of one view per menu level: a rebuilt Modal
would re-run auto-adjust and visibly move the parent menu when a submenu
opened. Patching made the cache unnecessary: the engine keeps the
surviving Modal entries and flows fresh item views through the live
children bindings. The workaround was removed from the source, and the
comment marking its grave is still there.
touch() through by-reference props, the pattern, end to end:
const Row = component('row', { item: Object }, (props) =>
html`<b>${() => props.item.value.title}</b>`);
const App = (p, { state }) => {
const rows = state([{ id: 1, title: 'a' }, { id: 2, title: 'b' }]);
const rename = () => {
rows.value[0].title = 'mutated'; // in place, same reference
rows.touch(); // the Row re-reads; no rebuild
};
return html`<div>
${() => rows.value.map((r) => html`<i key="${r.id}"><${Row} item="${r}" /></i>`)}
<button onclick="${rename}">rename</button>
</div>`;
};
The engine suite pins all three properties: the component re-reads the
mutated item without rebuilding, the propagation recurses through nested
components, and a shared State prop is left alone until its owner
touches it.
Reference
// Positional (default): append / shrink / in-place changes reuse entries
${() => items.value.map((x) => html`<li>${x}</li>`)}
// Keyed: reorder / insert / remove moves DOM + component instances
${() => rows.value.map((r) => html`<tr key="${r.id}">...</tr>`)}
key="${r.id}" // stable id
key="${r}" // the source object itself (Object.is identity)
keyis an engine directive: never rendered, never a component prop, noLJS-402on contract components.- Unique per list; duplicates warn
LJS-204and rebuild. - Scope: one list position. Cross-list moves rebuild.
- Key the source item, never a per-render normalized copy.
- Matched entries patch: prop states written, fresh closures swapped in,
children stay live. Structural changes (different component, swapped
shared
State,bind/ref/expose, undeclared prop) rebuild. touch()propagates into by-reference component props, recursively; sharedStateprops stay with their owner.
For the mutation model behind touch(), read State; for
what a contract component’s live prop states are, read
Contracts.