Зачем нужен 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можно использовать намеренно — как самый чистый способ сбросить состояние компонента.