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} />)}
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:
- State changes
- Component re-renders
- 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: [...]
}
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
}
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!
))}
</>
);
}
Here's What Happened
- User checks "Walk dog" ✓
- User clicks "Add Todo"
- "Walk dog" becomes unchecked 😱
- 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
After adding todo:
Position 0: New todo
Position 1: Buy milk
Position 2: Walk dog
Position 3: Write code
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);
}
}
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
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
}
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} />
))}
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);
}
});
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
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} />
))}
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
After adding todo at top:
key=0: New todo
key=1: Buy milk
key=2: Walk dog
key=3: Write code
From React's perspective:
- The component with
key=0changed 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} />
))}
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} />
))}
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}`}
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} />
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)