Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 049 / 100

Keys in lists, explained: a hands-on guide for engineers

Keys tell React which item is which.

Expona Admin8 min read

Keys in lists, explained: a hands-on guide for engineers

React's list rendering is one of the first topics new developers encounter, yet one of the most misunderstood in production codebases. The key prop is not optional sugar—it's how React identifies which items have changed, been added, or removed. Without stable keys, your component state, input values, and animations will behave unpredictably, especially when lists reorder or items delete. This guide walks you through the mechanics of keys in lists, explained with real scenarios you'll face building features like shopping carts, search filters, and comment threads.

Why React needs keys at all

React maintains a virtual representation of your DOM. When you render a list, React needs to match old elements to new ones to avoid unnecessary remounts and state loss. Without a key, React assumes position in the array is identity—the first item in the old list matches the first item in the new list. This breaks the moment your list reorders, deletes, or inserts items out of order.

Consider a user list where each user has an input field for notes. If you don't provide a key and the list gets sorted alphabetically, the input value that belonged to Alice now sits next to Bob's name. Your component state got separated from the data it describes.

The key tells React: this element represents this specific data, regardless of its position. Position is implementation detail; identity is truth.

Use stable unique IDs from your data

The best key is a stable identifier that comes with your data. This is typically a database ID, a slug, or any unique value that won't change during the component's lifetime.

tsx

// Good: using database IDs
function UserList({ users }) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name}
          <input placeholder="Notes" />
        </li>
      ))}
    </ul>
  );
}

// Rendered with users = [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}]
// Each input is bound to a user by their id

In a real e-commerce cart, you'd use product IDs or order line item IDs. In a comment thread, use comment IDs from your backend. The key insight is that the ID must be stable—it doesn't change when the data is fetched again or when the list reorders.

When you sort, filter, or paginate, the key-to-element binding remains consistent:

tsx

function UserList({ users, sortBy }) {
  // Sort the display, but keys remain tied to user.id
  const sorted = [...users].sort((a, b) => {
    if (sortBy === 'name') return a.name.localeCompare(b.name);
    return a.joinDate - b.joinDate;
  });

  return (
    <ul>
      {sorted.map((user) => (
        <li key={user.id}>
          {user.name}
          <input placeholder="Notes" />
        </li>
      ))}
    </ul>
  );
}

When the user changes the sort order, React remounts nothing. It moves the DOM elements in place and preserves input state. The component tree stays alive because the identity of each item hasn't changed—only their position.

Index keys break when items reorder or delete

Using the array index as a key is a common trap. It seems convenient, but it breaks the moment your list becomes dynamic. Let's walk through why.

tsx

// Bad: using array index as key
function TaskList() {
  const [tasks, setTasks] = useState(['Write docs', 'Review PRs', 'Deploy']);

  const deleteTask = (indexToDelete) => {
    setTasks(tasks.filter((_, i) => i !== indexToDelete));
  };

  return (
    <ul>
      {tasks.map((task, index) => (
        <li key={index}>
          {task}
          <button onClick={() => deleteTask(index)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

Initially:

  • Index 0, key=0: Write docs
  • Index 1, key=1: Review PRs
  • Index 2, key=2: Deploy

Delete the middle item. Now:

  • Index 0, key=0: Write docs
  • Index 1, key=1: Deploy (was at index 2, now at index 1)

React sees key=1 still exists, so it reuses that DOM node. But the data changed—React updated the text from "Review PRs" to "Deploy" without remounting. If that list item had component state (a checked boolean, an input value), it would carry over to the wrong item.

This gets worse with reordering. If a user drags tasks to change priority and you use indices as keys, React will tear down and rebuild more of the tree than necessary, losing local state and animations.

The fix: always use a stable identifier from your data.

tsx

// Good: use a stable ID
function TaskList() {
  const [tasks, setTasks] = useState([
    { id: 'task-1', text: 'Write docs' },
    { id: 'task-2', text: 'Review PRs' },
    { id: 'task-3', text: 'Deploy' },
  ]);

  const deleteTask = (idToDelete) => {
    setTasks(tasks.filter((task) => task.id !== idToDelete));
  };

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          {task.text}
          <button onClick={() => deleteTask(task.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

Now when you delete, the remaining items keep their identity and any local state survives the delete.

Random keys remount every render

Some teams generate keys on the fly with Math.random() or crypto.randomUUID() during render. This defeats the entire purpose of keys.

tsx

// Bad: random key every render
function PostList({ posts }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={Math.random()}>
          {post.title}
          <input placeholder="Reply" />
        </li>
      ))}
    </ul>
  );
}

// On every render, all keys change.
// React treats each item as a new element.
// All input fields get remounted, losing their value.

React will remount every single list item on every render. Input fields reset. Checkboxes uncheck. Animations restart. If you have focus management or tooltips, they disappear. Performance tanks because React does extra work.

This often happens in codebases where the backend doesn't provide IDs, or where developers assume IDs are "unnecessary." In practice, if you're iterating over an array in React, you always need a stable key. If your data doesn't have one, add it at fetch time or during normalization:

tsx

// Generate stable IDs once at fetch time
async function fetchPosts() {
  const response = await fetch('/api/posts');
  const data = await response.json();
  
  // Add stable ID based on content hash or auto-increment
  return data.map((post, index) => ({
    ...post,
    _id: post.id || `post-${index}-${Date.now()}`, // fallback, not ideal
  }));
}

// Or better: ensure your backend always provides an ID

Changing a key intentionally resets state

While unstable keys are bad, there's a valid use case for intentionally changing a key: forcing a component to reset its internal state.

Suppose you have a form component that filters a list. When the filter changes, you want the form to reset:

tsx

function PostDetailForm({ postId }) {
  const [rating, setRating] = useState(0);
  const [comment, setComment] = useState('');

  return (
    <form>
      <label>
        Rating:
        <select value={rating} onChange={(e) => setRating(e.target.value)}>
          <option value="0">Choose</option>
          <option value="1">1 star</option>
          <option value="5">5 stars</option>
        </select>
      </label>
      <textarea
        value={comment}
        onChange={(e) => setComment(e.target.value)}
        placeholder="Your comment"
      />
    </form>
  );
}

function PostList({ posts }) {
  const [selectedId, setSelectedId] = useState(null);

  return (
    <>
      <ul>
        {posts.map((post) => (
          <li key={post.id} onClick={() => setSelectedId(post.id)}>
            {post.title}
          </li>
        ))}
      </ul>
      {selectedId && (
        <PostDetailForm key={selectedId} postId={selectedId} />
      )}
    </>
  );
}

By passing key={selectedId}, each time the user clicks a different post, React unmounts the old form and mounts a fresh one. The rating and comment state reset automatically. Without the key, you'd need to manually clear state in a useEffect:

tsx

// Verbose alternative without key
useEffect(() => {
  setRating(0);
  setComment('');
}, [postId]);

The key approach is cleaner: React's natural lifecycle handles the reset. This is one of the few times you'll intentionally change a key, and it's always for a deliberate reason.

Common mistakes

  • Using array indices in dynamic lists. Indices look like IDs but they're not stable. Use this only for static, non-filtered, non-sorted lists where nothing ever reorders or deletes.
  • Generating keys during render. Math.random(), UUID(), or string concatenation with current time creates a new key on every render. Save generated IDs to state or fetch them from your backend.
  • Using composite keys without hashing. If you use multiple fields like key={`${userId}-${date}`}, ensure the combination is truly stable. If date is a render timestamp, it will change every time.
  • Assuming keys prevent re-renders. Keys affect identity and mounting, not render behavior. If a parent re-renders, children re-render too, regardless of keys.
  • Forgetting keys when mapping. React will warn you in development. Don't silence the warning—fix it by adding a key. The warning is there because key-less lists are almost always a bug.

Key takeaways

  • Keys tell React which item is which across renders. Use stable, unique identifiers from your data—typically database IDs or slugs.
  • Array indices work only for static, non-reordered lists. In any dynamic list, indices will cause state and DOM to desynchronize when items reorder or delete.
  • Random keys or keys generated during render cause unnecessary remounts, breaking input focus, animations, and state preservation. Always use stable keys.
  • Intentionally changing a key forces a component to unmount and remount, resetting its state. Use this deliberately when you need a clean slate, but it's not the default approach.
  • Keys affect identity and mounting lifecycle, not render performance. Every component still renders, but with the right key, React preserves state and DOM efficiently.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles