50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
reactMay 19, 2026·8 min read

React key prop mistakes — why your list is re-rendering weirdly

Using array index as key isn't just 'bad practice' — it causes real bugs. Here's what's actually happening and how to fix it.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

React key prop mistakes — why your list is re-rendering weirdly

You've seen the warning a hundred times: 'Each child in a list should have a unique key prop.' You slap `key={index}` on there, the warning disappears, and you move on. Then six months later you have a bug where checkboxes are checking themselves, inputs are showing the wrong values, or animations are firing on the wrong elements. Congrats — past you just made present you's afternoon much worse.

The key prop is one of those React fundamentals that everyone knows exists and almost nobody fully understands. We've personally shipped key-related bugs into production, watched Stefan spend 45 minutes debugging a form that was clearly broken due to index keys, and read dozens of confused Stack Overflow posts from people who couldn't figure out why their UI was lying to them. So let's actually understand this thing.

What React actually does with the key prop

React's reconciliation algorithm needs to figure out what changed between renders. When you have a list of elements, React uses the key to match up elements from the previous render with elements in the current render. If the keys match, React updates the existing component instance. If a key disappears, React unmounts that component. If a new key appears, React mounts a new component.

This is the crucial part: same key = same component instance = state is preserved. Different key = different component instance = state is reset. React is essentially saying 'this is the same logical thing as last time' or 'this is a new thing'. When you lie to React about identity via bad keys, you get a UI that lies back to you.

The key prop isn't just for performance — it's about identity. It tells React which component instance corresponds to which piece of data across renders.

The index key trap and why it actually breaks things

Using the array index as a key works fine when your list is static and will never be reordered, filtered, or have items added/removed from anywhere except the end. That's... not most lists. Here's a concrete example of what goes wrong:

// This looks innocent but is a trap
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Buy milk' },
    { id: 2, text: 'Walk the dog' },
    { id: 3, text: 'Ship the feature' },
  ]);

  const removeFirst = () => setTodos(todos.slice(1));

  return (
    <ul>
      {todos.map((todo, index) => (
        // BAD: key={index}
        <li key={index}>
          <input type="checkbox" />
          <span>{todo.text}</span>
        </li>
      ))}
      <button onClick={removeFirst}>Remove first</button>
    </ul>
  );
}

// When you click 'Remove first':
// Before: key=0 -> 'Buy milk', key=1 -> 'Walk the dog', key=2 -> 'Ship the feature'
// After:  key=0 -> 'Walk the dog', key=1 -> 'Ship the feature'
//
// React sees key=0 still exists! It reuses that component instance.
// The checkbox state from 'Buy milk' is now on 'Walk the dog'.

See what happened? React matched key=0 (old 'Buy milk') with key=0 (new 'Walk the dog') and said 'same component, just update the text'. But the checkbox — which is uncontrolled, meaning its state lives in the DOM, not React — kept its old value. The text updated. The checkbox didn't. Your UI is now lying.

This exact bug bites you with: uncontrolled form inputs inside list items, animation libraries that trigger on mount/unmount, any component that has internal state, third-party components you don't control. Basically anything interesting.

The fix is obvious, but the gotchas aren't

Use stable, unique identifiers from your data. Usually that's the database ID. Simple:

// Good: use your actual data IDs
{todos.map((todo) => (
  <TodoItem key={todo.id} todo={todo} />
))}

// Also fine if you genuinely have unique strings
{countries.map((country) => (
  <CountryOption key={country.code} country={country} />
))}

// For nested lists, scope your keys — they only need to be unique
// among siblings, not globally unique across the entire app
{users.map((user) => (
  <div key={user.id}>
    {user.roles.map((role) => (
      // key={role.id} is fine even if role IDs collide across users
      // because they're siblings within different parent elements
      <span key={role.id}>{role.name}</span>
    ))}
  </div>
))}

The gotcha people hit is when they don't have IDs. Maybe you're rendering a list of strings, or data that came from somewhere without unique identifiers. The temptation is to use index, or worse — generate a random key on each render.

// NEVER do this — generates a new key every render
// This will unmount and remount every item on every render
{items.map((item) => (
  <Item key={Math.random()} item={item} />
))}

// Also bad for the same reason
{items.map((item) => (
  <Item key={crypto.randomUUID()} item={item} />
))}

// If you have no ID, create one when the data is created, not during render
const [items, setItems] = useState(() =>
  rawItems.map(item => ({ ...item, _key: crypto.randomUUID() }))
);

// Now this is stable
{items.map((item) => (
  <Item key={item._key} item={item} />
))}

Generate keys once when data enters your system — when you fetch it, when you create it, when you set it in state — not during the render function. The render function runs multiple times. Your keys should not change between those runs unless the underlying data actually changed identity.

When using index is actually fine

We don't want to be dogmatic about this. Index keys are genuinely fine in specific circumstances, and refusing to use them ever is cargo-cult programming. Use index as a key when all three of these are true:

  • The list is static — it will never be reordered, filtered, or have items removed from non-end positions
  • The items have no stable identity (like a list of strings you're just displaying)
  • The list items have no internal state and no side effects on mount/unmount

A read-only list of tags? Index is fine. A list of sentences in a blog post? Fine. A todo list where users can check things off, reorder items, or add things to the middle? Not fine. The rule of thumb: if the list can change in any way that isn't 'append to the end', don't use index.

Using key to intentionally reset component state

Here's a pattern that confuses people when they first see it but becomes a tool you reach for regularly: deliberately changing a key to force a full remount of a component.

// Problem: you have a form component with its own internal state
// and you want to 'reset' it when the user selects a different item
function EditUserForm({ userId }: { userId: string }) {
  const [name, setName] = useState('');
  
  // If userId changes, this form still has the old name in state
  // You'd have to useEffect + setState to sync it, which is messy
  
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

// Solution: use key to tell React this is a different form instance
function ParentComponent() {
  const [selectedUserId, setSelectedUserId] = useState('user-1');
  
  return (
    <>
      <UserSelector onChange={setSelectedUserId} />
      {/* Changing userId changes the key, which remounts the form fresh */}
      <EditUserForm key={selectedUserId} userId={selectedUserId} />
    </>
  );
}

// This is cleaner than:
// useEffect(() => { resetFormState() }, [userId])

This pattern is legitimately useful. React docs even recommend it in some cases over using useEffect to sync external state. When you change the key, you get a fresh component with fresh state — no useEffect gymnastics, no manual reset logic. We've used this in multi-step forms where going back to a previous step should show a clean form, and in data tables where switching filters should reset any unsaved row edits.

Changing a key intentionally is not a hack — it's telling React 'this is conceptually a different thing now, start fresh.' Used deliberately, it's cleaner than manual state resets via useEffect.

Debugging key-related bugs in the wild

When you suspect a key issue, the React DevTools are your first stop. Enable 'Highlight updates when components render' and watch what flashes when you interact with your list. If the wrong components are flashing, or components are flashing when they shouldn't be, keys are usually involved.

The symptoms that point to key bugs specifically:

  • State from one item 'bleeding' into another after reorder or delete — classic index key problem
  • Animations triggering on the wrong items — your animation library is responding to mount/unmount based on bad key matching
  • Form inputs showing stale values after the underlying data changes
  • useEffect running more or less often than expected when list items change
  • Controlled inputs losing focus unexpectedly (the component unmounts and remounts)
  • Components mounted fresh when you expected them to update, or updated when you expected them to remount

There's also a performance angle here. When your keys are unstable — either random keys on every render, or index keys on a list that frequently reorders — React does more work than necessary. A stable key tells React 'this is the same component, just update its props.' An unstable key says 'throw this away and build a new one.' For simple list items that doesn't matter much. For complex components with deep trees, it can tank your performance.

A real pattern we use in production

When we're building data-heavy interfaces in our templates at peal.dev — things like user tables, invoice lists, dashboard feeds — we follow a simple convention: every piece of data that gets rendered in a list gets an `id` field, and that's always the key. No exceptions, no debates. If the data comes from an API that doesn't return IDs, we normalize it on the way in:

// utils/normalize.ts
export function withStableKeys<T>(items: T[]): (T & { _id: string })[] {
  return items.map(item => ({
    ...item,
    _id: crypto.randomUUID(),
  }));
}

// Used once when data arrives, not in render
const { data } = await fetch('/api/items').then(r => r.json());
const normalizedItems = withStableKeys(data);

// Later in component
{normalizedItems.map(item => (
  <ListItem key={item._id} item={item} />
))}

// Alternatively, if you use React Query or SWR, do it in the select/transform:
const { data: items } = useQuery({
  queryKey: ['items'],
  queryFn: fetchItems,
  select: (data) => withStableKeys(data),
});

The IDs are generated once, they're stable for the lifetime of that data in memory, and every list in the codebase uses `key={item._id}` or `key={item.id}`. When someone reviews a PR and sees `key={index}`, that's an automatic change request. It's the kind of boring, consistent rule that prevents 2am debugging sessions.

Keys are one of those things where getting it right is almost zero extra effort, but getting it wrong creates bugs that are genuinely hard to trace. The warning React gives you is just 'hey, add a key' — it can't tell you if your key is semantically correct. That part is on you. Use your data's actual identity as the key, generate stable IDs when your data doesn't have them, and only use index when you're genuinely sure the list is static. Future you — the one debugging at midnight — will be grateful.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders