Skip to content

Keys, Identity, and Reset Patterns

advanced12 min read

The Key That Unlocks React Behavior

Every confusing React bug you've ever encountered -- state appearing on the wrong list item, inputs losing focus, animations replaying when they shouldn't -- traces back to one thing: how React identifies elements across renders. Keys are the mechanism, and here's the thing: most developers only understand half of it.

// This has a bug. Can you see it?
function ChatList({ chats }) {
  return chats.map((chat, index) => (
    <ChatPreview
      key={index}  // The bug is right here
      chat={chat}
      unreadCount={chat.unread}
    />
  ));
}

When a new message pushes a chat to the top of the list, every ChatPreview after it receives the wrong chat's state. Animations replay. Unread badges show wrong counts. The user sees a different chat's draft message in their input.

The Mental Model

Mental Model

Think of keys as name tags at a conference. Without name tags, the conference organizer seats people by the order they arrive — person in seat 1 is "the first person," person in seat 2 is "the second person." If people swap seats, the organizer assumes the person in seat 1 changed their name and updates their badge.

With name tags (keys), the organizer tracks people by name. Alice is Alice regardless of which seat she's in. If Alice moves from seat 1 to seat 3, the organizer moves her name plate — they don't give seat 3 a new badge with Alice's name while leaving a confused stranger in seat 1 with Alice's old materials.

Keys are name tags. Without them, React tracks by position. With them, React tracks by identity.

How Keys Work in Reconciliation

During reconciliation, React matches old fibers to new elements. For lists, the matching strategy is:

  1. No keys (or all keys are index): Match by position. Element at index 0 maps to old fiber at index 0.
  2. With keys: Build a map of key → old fiber. Match new elements by looking up their key in the map.
// Without keys — position-based matching
// Old: [Alice, Bob, Charlie]
// New: [Bob, Charlie]  (Alice removed)

// React's perspective:
// Position 0: Alice → Bob (update Alice's fiber with Bob's props)
// Position 1: Bob → Charlie (update Bob's fiber with Charlie's props)
// Position 2: Charlie → nothing (delete Charlie's fiber)
// Result: 2 updates + 1 deletion = 3 DOM operations
// BUG: Alice's state (draft message, scroll position) now shows on Bob's item

// With keys — identity-based matching
// Old: [Alice(key=1), Bob(key=2), Charlie(key=3)]
// New: [Bob(key=2), Charlie(key=3)]

// React's perspective:
// key=1 (Alice): not in new list → delete
// key=2 (Bob): in both → update props (none changed → skip)
// key=3 (Charlie): in both → update props (none changed → skip)
// Result: 1 deletion. Bob and Charlie keep their state.
Execution Trace
Old list
[key:1 Alice, key:2 Bob, key:3 Charlie]
Three chat items with stable keys
Remove Alice
New list: [key:2 Bob, key:3 Charlie]
Alice removed from server response
Build map
oldMap: 1=AliceFiber, 2=BobFiber, 3=CharlieFiber
React maps old keys to fibers
Match Bob
key=2 found in map, reuse BobFiber
Bob keeps his state, scroll position, input draft
Match Charlie
key=3 found in map, reuse CharlieFiber
Charlie keeps his state too
Cleanup
key=1 remaining in map, delete AliceFiber
Only Alice is unmounted. One DOM removal

The Index-as-Key Bug in Detail

Here's a concrete demonstration of the index-as-key problem:

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Buy milk' },
    { id: 2, text: 'Walk dog' },
    { id: 3, text: 'Code' },
  ]);

  function removeFirst() {
    setTodos(todos.slice(1)); // Remove "Buy milk"
  }

  return (
    <>
      <button onClick={removeFirst}>Remove First</button>
      {todos.map((todo, index) => (
        <TodoItem key={index} todo={todo} />
      ))}
    </>
  );
}

function TodoItem({ todo }) {
  const [checked, setChecked] = useState(false);
  return (
    <label>
      <input type="checkbox" checked={checked} onChange={() => setChecked(!checked)} />
      {todo.text}
    </label>
  );
}

Scenario: User checks "Buy milk" (index 0). Then clicks "Remove First."

With key={index}:

  • Old: [{key:0, "Buy milk" ✓}, {key:1, "Walk dog"}, {key:2, "Code"}]
  • New: [{key:0, "Walk dog"}, {key:1, "Code"}]
  • React matches key 0 → key 0: updates text from "Buy milk" to "Walk dog" but keeps the checked state
  • Result: "Walk dog" appears checked. The user never checked it.

With key={todo.id}:

  • Old: [{key:1, "Buy milk" ✓}, {key:2, "Walk dog"}, {key:3, "Code"}]
  • New: [{key:2, "Walk dog"}, {key:3, "Code"}]
  • React: key 1 deleted, key 2 preserved (unchecked), key 3 preserved (unchecked)
  • Result: Correct. "Walk dog" is unchecked.

Key-as-Reset: Intentional Remounting

Here's where keys get really interesting. They aren't just for lists. You can use them on any component to force React to destroy and recreate it:

function UserProfile({ userId }) {
  // When userId changes, the entire form resets
  return <ProfileForm key={userId} userId={userId} />;
}

function ProfileForm({ userId }) {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  // State starts fresh for each userId because the key changed
  // React unmounts the old ProfileForm and mounts a new one

  useEffect(() => {
    fetchUser(userId).then(user => {
      setName(user.name);
      setEmail(user.email);
    });
  }, [userId]);

  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
    </form>
  );
}

Without key={userId}, switching from user 1 to user 2 would keep the form mounted. The old user's name and email would flash briefly before the effect fetches new data. With the key, React unmounts the old form entirely and mounts a fresh one — no stale data flash.

Tip

Key-as-reset is the idiomatic way to "reset a component." Don't use useEffect to reset state when a prop changes — that causes an extra render with stale state. A key change is cleaner: one unmount, one fresh mount.

Production Scenario: The Leaking Chat Input

A chat application has a message input per conversation:

function ChatWindow({ conversationId }) {
  return (
    <div>
      <MessageList conversationId={conversationId} />
      <MessageInput conversationId={conversationId} />
    </div>
  );
}

function MessageInput({ conversationId }) {
  const [draft, setDraft] = useState('');

  return (
    <input
      value={draft}
      onChange={e => setDraft(e.target.value)}
      placeholder="Type a message..."
    />
  );
}

Bug: User types "Hey Alice" in conversation with Alice. Switches to Bob's conversation. The input still shows "Hey Alice." The draft leaked between conversations because MessageInput was never unmounted — React saw the same component type at the same position and reused it.

Fix: Add a key to reset the input per conversation:

function ChatWindow({ conversationId }) {
  return (
    <div>
      <MessageList conversationId={conversationId} />
      <MessageInput key={conversationId} conversationId={conversationId} />
    </div>
  );
}

Now switching conversations unmounts the old MessageInput and mounts a fresh one with empty draft state.

Common Trap

Key-as-reset destroys all state in the component subtree, including child components. If your form has deeply nested components with their own state, all of it resets. This is usually what you want, but be aware — if you need to preserve some state across resets, lift it above the keyed component.

When Index-as-Key is Safe

Before you go adding IDs to everything, let's be fair: index-as-key is not always wrong. It's safe when ALL of these are true:

  1. The list is static — items are never reordered, inserted, or deleted
  2. Items have no state — no inputs, checkboxes, or component state
  3. Items are not animated — no transition state tied to item identity
// Safe: static navigation items, no state, no reordering
const navItems = ['Home', 'About', 'Contact'];
navItems.map((item, i) => <NavLink key={i}>{item}</NavLink>);

// Safe: static table headers
const headers = ['Name', 'Email', 'Role'];
headers.map((h, i) => <th key={i}>{h}</th>);

// NOT safe: any list where items can change
const [todos, setTodos] = useState(initialTodos);
todos.map((todo, i) => <TodoItem key={i} todo={todo} />); // Bug waiting to happen

If in doubt, use a stable unique identifier. The cost of a proper key is negligible, and the bugs from index-as-key are subtle and painful to debug.

Common Mistakes

Common Mistakes
  • Wrong: Using index as key for any list that changes (sort, filter, add, remove) Right: Use a stable unique ID from your data (database ID, UUID, or a computed stable hash)

  • Wrong: Generating random keys on each render: key={Math.random()} Right: Keys must be stable across renders. Generate IDs when data is created, not when it renders

  • Wrong: Using useEffect to reset state when a prop changes Right: Use key={prop} on the component to force a clean remount

  • Wrong: Omitting keys entirely on list items Right: Always provide keys on mapped elements. React warns in development for a reason

Challenge

Fix the key bug

Quiz

Quiz
What is the purpose of using key={userId} on a non-list component like <ProfileForm key={userId} />?
Quiz
Given a list rendered with key={index}, what happens when you insert an item at the beginning?

Key Rules

Key Rules
  1. 1Keys control component identity during reconciliation. Same key + same type = same instance (state preserved). Different key = new instance (state reset).
  2. 2Never use array index as key for lists that reorder, insert, or delete. State and uncontrolled inputs will transfer to wrong items.
  3. 3Use key={value} on non-list components to force a clean remount when value changes. This is the 'key as reset' pattern.
  4. 4Keys must be stable (same across renders), unique (among siblings), and derived from data (not generated during render).
  5. 5Index-as-key is safe only for static, stateless, non-animated lists. When in doubt, use a proper ID.
  6. 6Key-as-reset is cleaner than useEffect-based state resets: no flash of stale state, no extra render cycle.