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 State prop swapped for another one, bind/ref/expose changes, 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)
  • key is an engine directive: never rendered, never a component prop, no LJS-402 on contract components.
  • Unique per list; duplicates warn LJS-204 and 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; shared State props stay with their owner.

For the mutation model behind touch(), read State; for what a contract component’s live prop states are, read Contracts.