DEV Community

Munna Thakur
Munna Thakur

Posted on

The Day I Finally Understood React Keys (And Why Your Checkbox Keeps Breaking)

I've been writing React for almost two years now. Built features, shipped products, debugged performance issues. I thought I knew what I was doing.

Then last month, a user reported a weird bug: "The checkbox keeps unchecking itself when I add items to the list."

I stared at the code for 30 minutes. Everything looked fine. The state was correct. The component logic was simple. What could possibly be wrong?

Then I saw it.

{items.map(item => <TodoRow todo={item} />)}
Enter fullscreen mode Exit fullscreen mode

No key prop.

I'd seen the warning a thousand times. Added keys to make it go away. But I never understood why this would cause checkboxes to randomly uncheck themselves.

That bug sent me down a rabbit hole that completely changed how I think about React.


What React Is Really Doing (And We Don't Know It)

Here's what I thought React did:

  1. State changes
  2. Component re-renders
  3. React updates the DOM

Simple, right?

Wrong.

What React actually does is way more interesting—and explains everything about keys.

React Maintains Two Separate Worlds

World 1: Virtual DOM

This is just a bunch of JavaScript objects describing what your UI should look like:

{
  type: 'div',
  props: { className: 'todo-item' },
  children: [...]
}
Enter fullscreen mode Exit fullscreen mode

Fresh objects. Created every single render. Thrown away immediately after.

World 2: Fiber Tree

This is the real magic. These are internal React nodes that persist across renders:

{
  type: TodoRow,
  stateNode: actualDOMNode,
  memoizedState: { checked: true },  // Your useState values live here
  memoizedProps: { todo: {...} },
  child: nextFiberNode,
  sibling: anotherFiberNode,
  return: parentFiberNode
}
Enter fullscreen mode Exit fullscreen mode

Here's the kicker: Your component state lives in Fiber nodes, not in Virtual DOM.

When React re-renders, it's not really "re-rendering" your component. It's deciding which Fiber nodes to keep, which to update, and which to throw away.

That decision is called reconciliation.

And keys? Keys are how React makes that decision correctly.


The Bug I Couldn't Debug (Until I Could)

Let me show you the exact bug that broke my brain.

I had a simple todo list with checkboxes:

function TodoRow({ todo }) {
  const [checked, setChecked] = useState(false);

  return (
    <div className="todo-row">
      <input 
        type="checkbox"
        checked={checked}
        onChange={() => setChecked(!checked)}
      />
      <span>{todo.text}</span>
    </div>
  );
}

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

  function addTodoAtTop() {
    setTodos([
      { id: Date.now(), text: 'New todo' },
      ...todos
    ]);
  }

  return (
    <>
      <button onClick={addTodoAtTop}>Add Todo</button>
      {todos.map(todo => (
        <TodoRow todo={todo} />  // No key!
      ))}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here's What Happened

  1. User checks "Walk dog" ✓
  2. User clicks "Add Todo"
  3. "Walk dog" becomes unchecked 😱
  4. Or worse—"Buy milk" becomes checked instead

The state just... moved.

No errors. No warnings (okay, there was a warning). Just a broken UI that made no sense.


What React Saw (The Position-Based Disaster)

Without keys, React uses position to match old Fibers to new elements.

Before adding todo:

Position 0: Buy milk
Position 1: Walk dog  ✓ (checked)
Position 2: Write code
Enter fullscreen mode Exit fullscreen mode

After adding todo:

Position 0: New todo
Position 1: Buy milk
Position 2: Walk dog
Position 3: Write code
Enter fullscreen mode Exit fullscreen mode

React's thinking goes like this:

// Simplified reconciliation without keys
for (let i = 0; i < newElements.length; i++) {
  const oldFiber = oldFibers[i];
  const newElement = newElements[i];

  if (oldFiber && oldFiber.type === newElement.type) {
    // "Same component type at same position? Reuse it!"
    reuseFiber(oldFiber);
  }
}
Enter fullscreen mode Exit fullscreen mode

So React does this:

Position 0: Fiber(Buy milk) → New todo
Position 1: Fiber(Walk dog, checked=true) → Buy milk  
Position 2: Fiber(Write code) → Walk dog
Position 3: Create new Fiber → Write code
Enter fullscreen mode Exit fullscreen mode

Notice what happened? The Fiber that had checked=true (Walk dog) got reused for "Buy milk".

The state didn't move.

The state stayed exactly where React thought it should be.

But the identity moved.


Inside a Fiber Node (What You're Actually Breaking)

Let me show you what a Fiber node actually looks like when you have that checkbox checked:

FiberNode_WalkDog = {
  type: TodoRow,
  key: null,  // ← This is the problem
  index: 1,

  // The actual DOM element
  stateNode: <div class="todo-row">...</div>,

  // Your useState hook lives here
  memoizedState: {
    baseState: { checked: true },
    queue: [],
    next: null  // Linked list of hooks
  },

  // Props from last render
  memoizedProps: { 
    todo: { id: 2, text: 'Walk dog' } 
  },

  // Fiber tree structure
  child: null,
  sibling: FiberNode_WriteCode,
  return: FiberNode_TodoList,

  // Effect tracking
  flags: 0,
  lanes: 0
}
Enter fullscreen mode Exit fullscreen mode

When React reuses this Fiber for "Buy milk", it keeps everything—the state, the hooks, the DOM node—and just updates the props.

So you get a checkbox that thinks it belongs to "Walk dog" (because it's still checked), but it's rendering next to "Buy milk".

That's the bug.


The Fix (And Why It Actually Works)

Add one attribute:

{todos.map(todo => (
  <TodoRow key={todo.id} todo={todo} />
))}
Enter fullscreen mode Exit fullscreen mode

Now React's reconciliation algorithm completely changes.

With Keys: Identity-Based Matching

Instead of matching by position, React builds a lookup table:

// React's internal logic with keys
const oldFiberMap = new Map();

oldFibers.forEach(fiber => {
  if (fiber.key !== null) {
    oldFiberMap.set(fiber.key, fiber);
  }
});

newElements.forEach(element => {
  const matchingFiber = oldFiberMap.get(element.key);

  if (matchingFiber) {
    // Found the same identity! Reuse this specific Fiber
    reuseFiber(matchingFiber);
  } else {
    // New identity, create new Fiber
    createFiber(element);
  }
});
Enter fullscreen mode Exit fullscreen mode

So when you add a todo at the top:

key=1: Buy milk     → Reuse Fiber(Buy milk)
key=2: Walk dog ✓   → Reuse Fiber(Walk dog, checked=true)
key=3: Write code   → Reuse Fiber(Write code)
key=4: New todo     → Create new Fiber
Enter fullscreen mode Exit fullscreen mode

The Fiber with checked=true stays attached to "Walk dog".

State and identity stay together.

The UI tells the truth.


Why key={index} Is a Trap

You might think: "I'll just use the array index as the key!"

{todos.map((todo, index) => (
  <TodoRow key={index} todo={todo} />
))}
Enter fullscreen mode Exit fullscreen mode

This removes the warning. But it doesn't fix the bug.

Why? Because indexes change when the list changes.

Before adding todo:

key=0: Buy milk
key=1: Walk dog ✓
key=2: Write code
Enter fullscreen mode Exit fullscreen mode

After adding todo at top:

key=0: New todo
key=1: Buy milk
key=2: Walk dog
key=3: Write code
Enter fullscreen mode Exit fullscreen mode

From React's perspective:

  • The component with key=0 changed from "Buy milk" to "New todo"
  • So React creates a new Fiber and destroys the old one
  • Except when it doesn't (implementation details vary)
  • Either way, identity is broken

Index keys are only safe when your list is completely static—no adding, removing, reordering, or filtering. Ever.

Which means almost never.


When I Actually Understood Keys

The moment it clicked for me was this realization:

Fiber nodes are not components. They're identities.

A component is just a function that returns JSX. It runs, it finishes, it's done.

But a Fiber node is a living thing. It holds:

  • Your component's state
  • Your hooks (as a linked list)
  • References to DOM nodes
  • Information about effects to run
  • Metadata about work to be done

When React "reconciles", it's not comparing components. It's trying to figure out:

"Is this new element describing the same logical thing as this old Fiber?"

Without keys, React guesses based on position.

With keys, React knows for sure.


What About Performance?

Yeah, keys improve performance. But that's not why they exist.

Keys exist for correctness.

The performance benefits are side effects:

  • Fewer DOM mutations (reusing nodes instead of destroying/creating)
  • Fewer effect cleanups and re-runs
  • Fewer hook resets
  • Less layout thrashing

But all of that only matters if your UI is telling the truth in the first place.

A fast UI that shows wrong data is still broken.


The Rules I Now Follow

After spending a week debugging key-related bugs, here are my rules:

1. Every dynamic list gets unique keys

// ✓ Good
{users.map(user => (
  <UserCard key={user.id} user={user} />
))}
Enter fullscreen mode Exit fullscreen mode

2. Keys must be stable across renders

// ✗ Bad - creates new key every render
{users.map(user => (
  <UserCard key={Math.random()} user={user} />
))}

// ✗ Also bad - changes when list changes
{users.map((user, i) => (
  <UserCard key={i} user={user} />
))}
Enter fullscreen mode Exit fullscreen mode

3. Use IDs from your data

// ✓ Best
key={user.id}

// ✓ Okay if you don't have IDs
key={user.email}

// ✓ Last resort
key={`${user.firstName}-${user.lastName}-${i}`}
Enter fullscreen mode Exit fullscreen mode

4. Understand what "dynamic" means

A list is dynamic if it can:

  • Grow (adding items)
  • Shrink (removing items)
  • Reorder (sorting, drag-and-drop)
  • Filter (search, tabs, categories)

If any of those apply, use real keys.


The Deeper Reason (Fiber Architecture)

Here's something that blew my mind: React can pause rendering.

With React Fiber (introduced in React 16), rendering is interruptible. React can:

  • Start rendering a big component tree
  • Pause in the middle
  • Handle a high-priority update (like user input)
  • Resume the original render
  • Or throw it away entirely

This is how React stays responsive even when updating huge lists.

But this only works if React can reliably identify which Fiber is which.

If identity is based on position, and positions are changing, React can't safely pause and resume. It doesn't know if the thing at position 3 is the same component it was rendering before.

Keys make concurrent rendering safe.


What I Wish Someone Told Me Earlier

Keys aren't a React quirk.

They're not a warning to suppress.

They're not an optimization trick.

Keys are React's identity system.

When you write:

<TodoRow key={todo.id} todo={todo} />
Enter fullscreen mode Exit fullscreen mode

You're telling React:

"This component represents todo #5. No matter where it appears in the list, no matter how many times the list re-renders, this component's identity is tied to todo #5."

And React uses that information to preserve state, maintain hooks, reuse DOM nodes, and schedule updates correctly.

Without that information, React guesses. And sometimes it guesses wrong.


The Takeaway

Most bugs in React aren't React's fault.

They're identity bugs.

Once you start thinking about Fiber nodes instead of just components, everything makes more sense:

  • Why keys matter
  • When to use memo
  • How hooks work
  • Why you can't call hooks conditionally
  • What reconciliation actually means

That checkbox bug I mentioned at the start? Fixed in 10 seconds once I understood the problem.

But understanding the problem took me two years.

Don't make the same mistake I did.

When React asks for a key, it's not being annoying.

It's asking: "Who is this component?"

Give it an honest answer.


Want to go deeper? The React source code for reconciliation is in ReactChildFiber.js. The function you want is reconcileChildrenArray. It's surprisingly readable once you understand what Fiber nodes are.

Still confused? Hit me up on LinkedIn. I love talking about this stuff.


Top comments (0)