Skip to content

    Frontend System Design

    Keys, Lists and Component Identity

    Learn how React uses position and keys to decide which component keeps its state, why index keys break reordered lists, and how a key resets a form on purpose.

    By Published 12 min read

    • frontend-system-design
    • react
    • keys
    • lists
    • component-identity
    Share

    Standard lessonintermediateReact labFrontend System Design

    Before this: React Reconciliation and the Virtual DOM

    What you'll learn: By the end of this lesson you will be able to explain that React keeps a component's state by its identity (its position in the tree, or its key among siblings), choose a correct key for a list, predict what an index key, a random key and a duplicate key do, use a key to reset state on purpose, and avoid the nested-component-definition trap that destroys state on every render.

    A coat check hands you a numbered ticket. You get your own coat back because the ticket belongs to the coat, not to the coat's place on the rail. Now imagine a clerk who ignores tickets and hands back "whatever hangs in the third slot". Everything works until someone takes a coat out and the rail shifts.

    React has the same choice to make on every render. Given the old tree and the new one, which new component is the same one as before, so that its state and its DOM can be kept? The answer is identity. Without help, React uses position. With a key, it uses the key.

    The running example is a shopping cart. Each line (Notebook, Pen, Lamp) has a gift note the shopper has typed into a text box, stored in the line component's own state. The shopper can reverse the order, remove the first line, or add a line at the top. The only thing that changes between a correct cart and a broken one is the key.

    The lesson uses React 19. At the time of writing, react.dev lists React 19.3 as the latest version, and the lab runs on 19.3.1.

    The core idea

    1. State belongs to a position in the render tree. React's docs say it keeps state for as long as you render the same component at the same position.
    2. A key replaces the position as identity. Among siblings, "keys tell React which array item each component corresponds to". They are unique among siblings only, not globally.
    3. Same identity means kept; different identity means destroyed. A component whose identity changes is unmounted and a new one is mounted: all its state, and the state of everything below it, is gone.

    Everything else in this lesson follows from these three statements.

    State lives at a position

    React docs show two identical <Counter /> tags at different positions holding independent state. Position means position in the UI tree, not in your JSX text. Three behaviours follow, and the lab checks each.

    • Same type at the same position keeps state. Changing a prop on <Counter> inside the same <div> keeps the count.
    • A different type at the same position resets the subtree. Wrapping the counter in a <div> on one render and a <section> on the next destroys the state, because the parent changed type.
    • A key separates things that share a position. Two <Counter>s for different people, rendered in turn at one spot, share one count unless each gets key={person}.

    Figure 1. Identity decides what survives a render

    flowchart TD accTitle: How React decides to keep or reset a component accDescr: React compares the new element with the old one at the same place. If the type and the key are both the same, state is kept. If either differs, the old component is unmounted and a new one is mounted with fresh state. A["New render: element at a place"] --> B{"Same type
    as before?"} B -->|"no"| X["Unmount old, mount new:
    state reset"] B -->|"yes"| C{"Same key
    (or no key, same position)?"} C -->|"no"| X C -->|"yes"| K["Keep the component:
    state and DOM kept, props updated"]

    How to read this: the question is always "is this the same component as last time". Props do not enter into it, which is why changing a prop never resets state but changing a key always does.

    The trap: components defined inside components

    function Cart() {
      const [clicks, setClicks] = useState(0);
    
      function NoteField() {            // a NEW function, so a new component type, every render
        const [text, setText] = useState("");
        return <input value={text} onChange={(e) => setText(e.target.value)} />;
      }
    
      return (<>
        <NoteField />
        <button onClick={() => setClicks(clicks + 1)}>Clicked {clicks}</button>
      </>);
    }

    Each render of Cart creates a different NoteField function, so React sees a different type at the same position and resets it. React's docs say to declare component functions at the top level. In the lab, typing "abc" works (typing re-renders only the field), but clicking the button re-renders Cart and empties the input. The hoisted version keeps "abc".

    Keys in lists

    Every element rendered directly from a map() needs a key, a string or number that is unique among its siblings. Where keys come from, per React's docs:

    • Data from a database: use its IDs, which are unique by nature.
    • Locally created data: use a counter or crypto.randomUUID() when the item is created, not when it is rendered.

    The docs give two rules: keys must be unique among siblings, and they must not change ("don't generate them while rendering"). Two more facts belong here. A key is not a prop: your component does not receive it, so pass the ID separately if the component needs it. And the short <>...</> syntax cannot take a key, so a list item that renders several elements uses <Fragment key={id}>.

    Figure 2. Choosing a key

    flowchart TD accTitle: Choosing a key for a list item accDescr: If the data has a stable ID, use it. If the items are created locally, assign an ID when each is created. Use the index only for a static list that never reorders, inserts or deletes. Never generate a key during render. Q{"Does each item have
    a stable ID?"} -->|"yes"| A["key = that ID"] Q -->|"no"| R{"Is the list static:
    no reorder, insert, delete?"} R -->|"yes"| I["Index is acceptable"] R -->|"no"| N["Assign an ID when the
    item is created, then use it"]

    How to read this: the key has to be a property of the item, fixed for its lifetime. The array position is a property of the list at one moment.

    Illustration 1 (minimal): a key separates two people

    <Counter key={person} label={person} />

    Without the key, switching person from "Taylor" to "Sarah" updates the same counter, so Sarah starts at Taylor's count. In the lab, after one click on "Taylor", the unkeyed version shows "Sarah: 1" and the keyed version shows "Sarah: 0". The key made React treat them as two components.

    Illustration 2 (realistic): the cart with index keys and id keys

    Each CartLine holds its own gift note. The list is rendered twice with the only difference being the key: key={index} against key={item.id}.

    Figure 3. Reversing the cart, matched by index

    flowchart LR accTitle: Reversing a list when keys are indexes accDescr: Before, key 0 is the Notebook with the note for Sam, key 1 the Pen, key 2 the Lamp. After reversing, key 0 is the Lamp. React keeps key 0, so the note for Sam now sits on the Lamp. subgraph B["Before"] B0["key 0: Notebook
    note: for Sam"] B1["key 1: Pen"] B2["key 2: Lamp"] end subgraph A["After reverse"] A0["key 0: Lamp
    note: for Sam"] A1["key 1: Pen"] A2["key 2: Notebook"] end B0 --> A0 B1 --> A1 B2 --> A2

    How to read this: React matches by key, and index keys match the first row to the first row. The data moved, but the state stayed in its slot.

    Figure 4. The same reversal, matched by id

    flowchart LR accTitle: Reversing a list when keys are item ids accDescr: Before, id 1 is the Notebook with the note for Sam, id 2 the Pen, id 3 the Lamp. After reversing, id 1 is last. React matches by id, so the note for Sam stays with the Notebook. subgraph B["Before"] B1["id 1: Notebook
    note: for Sam"] B2["id 2: Pen"] B3["id 3: Lamp"] end subgraph A["After reverse"] A3["id 3: Lamp"] A2["id 2: Pen"] A1["id 1: Notebook
    note: for Sam"] end B1 --> A1 B2 --> A2 B3 --> A3

    How to read this: the arrows now cross. React finds each component by its key and moves it, state included.

    The lab runs the three operations on both versions, in jsdom with React 19.3.1, after typing "for Sam" into the Notebook's note:

    Operation Index keys Id keys
    Reverse The Lamp shows "for Sam"; the Notebook's note is empty The Notebook keeps "for Sam"
    Remove first line The Pen shows "for Sam"; the last line component is the one destroyed The Notebook's line is destroyed with its note; the Pen is empty
    Add at top The new item shows "for Sam"; the Notebook is empty The Notebook keeps its note; the new item is empty

    Nothing throws and nothing warns. The user sees a gift message on the wrong product. The lab also checks the DOM: after a reversal with id keys, the <li> element for the Notebook is the same object as before, now last; with index keys the first <li> stays first and its content is rewritten. That describes jsdom in this version and illustrates "key follows the item"; it is not a statement about performance.

    Illustration 3 (failure): random and duplicate keys

    key={Math.random()} looks like a unique key, but React's docs warn that keys must not change between renders. Every render of the cart creates new identities, so every line is unmounted and rebuilt. In the lab, typing in a note works (the line re-renders, not the cart), but clicking Reverse empties it, and the mount counter goes from 3 to 6 with 3 unmounts. The shopper loses their typing every time something else re-renders the list.

    Duplicate keys break the "unique among siblings" rule. In the lab's development build, React logs a warning that contains "same key". The wording is not documented on the pages cited here, so do not depend on it. The docs simply require keys to be unique among siblings, and the lab tests nothing beyond the warning.

    Resetting state on purpose with a key

    The same mechanism is a feature. React's docs recommend a key, not an Effect, to reset a component's state when a prop identifies something new.

    <MessageDraft key={userId} userId={userId} />

    Figure 5. Switching sellers with and without a key

    sequenceDiagram accTitle: Switching the seller a message is addressed to accDescr: The shopper types a draft for seller 1 and then selects seller 2. With key equal to the seller, React unmounts the old form and mounts a new empty one. Without a key, the same form stays and the draft follows the shopper to seller 2. participant S as Shopper participant R as React participant F as MessageDraft S->>F: types "Is it in stock?" (seller 1) S->>R: selects seller 2 alt key = userId R->>F: unmount the old form R->>F: mount a new form (empty draft) else no key R->>F: same form, new prop Note over F: draft still says "Is it in stock?" end

    How to read this: the draft is state of the form, so it survives only if React keeps the same form. The key says "a different seller is a different form".

    In the lab, without the key the draft "Is it in stock?" is still in the box addressed to seller 2; with key={userId} it is empty. The docs also warn about the cost of a reset: the old state is gone. If a user should get a draft back when returning to a seller, lift the state up into a parent, keep it in a map by ID, or persist it. A key resets; it does not remember.

    Choice Keeps state when order changes Resets when the item changes Main risk
    Index key No: state stays in the slot No Wrong item shows old state
    Stable ID key Yes Yes (new ID means new component) IDs must really be stable and unique
    Random key No: every render resets Always Lost input, wasted work
    No key (single element) Yes, by position Only if type or parent changes Surprising sharing, as with Taylor and Sarah

    Build it: identity under test

    Goal. See state follow a key and not a position. The lab is examples/frontend/labs/react-keys-identity: React 19.3.1 with Testing Library under jsdom, which is not a browser. It shows DOM content and mount counts, not speed or layout.

    cd examples/frontend
    pnpm install
    pnpm --filter ./labs/react-keys-identity test   # 19 tests

    Steps.

    1. Read Cart and keyFor in src/components.tsx. One function picks the key by mode: index, id or random.
    2. In test/keys.test.tsx, compare "id keys: the note follows the item when the list is reversed" with "index keys: the note stays at its position and lands on a different item (BUG)". Same operation, one changed key, opposite results.
    3. Read the Lifecycle assertions. With index keys, removing the first line reports one unmount, but it was the last line component that was destroyed.
    4. Find "random keys". It types first, then clicks Reverse, because typing alone does not re-render the cart. That detail came from a failing first draft of the test: it assumed typing would reset the input and React did not.

    Real output from this run (Vitest 5.1.0):

    Test Files  1 passed (1)
         Tests  19 passed (19)

    There is no demo or golden file because Node cannot run .tsx; the tests assert behaviour.

    Break it. In keyFor, change if (mode === "id") return item.id; to return index;. The id-key tests fail with the bugs the index-key tests describe. Revert.

    Stretch. Add a "Sort by name" button and show index keys corrupt notes there too. Keep a draft per seller by lifting the draft into a map keyed by userId.

    In practice: gift notes on the wrong product

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    A store's cart lets shoppers type a gift note per line. A release adds "Sort by price". Support starts receiving orders where the note for one product appears on another. It cannot be reproduced on a cart with one line, and QA's carts had no notes.

    Starting architecture

    Figure 6. The cart as built

    Starting architecture: lines keyed by array index hold their own note stateSort control to Cart (new order); Cart to Cart lines (props by slot)REACT TREESort control: reorders the arraySort controlreorders thearrayCart: map with key=indexCartmap withkey=indexCart lines: own note stateCart linesown note statenew orderprops by slot

    How to read this: the sort changes which data goes to which slot, but index keys keep each slot's component, so a slot's note stays in the slot.

    Options considered

    Option What it fixes What it costs Risk
    Key by item ID Notes follow items One-line change IDs must be stable
    Lift notes into cart state Notes follow items, survive remounts Refactor of line components More props and handlers
    Reset notes on sort Hides the symptom Shoppers lose typing Wrong behaviour kept

    The decision

    Field Decision record
    Context Index keys attach line state to slots, so sort, add and remove move notes between items.
    Decision Key lines by the cart-line ID the server assigns. Add a lint rule or review item against index keys in dynamic lists.
    Consequences, good Notes stay with items through every reorder.
    Consequences, bad Lines that have no server ID yet need one assigned when created.
    Revisit when Notes must survive leaving the cart. Then lift them into shared state.

    Rollout and verification

    1. Write the failing test: type a note, reverse the list, assert the note moved with the item.
    2. Change the key and watch it pass.
    3. Check the other operations: remove first, add at top, and a keyboard-only pass.
    4. Search for other key={index} uses in lists that reorder.

    The 2 a.m. failure

    After the fix, a new "Add to cart" flow assigns the line ID with crypto.randomUUID() inside the render of the cart, so every render produces fresh IDs. Shoppers lose half-typed notes whenever anything re-renders the cart. The cause is the rule that keys must not be generated while rendering.

    First three checks: (1) look for key expressions calling randomUUID or Math.random in render; (2) watch whether input loses its value when an unrelated state changes; (3) compare mount counts in a test. Mitigation: assign the ID once, in the event handler that adds the line. Follow-up: add a test that re-renders the cart and checks the notes are intact.

    Architect's notes

    • Give every list item an ID at creation. Retrofitting is harder than assigning IDs up front.
    • Decide what a key should reset. Resetting a form by key is cheaper than writing reset logic, but it discards state.
    • Keep list rows small and their state minimal. State that must survive reordering probably belongs in the parent.
    • Review key={index} as you review any. It is sometimes right, and always worth a sentence.

    Common mistakes

    • Using the index as a key for lists that reorder, insert or delete.
    • Generating keys in render with Math.random() or randomUUID().
    • Defining a component inside another component.
    • Expecting props.key. A key is not a prop.
    • Using <> in a list that needs a key. Use <Fragment key={id}>.
    • Resetting state with an Effect when a key does it.

    Key takeaways

    • State belongs to a position in the render tree, and a key is the identity among siblings.
    • Same type and same key (or position) keeps state; a different one unmounts and remounts.
    • Use a stable, unique ID from the data, or one assigned when the item is created.
    • Index keys attach state to slots: reorder, insert and delete move state between items.
    • Random or render-time keys destroy state on every render.
    • A key is not a prop, and keys are unique among siblings only.
    • A key resets state on purpose; it does not remember it.
    • Never define a component inside another component's body.

    Practice exercises

    Exercise 1 — Starter (30 min): Run the lab. Predict, then check, what "Add at top" does with index keys, and draw the figure for it.

    Exercise 2 — Intermediate (2 hours): Add a "Sort by name" button and a quantity stepper per line. Show with tests that index keys break both, and fix them.

    Exercise 3 — Advanced (half day): Implement "keep a draft per seller" by lifting state into a map keyed by userId while keeping key={userId} on the form, and test switching sellers back and forth.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments