Chapter 6 of 7 · 13 min read
Stable keys, monotonic updates, and no flicker
On this page
A reconciler — the part of a UI framework that compares the previous render with the next one and decides which elements to keep, which to move, and which to destroy and rebuild — works under the same contract a database has with a primary key: it can only tell you two renders are the same thing if you give it something that names the thing. A streamed list breaks that contract in a way a static list does not, because every snapshot from an incremental parser is a new array, often in a new order, and the reconciler has to decide what moved and what is new.
This chapter is about the three mechanisms that keep a progressive render legible while it is still filling: where identity comes from, what an update is allowed to do to what the reader has already read, and how the layout stops moving. All three live inside the provisional phase — they govern how slots fill, not whether the whole render can later be withdrawn.
Identity has to be assigned by the producer
An array index names a position, so keying by index tells the reconciler that whatever is in slot 2 now is the same entity as whatever was in slot 2 before, which is true only while the list is append-only and stays in one order. Every insertion into the middle, every re-sort, every filter change breaks that claim silently, and the visible symptom is not an exception but a row that briefly wears another row's label, another row's expanded state, another row's animation.
The step from a key to a misplaced checkbox is the one usually skipped, and it is
three links long. The reconciler is handed the previous list of keys and the next
one, and the only question it asks is which keys appear in both. A key present in
both means this is the same element, so the framework keeps the existing DOM
node, keeps the component instance attached to it, and updates the props. A key
present only in the new list means this is a new element, so it mounts a fresh
instance. Put those together with where component state lives: a checkbox, an
expanded toggle, an animation's progress and a text cursor all belong to the
instance, and the instance belongs to the key. State therefore follows the key
and not the data. Under key={i} the key is the position, so the instance
sitting at position two keeps its checkbox while the data underneath it is
replaced by whatever sorted into position two on this frame. Nothing is
corrupted, nothing throws, and the framework has done exactly what the key
instructed it to do.
The fix is a key that comes from the item rather than from its surroundings, and the options differ in whether they are available at the moment you first need them.
| Keying strategy | Stable under insertion | Available before the item closes | Survives a retry | Cost |
|---|---|---|---|---|
| Array index in display order | no | yes | n/a | zero, and wrong |
Model-emitted id field | yes | only after that field closes | yes, if the model repeats it | ~5 output tokens per item, plus a schema property |
| Arrival ordinal in the accumulating array | yes | yes, from the first byte of the item | no — a new attempt renumbers | zero |
| Hash of the item's content | no | no | yes | changes on every delta |
The content hash row is the tempting wrong answer, because hashing feels like the canonical way to derive identity from data. A streaming item's content changes on every chunk by construction, so its hash changes on every chunk, and keying by it tells the reconciler that every item was destroyed and replaced between frames — which unmounts the row, loses its state, and restarts its entry animation sixty times a second.
Hashing deserves a defence before it is dismissed, because it is a recommended technique elsewhere and the instinct behind it is sound. For an immutable list — rows that arrive complete and never change afterwards — a content hash is a genuinely good key. It survives reordering, it needs no cooperation from whoever produced the items, and two rows with identical content are interchangeable in any case. Every one of those properties rests on the content being final. A streaming item's content is by construction not final, so its hash is a value that changes on a schedule the network sets. Keying on it does not hand the reconciler bad identities. It tells the reconciler, correctly by the rule it was given, that every row was destroyed and a different one created in its place between one frame and the next.
Arrival ordinal is the one to reach for first, because it exists before any field
of the item has closed and it is genuinely stable: item 3 in the accumulating
array is item 3 for the rest of that stream, regardless of where you decide to
display it. What it does not survive is a retry behind the provisional marker,
and the previous chapter already settled that a retry clears the render rather
than merging into it, so the ordinals of the abandoned attempt never meet the
ordinals of the new one. Combining the attempt id with the ordinal —
`${attemptId}:${i}` — makes that explicit and costs nothing.
A model-emitted id is worth adding to the schema when the items outlive the
stream: when the user's selection is persisted, when the row links to a record,
when a later request refers back to a specific item. Asking for it is a schema
property like any other, and with schema-constrained generation the model will
emit it in declared order, so putting id first in the item's property list
means the identity closes before any of the content does.
// Before: position is identity, and position is a function of everything else.
{results.sort(byScore).map((r, i) => <Row key={i} item={r} />)}
// After: identity is assigned when the item first appears and never reassigned.
{results.map((r, i) => ({ ...r, key: r.id ?? `${attemptId}:${i}` }))
.sort(byScore)
.map((r) => <Row key={r.key} item={r} />)}The second version still re-sorts, and the sorting is now a purely visual movement of rows that keep their identity, their checkbox and their scroll anchoring. That is a large improvement and it is not the whole fix, because a row that moves under a pointer that is already travelling toward it will still receive a click meant for its neighbour.
Only add information
Sorting during the stream is a special case of a general property worth enforcing directly: within the provisional phase, each update should only ever add information to what is already on screen. A field that was present stays present, a string that was rendered only ever grows at its end, an item that appeared does not disappear, and a slot that showed a value does not go back to showing a skeleton, the grey placeholder block that stands in for a value which has not arrived. Users read while the stream runs, and a render that removes or rewrites something they have already read costs them a re-read at best and misleads them at worst.
The snapshot logic from earlier in this topic gets you most of the way there,
because a repaired snapshot is a faithful prefix of the document, and prefixes
grow. Regressions still get in from four directions. The repair rules drop
unterminated numbers and partial literals, so a permissive parser configured to
keep them will show 12 and then 1200, which is a value replacement rather
than an extension. Snapshots produced asynchronously — decoded off the main
thread, or posted from a worker — can arrive out of order, so a stale snapshot
applied after a fresher one erases fields. A re-sort is a reordering, which is
information removal in the reader's terms even if no bytes were lost. And a retry
substitutes a whole new generation, which is legitimate precisely because it is
not an update to the existing one.
The third of those four is the one people argue with, so it is worth being exact about what a re-sort removes. Nothing in the data disappears: every item is still present, carrying every field it had a moment earlier. What disappears is the reader's index into it. Somebody who has located a row by its position — and anybody pointing at a row has located it by position, whatever they believe they are doing — is holding a fact that the next frame silently invalidates. Bytes are not the only thing a render can take away, and a reader's place in a list is the one they will not notice losing until they have already acted on the wrong row.
Making the property checkable is cheap, and it converts a class of bug that shows up as user confusion into a class that shows up in tests:
function mergeMonotonic<T extends object>(prev: Partial<T>, next: Partial<T>): Partial<T> {
for (const key of Object.keys(prev) as (keyof T)[]) {
const a = prev[key], b = next[key];
if (b === undefined) throw new Regression(`${String(key)} disappeared`);
if (typeof a === "string" && typeof b === "string" && !b.startsWith(a))
throw new Regression(`${String(key)} rewritten: ${a!.slice(-12)} -> ${b.slice(-12)}`);
if (Array.isArray(a) && Array.isArray(b) && b.length < a.length)
throw new Regression(`${String(key)} shrank ${a.length} -> ${b.length}`);
}
return next;
}Run that as an assertion in development and as a counter in production, wired to drop the offending snapshot rather than to throw at a user. Feeding it the boundary-fuzzing test built earlier in this topic — the same payload sliced at every possible cut point, each cut producing a snapshot sequence — turns it into a property test: for every slicing of the document, the sequence of snapshots is monotonic and the final one equals the parsed document.
Reserving the space before the value exists
The other half of flicker is layout. A field that appears when it arrives pushes everything below it down, and a panel with six such fields moves its own content four or five times during a three-second stream, which is why a pointer and the row it is travelling toward end up disagreeing about where the row is. The cure is to draw the shape of the object before its values exist, from the schema, which you have.
Every declared property gets a slot at mount: a label, a fixed area sized for the
value it will hold, and a pending treatment inside it. The size comes from what
the schema tells you — an enum badge is as wide as its longest member, a currency
amount is as wide as its formatted maximum, a summary described as two to four
sentences gets three lines of skeleton at the panel's line height. When the value
arrives it replaces the skeleton inside a box that does not change size, so the
reflow is zero and the eye stays where it was. Where the shape is genuinely
unknowable, such as an array whose length is not bounded by the schema, reserve
the container and let it grow downward only, with the growth below anything
interactive.
This is also where the render gates become visible in the layout. A field held back by a semantic gate has a slot that is deliberately empty for longer than the data required, and the pending treatment in that slot should not look like a network wait. Labelling it waiting on red flags rather than showing an animated shimmer tells the user that the system is holding something back on purpose, which is the truthful description of what is happening and is a better answer to "why is that box still empty" than a spinner.
The last ingredient is update frequency. Taking a snapshot every 50 to 100
milliseconds rather than every chunk was a CPU argument earlier in this topic,
and perception argues for the same number from the other side. Text that updates
at 60 hertz reads as vibration rather than as writing; the same text updated at
10 to 20 hertz reads as typing, and no information is lost because a human cannot
read faster than the words arrive. Coalescing also removes a whole family of
intermediate states — the field that was briefly Refun, the array that briefly
had one element — by never rendering them.
Those two framings are one number read from either end, and converting between them is a reciprocal. An update every 50 to 100 milliseconds is between ten and twenty updates a second, because a rate is one divided by an interval. Sixty updates a second is one every sixteen or so milliseconds, which is below the interval at which the eye resolves changes as separate events, so consecutive frames blend into a shimmer instead of reading as writing. Slowing to ten or twenty puts enough distance between updates that each one reads as an event, which is what makes the same text look like typing. Nothing is lost in the slowing, because the words are not arriving faster than that in the first place and a coalesced update carries every character that landed during its interval.
Monotonicity does not govern the phase transitions
Monotonicity governs the inside of the provisional phase and says nothing about
the phase transitions from the previous chapter. Withdrawal removes everything,
which violates every rule in this chapter and is correct, because it is a
transition of the whole render rather than an update to a slot. Keeping the two
mechanisms distinct in the code — a merge function that refuses to lose
information, and a reducer that can move the whole state to withdrawn — is what
prevents a well-meaning implementation from treating withdrawal as an update and
finding it blocked by its own invariant.
It is also not a claim that the model's early output was right. A ranked list is a genuine problem for this: if the model emits candidates in the order it considers them and the ranking is a property of the finished set, then any order you display before the array closes is a claim you will have to revise. Stable keys make the revision smooth, and smooth is not the same as honest. The gate rule applies to the aggregate rather than the elements — display rank, "top match", and any ordering that carries meaning only after the closing bracket, and show items in arrival order until then, which is monotonic by construction.
Identity, monotonicity and reserved space are three answers to one question: what does a frame of a half-arrived object claim, and does the next frame contradict it. A row keyed by position claims that slot 2 is a person. An update that reorders claims that what the reader just read was provisional in a way the marker never said. A field that appears and pushes the page down claims nothing at all and still costs the reader their place. None of the three is expensive to get right in advance, and all three are expensive to retrofit, because by then component state is keyed on the assumptions.
Nothing here is free, and the bill is the reason this topic ends where it does. Stable keys, a monotonic merge with assertions, reserved slots derived from the schema, coalesced updates, and the whole provisional state machine are a few hundred lines of client that exist because a human being is watching bytes arrive. When nobody is watching, all of it is cost with no counterpart, which makes the first question about any stream a question about its consumer.
Streaming structured output — read the current version at https://daviddominguez.dev/learn/en/streaming-structured-output/stable-keys-monotonic-updates-and-no-flicker