Зачем нужен key (и почему не индекс)

«Удаляю вторую задачу из списка — и текст из её поля ввода переезжает в третью. Что не так?» Один из самых наглядных вопросов на понимание реконсиляции.

key — стабильный идентификатор элемента среди соседей, по которому React понимает: это тот же самый узел, что и раньше, просто переехал, или это уже совсем другой узел.

Вопрос: куда «переехал» текст?

Интервьюер показывает список задач с неуправляемыми (uncontrolled) полями ввода — то есть такими, чьё значение хранит сам DOM-узел:

function TodoList({ todos, onRemove }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>
          {todo.name}
          <input placeholder="комментарий" />
          <button onClick={() => onRemove(todo.id)}>Удалить</button>
        </li>
      ))}
    </ul>
  );
}

Пользователь заполнил комментарии у всех трёх задач и удалил среднюю. Что он увидит?

Ответ: задача исчезнет правильная, а вот комментарии съедут. У последней задачи в поле окажется комментарий, который писали для удалённой.

Почему так

React сопоставляет детей списка по паре «тип + key». Пока key — это индекс, он описывает не элемент, а позицию. Было три li с ключами 0, 1, 2. Удалили средний — стало два li с ключами 0 и 1. React смотрит: ключ 0 был и есть, ключ 1 был и есть, ключ 2 пропал. Вывод, который он делает: «удалили последний, а у первого и второго поменялись пропсы». Он оставляет на месте первые два DOM-узла (а вместе с ними и всё, что в них жило: значение input, фокус, прокрутку, состояние дочерних компонентов) и просто переписывает текст задачи. Данные съезжают на одну позицию.

Смоделируем это чистым JS — состояние полей держим в словаре «ключ → текст», ровно как это делает React:

let items = [
  { id: 'a', name: 'Молоко' },
  { id: 'b', name: 'Хлеб' },
  { id: 'c', name: 'Сыр' },
];

// React хранит состояние узла по его key
const stateByIndexKey = new Map([[0, '1 л'], [1, 'бородинский'], [2, 'пармезан']]);
const stateByIdKey = new Map([['a', '1 л'], ['b', 'бородинский'], ['c', 'пармезан']]);

// удаляем «Хлеб» из середины
items = items.filter((it) => it.id !== 'b');

console.log('key={index} — что осталось в полях:');
for (let i = 0; i < items.length; i++) {
  console.log('  ' + items[i].name + ': ' + stateByIndexKey.get(i));
}

console.log('key={item.id} — что осталось в полях:');
for (const it of items) {
  console.log('  ' + it.name + ': ' + stateByIdKey.get(it.id));
}

Результат:

key={index} — что осталось в полях:
  Молоко: 1 л
  Сыр: бородинский
key={item.id} — что осталось в полях:
  Молоко: 1 л
  Сыр: пармезан

«Сыр: бородинский» — это и есть тот самый баг, только без React. Виден он не в разметке, а именно в состоянии, которое привязано к ключу.

Как чинится

{todos.map((todo) => (
  <li key={todo.id}>
    {todo.name}
    <input placeholder="комментарий" />
  </li>
))}

Теперь ключ описывает саму задачу, а не её место в массиве. Удалили b — React видит, что ключ b исчез, и удаляет именно его узел. Узлы a и c он переиспользует вместе с их состоянием.

Что именно ломается при key={index}

Что «съезжает»Как это видит пользователь
Значение неуправляемого input/textareaВведённый текст оказывается не в той строке
Состояние дочернего компонента (useState внутри строки)Раскрытая строка «схлопывается», чужой аккордеон открыт
Флажки checked у неуправляемых чекбоксовГалочка переехала на соседнюю задачу
Фокус, каретка, прокрутка внутри узлаКурсор прыгает в другое поле
CSS-анимации входа/выходаАнимируется не тот элемент, что удалили

Обратите внимание: если поля управляемые (значение приходит из состояния родителя), баг может не проявиться — React перепишет value из пропсов. Именно поэтому key={index} так долго живёт в проектах: он «работает», пока в строках нет собственного состояния. Как только оно появляется — начинается мистика.

Когда индекс всё-таки допустим

Три условия должны выполняться одновременно:

  • список никогда не переупорядочивается, не фильтруется и не сортируется;
  • элементы не добавляются и не удаляются из середины (только в конец, либо список вообще статичен);
  • у элементов нет собственного состояния и они не содержат полей ввода.

Пример, где индекс безопасен: неизменяемый список пунктов меню, отрисованный один раз. Если хоть одно условие под вопросом — берите стабильный id из данных.

А если id нет?

Собирайте ключ из данных: key={user.email}, key={`${row.date}-${row.type}`}. Если данные приходят без идентификатора — присвойте его в момент создания элемента (например, crypto.randomUUID()), а не в момент рендера. Никогда не пишите key={Math.random()}: ключ будет новым на каждом рендере, React будет размонтировать и монтировать весь список заново — потеря фокуса, мигание, дикие тормоза.

Как это работает под капотом

При сравнении детей React идёт по новому списку слева направо, пока элементы совпадают по типу и ключу — это быстрый путь. Как только совпадение ломается, он строит map из оставшихся старых детей по их ключам и дальше для каждого нового ребёнка ищет старый узел в этом map. Нашёл — переиспользует fiber (а значит, и хуки со состоянием, и DOM-узел), при необходимости лишь переставляет узел. Не нашёл — создаёт новый. Всё, что осталось в map после прохода, удаляется.

Отсюда два следствия, которые интервьюеры любят проверять:

  • Ключ уникален только среди соседей, а не глобально на странице. Два разных списка спокойно могут использовать ключи 1, 2, 3.
  • key не приходит в пропсы. Внутри компонента props.key — это undefined. Если ключ нужен ещё и как данные, передайте его вторым пропом: <Row key={id} id={id} />.

Бонус: key как инструмент сброса состояния

Смена ключа гарантированно размонтирует компонент — и это можно использовать намеренно. Классический приём: сбросить форму при переключении пользователя.

// при смене userId ProfileForm смонтируется заново
// со всем своим внутренним состоянием «с чистого листа»
<ProfileForm key={userId} userId={userId} />

Это чище, чем городить useEffect, который следит за userId и вручную обнуляет пять полей состояния.

Частые ошибки на собеседовании

  • «key нужен для производительности». Наполовину правда, но главное — корректность: без стабильного ключа React путает узлы и переносит состояние не туда. Начинайте ответ с корректности.
  • «key должен быть уникален во всём приложении». Нет, только среди соседей.
  • «key можно прочитать в props». Нельзя, key — служебное поле элемента.
  • key={Math.random()}, чтобы «точно был уникальным». Так вы гарантируете полный ремоунт списка на каждом рендере.
  • Не уметь объяснить почему. Заученное «индекс — плохо» без разбора механики закрывает грейд. Расскажите про сопоставление старых и новых детей по ключу — это и есть ответ.

Итоги-шпаргалка

  • key отвечает на вопрос «это тот же самый элемент?». По нему React переиспользует DOM-узел и состояние.
  • key={index} описывает позицию, а не элемент. Удаление или вставка в середину сдвигает всё состояние на позицию.
  • Правильный ключ — стабильный id из данных; составной ключ — тоже вариант.
  • Math.random() в ключе = ремоунт всего списка на каждом рендере.
  • Ключ уникален среди соседей и не попадает в props.
  • Смену key можно использовать намеренно — как самый чистый способ сбросить состояние компонента.
Проверьте себя
1. Список отрисован с key={index}. Пользователь ввёл текст в неуправляемые поля всех трёх строк, затем удалил вторую строку. Что увидит пользователь?
AВсе поля очистятся — React пересоздаст список
BТекст третьей строки исчезнет вместе со второй
CВ оставшейся последней строке окажется текст, введённый для удалённой строки
DНичего не изменится: значения полей не зависят от key
2. Какое утверждение про key верно?
Akey должен быть уникален глобально во всём приложении
Bkey уникален среди соседних элементов и недоступен внутри компонента через props.key
Ckey приходит в компонент как обычный проп и его можно прочитать
Dkey нужен только для скорости и на корректность не влияет