Skip to content
AtomicReps

Where your state actually lives

A lesson from How React Works. Play it above, or read it through below.

Level I · The Rendering Model
  • Two branches at the same position keep the same state

    The common read: React renders the branch I wrote, so switching to the other branch of the conditional gives that component new state.

    React never reads your conditional, whether it is switching a counter or the box you just made a prediction about. It reads the tree your function returns: two return statements that both put <Counter /> first inside a div are one address, whatever the two blocks look like in your editor. The parent, the slot and the component are the same, so React keeps the node and only its props change.

    Move the branch further up or further down the function and the kept state

    Takeaway: At the same address React keeps the state. That is correct for a tab and wrong for two different records.

    In the second case the bug is a value you expected to be cleared: a draft written for pull request 41 is still in the box under pull request 42, and the bug is filed as "the comment box is not clearing" against a component that never had a reset in it.

  • Two guards at two positions reset both counters on every switch

    The common read: Only one of these two counters is ever on screen, so they take turns in one slot and share the score between them.

    Write the switch as two && guards, with one counter visible at a time.

    Flip isPlayerA and the other counter appears: it comes up

    Rendering a component from two places gives it two addresses, and two addresses mean two independent pieces of state whether both are visible at once or not. A pair of && guards renders two components, and each is removed when the other appears.

    Takeaway: Removal is what destroys state, so a switch built from two guards resets in both directions. No state carries across.

    The docs make the same point with one element assigned to a variable and rendered twice: two counters that share nothing, because the state is held by the two positions the element was rendered at, not by the element. A review comment that says "just extract it into a variable and render it in both branches" does not share any state, because putting an element in a variable does not change where it is rendered.

    The bug report that follows says the filter panel forgets its selection every time the sidebar opens. The cause is that the sidebar branch renders the panel second inside the div, while the other branch renders it first, so the panel is at a different position in each branch.

  • Inside an array, the address is the key, and no key means the index.

    The common read: the two spellings return the same markup, so React is handed the same tree either way and the editor below the banner keeps what the user typed.

    Those two spellings did not give React the same tree. Team two put the children into an array and added a new first entry, so the editor's address went from index 0 to index 1. Team one's editor stayed the second child of the same div on both passes and never moved.

    Omit a key on an array child and you have

    With the index standing in for the key, every edit to the array changes which item each address refers to, because the item that used to be entry 0 is not what the address "index 0" means anymore:

    an insert adds one to the index of every later item a delete subtracts one from the index of every later item a sort puts a different item at almost every index

    Takeaway: A screenshot does not show the mismatch: the text is in a box that looks correct, attached to the wrong record.

    The eventual bug report says a colleague's note showed up on the wrong customer, and it goes to whoever owns the customer list rather than to whoever added the sort.

  • An index key is the position under another name.

    The common read: an index is unique inside this render, so key={i} satisfies the contract, and it only goes wrong for a list I actually sort.

    Compare key={i} with leaving the key out.

    Expand Bob in an index-keyed contact list, then delete Alice above him: the detail panel

    Writing key={i} adds nothing: without a key, React already uses the index. A list keyed by index behaves like a list with no keys, except that the warning is silenced, and the key identifies whichever item is at that position, not a particular item.

    Takeaway: The failure is not limited to sorting. Deleting the first row moves every later row to the index one lower, and the open panel, the checked box and the half-typed reply all stay with the slot instead of the record.

    The bug is reported as a data-integrity bug, filed against the row component or the endpoint that served it, in a sprint whose only change was a Delete button, and nobody checks the keys.

    The opposite mistake is worse, and people make it when the warning does not go away: a key generated during render, such as key={Math.random()}, never matches anything from the previous pass. Every row is destroyed and rebuilt on every parent render, including renders that have nothing to do with the list. A key that joins the current sort setting to the id, such as sort + "-" + c.id, destroys and rebuilds every row each time the sort setting changes.

    Takeaway: Use a stable id from the data. If the data has no id, generate one when the item is created, never during render.

  • A removed component's state is destroyed, not paused

    The common read: conditionally not rendering a component takes it off the screen, so its state is stored somewhere and comes back the way I left it.

    The counter is removed from the screen and later rendered again.

    Swap the counter out for a paragraph, then swap it back: the score that read 2 now reads

    React keeps nothing for a removed component: its state is destroyed the moment it stops being rendered, and rendering it again later mounts a new one from scratch.

    Takeaway: The same rule has a less obvious consequence: a different component type at one position resets the whole subtree beneath it, not just the node you swapped. Changing a wrapper's type three levels up, for example a div to a section, empties a form nobody touched.

    The belief that a removed component keeps its state somewhere matters because code review does not catch it. For example, a user fills in six fields, opens the help drawer that unmounts the form behind it, closes the drawer, and gets six empty fields back, and the complaint is filed against the drawer.

  • Changing a key resets the state below it, in one line

    The common read: a key clears the state that went stale, so writing one on the thing that shows the stale value clears that thing and leaves everything else alone.

    The key does not belong on the thing showing the stale value. It belongs on the component that owns that value, and it resets everything stored under that component. <Chat key={to.id} contact={to} /> says a chat with Alice and a chat with Bob are two different things drawn in the same place, so every piece of state under that tree resets on the switch, with no list to maintain anywhere.

    Keys look like a list feature because lists are where you first use them, but they are

    Takeaway: The key marks the top of what gets destroyed. Nothing above it or beside it is reset: put it one node lower and you have keyed something the value was never stored under; put it one node higher and you also reset a sibling's state.

    The figure's useEffect version does the same job by hand: it clears only the variables you remembered to list, renders once with the stale values first, and needs a new line in another component's file every time somebody adds state below the component that runs the Effect. The bug the key version prevents is a message sent to the wrong person: a half-typed message stays in the box after the recipient changes, and someone sends it, assuming the box belongs to the conversation on screen.

  • A changed key re-creates the DOM, not only the state.

    The common read: the only thing a key affects is React state, so putting one on a plain img with no state in it changes nothing.

    A different key at a position is a different node, and a different node is a different DOM element: React builds a fresh one instead of writing new attributes onto the old one. The gallery patch works this way, and it is why a key is useful on elements holding no React state at all. Anything the browser keeps on the element rather than in your state is lost with it:

    the decoded image it is still displaying the text typed into an uncontrolled input the scroll offset inside it

    Takeaway: Re-creating is neither free nor selective. A key on a wrapper high in the tree removes every DOM node beneath it, unmounts every component under it, and re-runs every Effect's cleanup and setup.

    That cost is acceptable for switching records and wrong for a filter that changes twice a second. The second case is the common failure: a performance bug report about typing lag in a search box, filed against the list, in a week when nobody changed the list.

  • Pick the address, or move the value out of the component.

    The common read: a key is the tool for resetting state, so anywhere state is surviving something it should not, the answer is to find a value to key on.

    A key is one of four tools for state that is handled wrongly when a component switches records or unmounts. The first two reset state that was kept when it should have been reset. The last two keep a value that was reset when it should have been kept.

    Two positions. Render the two things at two different positions when you can say so in the JSX. It is the cheapest option, and the one the docs put first: a component that's removed loses its state without anybody writing a key. An explicit key. Give the subtree an explicit key when the two things share one slot and you can't split it: a chat per recipient, a form per row, a profile per user. Lift the value. When the state should have been kept, stop resetting it and move it: hold the pending message for every recipient in the parent, so the child unmounting stops mattering. Storage outside React. A draft the user would not want to lose should be kept when the tab closes, so store it in localStorage and have the component read it from there at mount.

    Takeaway: Choose by asking which state should be kept, not which state is being kept now.

    The lift-the-value case usually starts as a user complaint: switching conversations deletes what was typed, and the bug is filed against the recipient picker, the one component in the diff that never held the draft.