Everybody ships this bug once. A table with a checkbox on every row. You tick one, you sort the
table, and the tick is now on somebody else's row. Nothing threw. Nothing looked broken. The wrong
person got the email.
The page had no idea the rows were the same rows in a new order. It saw three slots, and it filled
them again from the top.
A key is the answer to "which row is this?", and once a row has one, everything attached to that
row moves with it.
Tick the middle row, then sort
Three rows, one checkbox each, and a button that sorts them alphabetically. Put the file below in
your own project, tick the middle row, Pears, then press Sort, and watch where the word "picked"
ends up.
Pears moves, and the tick moves with it. Nothing in the code copies a checkbox state from one row to
another, because nothing needs to. The row is a thing with a name, and the name did not change when
its position did.
import { state } from '@markless/core';export default functionSortableRowsfunction SortableRows(): Element() @{@{ · Component bodyOpens the body of a component, where ordinary statements and the markup they produce sit together. letrowslet rows: {=statestate · Watched valueDeclares a variable the page follows, which you read and assign exactly like any other variable.([ { id: 'pears', label: 'Pears', picked: false }, { id: 'apples', label: 'Apples', picked: false }, { id: 'plums', label: 'Plums', picked: false }, ]); <section(property) section: GlobalAttributes & MarklessAttributes<HTMLElement> & NativeEventAttributes<HTMLElement>> <ul(property) ul: GlobalAttributes & MarklessAttributes<HTMLUListElement> & NativeEventAttributes<HTMLUListElement>class(property) class?: string | undefined="row-list">@for@for · ListRenders its block once per item, with an optional key clause naming the stable identity of each row. (constrowconst row: {of rows; key row.id) { <li(property) li: GlobalAttributes & MarklessAttributes<HTMLLIElement> & NativeEventAttributes<HTMLLIElement>class(property) class?: string | undefined="row"> <label(property) label: GlobalAttributes & MarklessAttributes<HTMLLabelElement> & NativeEventAttributes<HTMLLabelElement> & {> <input(property) input: GlobalAttributes & MarklessAttributes<HTMLInputElement> & NativeEventAttributes<HTMLInputElement> & FormAttributes & {type(property) type?: string | undefined="checkbox"checked(property) checked?: boolean | undefined={row.picked}onChangeonChange · Event handlerRuns when this element fires the matching DOM event, and receives the native event object.={() => (row.picked = !row.picked)} /> {row.label} </label(property) label: GlobalAttributes & MarklessAttributes<HTMLLabelElement> & NativeEventAttributes<HTMLLabelElement> & {> <span(property) span: GlobalAttributes & MarklessAttributes<HTMLSpanElement> & NativeEventAttributes<HTMLSpanElement>class(property) class?: string | undefined="row-mark">{row.picked ? 'picked' : ''}</span(property) span: GlobalAttributes & MarklessAttributes<HTMLSpanElement> & NativeEventAttributes<HTMLSpanElement>> </li(property) li: GlobalAttributes & MarklessAttributes<HTMLLIElement> & NativeEventAttributes<HTMLLIElement>> } </ul(property) ul: GlobalAttributes & MarklessAttributes<HTMLUListElement> & NativeEventAttributes<HTMLUListElement>> <button(property) button: GlobalAttributes & MarklessAttributes<HTMLButtonElement> & NativeEventAttributes<HTMLButtonElement> & FormAttributes & {onClickonClick · Event handlerRuns when this element fires the matching DOM event, and receives the native event object.={() => (rows = [...rows].sort(method) Array<{ id: string; label: string; picked: boolean; }>.sort(compareFn?: ((a: {Sorts an array in place. This method mutates the array and returns a reference to the same array.((left(parameter) left: {, right(parameter) right: {) => left.label.localeCompare(method) String.localeCompare(that: string, locales?: Intl.LocalesArgument, options?: Intl.CollatorOptions): number (+2 overloads)Determines whether two strings are equivalent in the current or specified locale.(right.label))) } > Sort A to Z </button(property) button: GlobalAttributes & MarklessAttributes<HTMLButtonElement> & NativeEventAttributes<HTMLButtonElement> & FormAttributes & {> </section(property) section: GlobalAttributes & MarklessAttributes<HTMLElement> & NativeEventAttributes<HTMLElement>>}
The whole of the identity story is key row.id in the loop header.
What a key buys you
The specification is specific about what travels with a keyed row: its component instances, its
local state(), its derived values, its async work, its DOM updates and its event wiring all stay
attached to the same logical item "across reorder, insert, and delete operations".
So a key is not a hint for a diffing algorithm to do better. It is the identity of the row, and it
is what makes a row a place where state can live at all.
Choosing the key
Use the thing that identifies the item in your data. A database id, a slug, an order number. It has
to be stable, meaning the same item carries the same key on every render, and unique within the
list.
Two things not to use. Not the array index by accident, because it names the slot rather than the
item, which is the original bug in a new costume. And not a value the reader can edit, because a row
that gets renamed becomes, as far as the page is concerned, a different row.
Naming the position is sometimes genuinely what you want, and TSRX has a way to say so out loud:
importProductCardimport ProductCardfrom './product-card.tsrx';import type { Product } from './products.ts';export default functionProductGridfunction ProductGrid({ products }: {({ products(parameter) products: readonly Product[] }: { readonlyproducts(property) products: readonly Product[]: readonlyProductimport Product[] }) @{@{ · Component bodyOpens the body of a component, where ordinary statements and the markup they produce sit together. <section(property) section: GlobalAttributes & MarklessAttributes<HTMLElement> & NativeEventAttributes<HTMLElement>>@for@for · ListRenders its block once per item, with an optional key clause naming the stable identity of each row. (constproductconst product: Productof products; index i; key i) { <ProductCardimport ProductCardproduct(property) product: Product={product} /> } </section(property) section: GlobalAttributes & MarklessAttributes<HTMLElement> & NativeEventAttributes<HTMLElement>>}
That reads "state follows the slot, not the item", which is right for a fixed set of positions and
wrong for a list that reorders.
An empty list gets its own branch
You do not need an @if around the loop to say "nothing here yet". @empty belongs to the loop and
renders when it has no items:
import { state } from '@markless/core';export default functionUpdatesfunction Updates(): Element() @{@{ · Component bodyOpens the body of a component, where ordinary statements and the markup they produce sit together. letupdateslet updates: {=statestate · Watched valueDeclares a variable the page follows, which you read and assign exactly like any other variable.([{ id: 'markless', project: 'Markless', version: '0.3.1' }]); <ul(property) ul: GlobalAttributes & MarklessAttributes<HTMLUListElement> & NativeEventAttributes<HTMLUListElement>class(property) class?: string | undefined="update-list">@for@for · ListRenders its block once per item, with an optional key clause naming the stable identity of each row. (constupdateconst update: {of updates; key update.id) { <li(property) li: GlobalAttributes & MarklessAttributes<HTMLLIElement> & NativeEventAttributes<HTMLLIElement>> <strong(property) strong: GlobalAttributes & MarklessAttributes<HTMLElement> & NativeEventAttributes<HTMLElement>>{update.project}</strong(property) strong: GlobalAttributes & MarklessAttributes<HTMLElement> & NativeEventAttributes<HTMLElement>> <span(property) span: GlobalAttributes & MarklessAttributes<HTMLSpanElement> & NativeEventAttributes<HTMLSpanElement>>{update.version}</span(property) span: GlobalAttributes & MarklessAttributes<HTMLSpanElement> & NativeEventAttributes<HTMLSpanElement>> </li(property) li: GlobalAttributes & MarklessAttributes<HTMLLIElement> & NativeEventAttributes<HTMLLIElement>> } @empty@empty · Empty list branchRenders when the list it belongs to has no items at all. { <li(property) li: GlobalAttributes & MarklessAttributes<HTMLLIElement> & NativeEventAttributes<HTMLLIElement>>No local updates</li(property) li: GlobalAttributes & MarklessAttributes<HTMLLIElement> & NativeEventAttributes<HTMLLIElement>> } </ul(property) ul: GlobalAttributes & MarklessAttributes<HTMLUListElement> & NativeEventAttributes<HTMLUListElement>>}
Why the sort was cheap
Reordering a keyed list moves existing rows. It does not rebuild them, and it does not run any
component function again: the rows already exist, keyed, and the work is moving DOM nodes into a new
order.
That is the same promise as the previous pages, kept at a different scale. Changing a number touched
one piece of text. Sorting a list touched the order of some rows. Neither one re-ran your code.
Coming from another framework?
If you have written keys before, you have probably been told they are a performance hint, and that using the index is a small sin you can get away with in a static list. The framing here is different and stricter. A key is the identity root for the row's own graph scope, which is to say it is what makes local state inside a row possible in the first place. That is also why the compiler refuses to guess: an interactive loop without a stable key is a build error rather than a warning in a console you may never open.
Try it yourself
Give each row its own counter with a button that increases it, tick nothing, and sort. The counts
follow their rows. Then change key row.id to key i, using an index i clause, and do it again.
Watching the counts stay behind while the labels move is the clearest demonstration of what a key
does that exists.