Ключи и списки
Ключи — механизм идентичности React для элементов списка: стабильные уникальные ключи держат состояние на нужной строке, индексные ключи портят его при перестановке/вставке/удалении, а меняющийся ключ намеренно вызывает перемонтаж.
Ты маппишь массив в список редактируемых строк. Пользователь печатает в третий инпут, затем удаляет первую строку — и его текст прыгает на другую строку. Ничто в твоей логике рендеринга его не перемещало. Ты не трогал состояние этого инпута. И всё же значение теперь привязано к неправильному элементу, а баг не найти в JSX, потому что JSX корректен.
Баг — в key. Большинство React-разработчиков относятся к ключам как к формальности для успокоения линтера: «React хочет ключ, вот index, готово». Но ключи — это то, как React решает, какой экземпляр компонента есть какой между рендерами. Ошибись с ними — и ты получишь не медленный список; ты получишь тихую порчу состояния: фокус, позицию скролла, значения неконтролируемых инпутов, состояние анимации — всё привязано к неправильной строке. Этот урок — о ключах как механизме идентичности, а не как о предупреждении, которое надо подавить.
После этого урока ты можешь объяснить, что ключ — это стабильная идентичность дочернего элемента списка для React между рендерами; точно предсказать, как индексный ключ портит состояние компонента при перестановке, вставке или удалении; исправить это стабильным id из данных; использовать намеренно меняющийся ключ, чтобы принудительно перемонтировать и сбросить состояние поддерева, когда ты этого действительно хочешь; и распознать обратный отказ — случайный ключ на каждый рендер, — который перемонтирует всё каждый раз, уничтожая и состояние, и производительность.
Ключ — это идентичность, которую React использует, чтобы сопоставить дочерний элемент с его экземпляром между рендерами, а не косметический проп. Когда список перерендеривается, React пара́ми сопоставляет новые элементы с предыдущими по ключу, в пределах одного родителя. Совпавшие ключи переиспользуют существующий экземпляр компонента (и его состояние, DOM-узел, эффекты, фокус); несовпавшие — монтируют заново или размонтируют. Позиция в массиве — не идентичность, идентичность — это ключ.
// React читает ключи, чтобы ответить: «это ТА ЖЕ строка, что в прошлом рендере, или новая?»
{rows.map((row) => (
<EditableRow key={row.id} row={row} /> // идентичность = row.id, стабильна при перестановках
))}Так что правило «ключи должны быть стабильны и уникальны среди сиблингов» — не совет по стилю, это контракт, позволяющий React держать состояние каждой строки приклеенным к нужным данным.
Использование индекса массива как ключа привязывает идентичность к позиции, поэтому любая перестановка/вставка/удаление переназначает состояние неправильной строке. При key={index} первый элемент всегда «ключ 0». Удали первую строку — и вторая строка станет ключом 0, так что React думает, что старый первый экземпляр выжил и просто получил новые пропсы. Экземпляр компонента (с его локальным состоянием) остаётся на месте, пока данные сдвигаются под ним.
// БАГ: ключ = позиция. Локальное состояние (значение инпута, фокус) приколото к слоту, не к элементу.
{items.map((item, index) => (
<Row key={index} item={item} /> // <input> внутри Row держит неконтролируемое значение
))}Это незаметно для статических списков, в которые только дописывают, — поэтому индексные ключи так долго проходят ревью. Порча срабатывает лишь в первый раз, когда список переставляется, вставляется в начало или удаляется из середины. Именно эта отсрочка делает баг senior-уровня: он отгружается, а затем всплывает в продакшене при реальном редактировании пользователем.
Исправь это ключом, который приходит из данных и никогда не меняется для этого элемента, — id из базы данных, UUID, назначенный при создании, что угодно стабильное. Теперь идентичность следует за элементом, а не за слотом. Переставь массив — и React заново сопоставит каждый экземпляр с его данными по id, унося значение инпута и фокус вместе с ним.
// ФИКС: ключ = собственный стабильный id элемента. Идентичность теперь путешествует с данными.
{items.map((item) => (
<Row key={item.id} item={item} />
))}
// если в данных нет id, назначь его ОДИН раз при создании — никогда во время рендера:
const add = (text: string) =>
setItems((prev) => [...prev, { id: crypto.randomUUID(), text }]);Id должен чеканиться, когда элемент создаётся, и храниться в данных, а не вычисляться в теле рендера. Ключ, сгенерированный внутри map, пересчитывается на каждом рендере — это следующий режим отказа.
Два намеренных применения ключей отмечают границу корректного использования — и одно частое злоупотребление. Намеренно меняющийся ключ — это фича: когда ты даёшь поддереву новый ключ, React размонтирует старый экземпляр и монтирует свежий, сбрасывая всё его состояние. Это идиоматичный способ очистить форму, когда редактируемая сущность меняется.
// НАМЕРЕННЫЙ перемонтаж: смена userId сбрасывает каждое поле/скролл/неконтролируемый инпут внутри.
<ProfileForm key={userId} userId={userId} />Злоупотребление — обратное: случайный ключ на каждом рендере. React ничего не сопоставляет, поэтому сносит и перестраивает весь список на каждом рендере — теряя всё состояние и непрерывно платя полную стоимость монтирования.
// АНТИПАТТЕРН: свежий ключ на каждом рендере → полный перемонтаж каждый раз.
{items.map((item) => (
<Row key={Math.random()} item={item} /> // никогда не переиспользует экземпляр — состояние + перф потеряны
))}Индексные ключи привязывают состояние к неправильной строке; случайные ключи привязывают его ни к какой строке. Стабильные id — единственный корректный дефолт, а меняющийся ключ корректен только тогда, когда сброс-при-изменении и есть намерение.
Редактируемый список дел: сначала баг индексного ключа, затем фикс стабильным id. У каждой строки неконтролируемый <input>, поэтому введённое значение живёт в DOM, принадлежит экземпляру компонента — ровно то состояние, которым управляют ключи.
До — индекс как ключ. Напечатай «buy milk» во второй инпут, затем удали первую строку:
function TodoList() {
const [items, setItems] = useState([
{ id: "a", label: "first" },
{ id: "b", label: "second" },
{ id: "c", label: "third" },
]);
const remove = (id: string) =>
setItems((prev) => prev.filter((i) => i.id !== id));
return (
<ul>
{items.map((item, index) => (
<li key={index}> {/* ← позиция, не идентичность */}
<input defaultValue={item.label} /> {/* неконтролируемый: значение держит экземпляр */}
<button onClick={() => remove(item.id)}>x</button>
</li>
))}
</ul>
);
}Удали первую строку — и массив сдвигается: second теперь индекс 0, third — индекс 1. React сопоставляет новый элемент с индексом 0 старому экземпляру с индексом 0 — чей <input> всё ещё держит то, что там напечатали. Текст, набранный во «second», остаётся на верхнем экземпляре, теперь показывающем неправильный элемент. Состояние прилипло к слоту.
После — единственное изменение — это ключ:
{items.map((item) => (
<li key={item.id}> {/* ← стабильная идентичность из данных */}
<input defaultValue={item.label} />
<button onClick={() => remove(item.id)}>x</button>
</li>
))}Теперь React сопоставляет по item.id. Когда first удаляют, экземпляры b и c заново сопоставляются со своими данными — React размонтирует ровно экземпляр a, и каждый другой инпут сохраняет правильное значение и фокус. Изменение в одну строку; разница в том, отслеживает ли идентичность элемент или слот.
Когда ты хочешь сброса — скажем, кнопка «дублировать строку» должна выдать новой строке пустой инпут, даже если та делит лейбл, — ты даёшь этой строке свежий id, и новый ключ естественно монтирует свежий экземпляр. Тот же механизм, применённый специально.
▸Почему это работает
Почему это портит именно состояние, а не просто рендерит неправильный текст? Потому что то, чем управляют ключи, — это то, что React хранит вне вывода рендера: значения useState, рефы, неконтролируемый DOM (текст инпута, состояние чекбокса, позиция скролла, открытость <details>), запущенные эффекты и незавершённые переходы/анимации. Вывод рендера всегда пересчитывается из текущих пропсов, поэтому видимый лейбл будет выглядеть правильно — но привязанное к экземпляру состояние остаётся приваренным к тому экземпляру, который React решил переиспользовать. Это несоответствие между «данными, которые описывают пропсы» и «состоянием, которое несёт экземпляр», и есть весь этот класс багов, и именно поэтому индексные ключи опасны конкретно на строках, держащих собственное состояние.
▸Частая ошибка
Обратная сверхкоррекция — key={Math.random()} или key={Date.now()}, чтобы «избежать устаревших строк», — хуже индексного ключа. Новый ключ на каждом рендере означает, что React ничего не сопоставляет, поэтому размонтирует и перемонтирует каждую строку на каждом рендере: всё локальное состояние и фокус непрерывно теряются, эффекты перезапускаются, DOM перестраивается, и ты платишь полную стоимость монтирования на списке, которому это никогда не было нужно. Если ты тянешься к случайному ключу, чтобы форсировать обновление, то на самом деле тебе нужен стабильный ключ плюс корректные пропсы — или, для единичного намеренного сброса, ключ, выведенный из сущности (key={userId}), который меняется только тогда, когда сброс действительно задуман.
Список строк, у каждой неконтролируемый <input>, использует key={index}. Пользователь печатает в инпут третьей строки, затем удаляет первую строку. Что произойдёт и почему?
Ключ — это идентичность дочернего элемента списка для React между рендерами: в пределах одного родителя React пара́ми сопоставляет новые элементы предыдущим экземплярам по ключу, и совпавший экземпляр сохраняет своё состояние, DOM, фокус и эффекты. Ключи должны быть стабильны и уникальны среди сиблингов. Индексный ключ привязывает идентичность к позиции, поэтому при любой перестановке/вставке/удалении данные сдвигаются под экземплярами и состояние попадает на неправильную строку — тихая порча неконтролируемых инпутов, фокуса и скролла, а не медленный рендер, поэтому он переживает ревью и всплывает лишь при реальном редактировании. Фикс — ключ из собственного стабильного id данных, отчеканенного один раз при создании, никогда не вычисляемого в рендере. Намеренно меняющийся ключ — это фича: он вызывает перемонтаж, чтобы сбросить поддерево (например, key={userId}, чтобы очистить форму). Обратное злоупотребление, случайный ключ на каждом рендере, не совпадает ни с чем и перемонтирует всё, уничтожая состояние и производительность. По умолчанию — стабильные id; меняй ключ только тогда, когда сброс-при-изменении — это ровно то, что ты имеешь в виду.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.