DOMGENERALFRAMEWORK-SPECIFICPLATFORM-SPECIFIC

Node Identity Across Updates

Focus, selection, caret position, scroll, animation progress and uncontrolled input values live on the node object — so whether an update reuses a node or replaces it is a visible product decision.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

When the data behind a list changes, how does the browser know that this row is still the same row?

The user intent

A person is typing in the third row of a table while it refreshes in the background. They expect their cursor, their text and their scroll position to stay exactly where they left them.

The obvious build

The data changed, so re-render the list from the new data. Same output, same result — the DOM ends up describing the new state either way.

Why it breaks

The caret jumps to the end of the field on every background refresh, because the input the user was typing in was replaced by an identical-looking new one.

How it breaks in a real browser
  • The caret jumps to the end of the field on every background refresh, because the input the user was typing in was replaced by an identical-looking new one.
  • A user checks the box on row three, the list re-sorts, and the checkmark is now on a different order — because the checkbox state lived on the node and the node did not move with the data.
  • A CSS transition restarts on every update. Transitions are state on the node; a replaced node begins its life at the starting value (Cheap and Expensive Animation).
  • A screen-reader user loses their place entirely: focus falls back to the document body, and the next Tab starts from the top of the page (Focus Management).
  • An in-progress file upload row is remounted, and the component's cleanup aborts the request it was in the middle of (Cancelling a Request Nobody Is Waiting For).
  • None of this appears in a diff of the rendered HTML. The markup before and after is identical; only the object identities changed.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The DOM has no concept of "the same logical item". Identity is object identity: this node object, at this address, with this state attached. Two nodes with identical markup are unrelated as far as the browser is concerned.
  • A large amount of user-visible state lives on the node and nowhere in your data: focus, text selection and caret offset, IME composition state, scroll position, uncontrolled form values, <details> open state, media playback position, running transitions and animations, and any custom element internals.
  • Removing and inserting a node destroys all of it. Moving a node — insertBefore on a node already in the tree — preserves all of it, because it is the same object.
  • Frameworks therefore need you to declare identity, because they cannot infer it. A key is a promise: "the item with this key is the same item as before, wherever it has moved to." Given keys, a reconciler can move nodes; without them it matches by position (Reconciliation and Keys).
  • Matching by position is not a bug — it is the correct default when there is no identity information. It only produces surprising results when the list actually reorders, filters or has items removed from anywhere but the end.
  • Identity is also why innerHTML replacement is so destructive: every node in the subtree is new, so every piece of node-local state is gone, even for the parts that did not change (What a Mutation Costs).

What this makes the browser do

And which of it is avoidable.

  • Destroying nodes: unlinking them, dropping their layout boxes, removing their accessibility nodes and notifying assistive technology of the removal.
  • Constructing replacements: allocating objects, computing style, generating boxes, building accessibility nodes, starting transitions from their initial values.
  • A move, by contrast, keeps the object and adjusts pointers. Layout still runs, because position changed, but style may be reusable and the accessibility node persists.
  • Avoidable: the entire destroy-and-recreate cycle for items whose content did not change, which on a long list is most of the frame (List Virtualization).

Match, move, or rebuild

An update is a matching problem: given the old set of nodes and the new list of items, which node corresponds to which item? With identity information the answer is a lookup, and unmatched nodes are removed while unmatched items become new nodes. Without it, the answer is "the one in the same position", which is right until something moves.

The diagram is the whole mechanism. Note what the two paths cost: on the keyed path a reorder is a set of moves, preserving every node object and everything attached to it. On the positional path a reorder is a content rewrite of every node from the first changed position onward.

The same reorder, two identity models
Old tree: A B CNew items: C A BKeyed: match by idUnkeyed: match by positionMove 3 nodesRewrite content of 3 nodesFocus, caret, scroll, animation preservedNode-local state now on the wrong item
UserLLMAgentToolDataDecisionHumanGuardrail
What the browser is asked to do
Replace the subtree
list.innerHTML = items.map(rowHtml).join('')
// every node in the list is new:
//   focus  -> document.body
//   caret  -> gone
//   scroll -> reset inside every row
//   CSS transitions -> restarted from initial
//   listeners -> destroyed with their nodes
Reconcile by identity
// match old node <-> new item by a stable id,
// then move / update / insert / remove:
//   unchanged rows      -> untouched, state intact
//   reordered rows      -> insertBefore, same objects
//   changed text        -> one text node updated
//   removed rows        -> removed, listeners collected

Every piece of state a user can perceive but not see in the markup — caret, focus, selection, scroll, transition progress — is attached to the node object. Preserving the object is the only way to preserve that state; there is no API to transfer it to a new node.

Choosing what to key by

A key is an assertion about the domain, not a syntactic requirement. The question it answers is "what makes two renders of this thing the same thing", and the answer comes from the data model rather than from the rendering code.

The test for a candidate key is two-part and both halves matter: is it unique across the list at any instant, and is it stable across the item's entire lifetime including edits, reorders and optimistic-to-confirmed transitions? A candidate that fails either test will produce this lesson's bugs.

What identifies a row?

What should this list be keyed by?

Server-assigned id

when The item exists server-side and its id never changes. The default correct answer for anything loaded from an API.

cost Rows created optimistically have no id yet, so you need a story for the window before the server responds (Optimistic UI).

Client-generated id at creation

when Rows exist in the UI before the server knows about them — drafts, queued uploads, offline mutations.

cost You must carry the client id alongside the server id after confirmation, or identity changes at the moment of the response and the node is replaced (The Offline Mutation Queue).

A natural key from the domain

when The domain genuinely has one that cannot change: an ISO country code, a currency, a locale.

cost Domains are less stable than they look. Anything a user can edit is not a natural key, however permanent it feels.

Array index

when The list is append-only, never filtered, never sorted, and its items hold no node-local state whatsoever.

cost The moment any of those four conditions changes — and one of them will — every row after the change point silently keeps the previous row's focus, caret and checkbox state.

A hash of the content

when Almost never, in a list. Reasonable for immutable rendered content that is either present or absent.

cost Editing an item changes its key, so an edit becomes a delete plus an insert — precisely the destroy-and-rebuild this lesson exists to avoid.

What the bug looks like from the outside

Almost none of these arrive as "the keys are wrong". They arrive as unreproducible complaints about the cursor, the checkboxes or the scroll position — because reproducing them needs an update to land at a specific moment relative to what the user is doing.

The diagnostic shortcut is to ask, for any of them: what state was destroyed, and did it live on a node? If the answer is yes, the question is which update replaced that node and why.

Identity bugs by how they get reported
TriggerSymptomCauseResponse
A background refresh lands while the user types"The cursor jumps to the end"The focused input node was replaced by an equivalent new oneKey the row stably, or lift the value into state you control (Form State Is a Draft).
Sorting or filtering an unkeyed list"I checked one box and a different one is ticked"Checkbox state lives on the node; positional matching moved the content and not the stateKey by item id so the reconciler moves nodes instead of rewriting them.
Any update to a list with entry animations"The animation replays every few seconds"Transitions are node state and start from the initial value on a new nodePreserve identity; animate on enter and exit explicitly rather than as a side effect of mounting.
A refetch after a mutation"I lose my place after saving"Focus fell to the document body when the focused node was destroyedPreserve identity across the refetch, and restore focus explicitly where you cannot (Focus Management).
An optimistic row confirmed by the serverThe new row flickers and its inline editor closesThe key changed from the client id to the server id, so the node was replacedKeep keying by the client id after confirmation, carrying the server id as data.
A keyed list under an unkeyed wrapperCorrect keys, and the whole list still remountsThe parent's identity changed, discarding every descendant regardless of their keysCheck identity at every level of the subtree, not only at the list (Drawing Component Boundaries).
Typing with an IMECharacters disappear mid-word, only in some localesComposition state on a replaced input is discardedPreserve identity, and test text entry with a non-Latin input method (Internationalization).

How to build it

Most important first.

  • Key lists by an identifier that belongs to the item and is stable for its lifetime. The server id is the usual right answer; a client-generated id at creation time is the right answer for rows that exist before the server knows about them (Optimistic UI).
  • Never key by array index in a list that can reorder, filter, or have items removed from anywhere but the end. Index keys tell the reconciler "position is identity", which is precisely the claim that is false (Reconciliation and Keys).
  • Never key by anything that changes when the item changes. A key derived from the content makes every edit a delete plus an insert, which is the failure this whole lesson is about.
  • Move state that must survive an update out of the node and into somewhere you own: controlled inputs, scroll position in state, and open/closed flags in your own model (Who Owns This State?).
  • When identity genuinely changes — a different user, a different document — make it change deliberately, so the reset is a decision rather than an accident.
  • Prefer moving nodes to replacing them, and prefer updating a text node to replacing its parent. The cheapest update is the one that changes the fewest object identities.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Focus is a property of a node object. Replace the focused node and focus falls to the document body, which for a keyboard user means the next Tab starts from the top of the page and for a screen-reader user means losing their reading position entirely (Keyboard Operability).
  • aria-activedescendant — the mechanism behind comboboxes and listboxes with a roving virtual cursor — references an option by id. If the option nodes are recreated with new ids on every keystroke, the reference dangles and the announcement stops (Accessible Component Patterns).
  • A live region announces its changes. Replacing the entire region rather than the changed row makes a screen reader re-announce the whole list, which is why identity preservation is what makes a results counter usable (Live Regions and Announcement).
  • IME composition — how a large share of the world types — is state on the input element. Replacing an input mid-composition discards partially composed text, and the failure is invisible to anyone testing in a Latin-script locale (Internationalization).
  • Text selection is node-scoped too. A user who selected an error message to copy it loses that selection when the message node is replaced by an identical one.

What can go wrong

Failure modes
  • Index keys plus a filter: removing an item shifts everything after it, and every subsequent row now shows different content in a node that kept the previous row's state.
  • Keys that are unique but not stable — a fresh UUID generated during render — which guarantees a full replacement on every single update, invisibly.
  • Keys that are stable but not unique, which most frameworks warn about at runtime and some silently mis-render.
  • Mounting a list under a container whose own identity changes — a wrapper keyed by the filter string, for instance — which discards every child regardless of how well the children are keyed.
  • Solving a caret-jump bug by making the input controlled and then fighting a different bug, where every keystroke round-trips through state and the field feels laggy on a slow device (Controlled vs Uncontrolled Inputs).
  • Assuming reuse means "nothing changed". A reused node with new content is correct for focus and wrong for anything that should reset — a scroll position inside the row, for instance.
What can arrive out of order
  • A background refetch arriving mid-keystroke is the canonical case: the response and the user's typing race, and identity decides whether the user notices (Stale-While-Revalidate).
  • An optimistic row created with a client id, then confirmed by the server with a different id, changes identity at the moment of confirmation. If the key switches, the node is replaced — mid-animation, and possibly mid-focus (Optimistic UI).
  • Two updates landing in the same frame reconcile against the intermediate tree, not the one you were looking at when you wrote the code (Out-of-Order Responses).
Security
  • Node reuse is a correctness property, not a security boundary. A reused node in a list that now shows a different user's data is a data-exposure bug caused by a stale key, not by the DOM (Authorization-Aware UI).
  • The important case: reusing a node across an identity change — a different account, a different tenant — can leave the previous occupant's uncontrolled input value, title attribute or data- payload in place. Change the key when the subject changes, so the subtree is genuinely rebuilt.
  • Keys derived from user-controlled values are not sanitised by being used as keys, but they do end up in framework internals and sometimes in data- attributes. Do not build markup out of them (Cross-Site Scripting).
  • A node kept alive purely to preserve identity retains everything under it, including data the user has since logged out of (Detached Nodes and What Keeps Them Alive).
Misreads
  • "Keys are a React thing." Every reconciler needs identity information; the syntax differs and the requirement does not (Reactivity Models).
  • "Keys are for performance." They are for correctness. Node-local state follows the node, and keys are how you say which node that is. The performance benefit is a consequence.
  • "Index keys are fine because my list never reorders." They are fine *until* it does — and adding a sort or a filter is a one-line change nobody will connect to a caret bug three sprints later.
  • "If the rendered HTML is identical, nothing happened." The markup is identical and every object identity changed, which is exactly the situation this lesson exists for.
  • "A unique key is a good key." A key that is unique per render and not stable per item is the worst of both: it never collides and it never matches.

Measuring it, and what changes in the field

How you would see this
  • Devtools highlights nodes as they change. A list where every row flashes on an update that changed one field is showing you a keying problem directly (Debugging Rendering and Jank).
  • Framework devtools name what remounted versus what re-rendered — the distinction the DOM cannot show you on its own (Debugging State).
  • A reproduction script: focus a field, trigger the background update, check whether document.activeElement is still the field. It is the fastest test that exists for this class of bug.
  • Field evidence usually arrives as "the cursor jumps" reports that nobody can reproduce locally, because locally the refresh never lands mid-keystroke (Real User Monitoring).
Slow device, slow network, large data, old tab
  • On a slow network, background refreshes land while the user is still typing far more often — the whole failure mode is a race between a response and a keystroke.
  • On a slow device, the destroy-and-recreate cost of unkeyed lists is what turns a "slightly wasteful" update into a visible stutter (Interaction Responsiveness).
  • With a long list, the cost of getting identity wrong scales linearly: every row is destroyed and rebuilt on every update.
  • With a virtualised list, nodes are deliberately recycled to show different data, so identity is managed explicitly and the usual rules invert (List Virtualization).
What this costs
  • Stable ids have to come from somewhere. For rows that do not exist server-side yet, you must generate and carry a client id — one more field, and one more thing to reconcile when the server assigns the real one (Rollback and Reconciliation).
  • Preserving identity preserves state you sometimes wanted to discard. A reused row keeps its scroll position and its expanded state, and "why did this stay open" is the mirror-image bug.
  • Controlled inputs make caret behaviour your problem rather than the browser's: you gain full control of the value and you now own cursor position, IME behaviour and every render on every keystroke (Form State Is a Draft).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThat node-local state belongs to the node object, and is destroyed when the node is, is a property of the DOM itself. Every framework, every templating library and every hand-written update loop is subject to it identically.
  • FRAMEWORK-SPECIFICHow identity is declared and what the default is differ. React and Vue take a key on list items and match by position without one; Svelte takes a keyed each block; Solid tracks by reference for arrays of objects; Angular uses a track expression. The consequence of getting it wrong is the same everywhere, but the warning you get — or do not get — is not (Choosing a Framework).
  • PLATFORM-SPECIFICIME composition and text-selection behaviour on node replacement differ by operating system and input method. A replacement that merely loses a caret position with a Latin keyboard can discard several characters of in-progress composition with a Japanese or Korean IME, so this must be tested on the platforms your users have.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Securityxss
Domains that do not exist yet
  • Software Design — a key is an identity assertion about a domain entity, and getting it wrong here is the same modelling error as an unstable primary key in a data model.
  • Testing & Reliability Engineering — the reproduction for every row of that failure table is "do something, then land an update while it is in progress", which is a timing test rather than an assertion about output.