Если вы хоть немного работали с React, вы наверняка видели желтое предупреждение в консоли: «Each child in a list should have a unique ‘key’ prop.» Это звучит как вежливое предложение, но на самом деле React предупреждает вас о том, что не может отличить элементы вашего списка друг от друга. Проигнорируйте его, и в итоге вы выпустите баг, который будет безумно сложно воспроизвести: состояние, перескакивающее на неверную строку, потеря фокуса текстовыми полями или анимации, срабатывающие не на том элементе.

Движок рендеринга React не сравнивает ваш UI попиксельно. Он строит легковесное дерево объектов, называемое виртуальным DOM, сравнивает новое дерево с предыдущим и вычисляет минимальный набор изменений, необходимых для реального DOM. Когда вы рендерите список, React видит массив соседних элементов. Без ключей у него нет надежного способа узнать, переместился ли элемент, был ли он заменен или удален. По умолчанию он сопоставляет элементы по позиции, что крайне ненадежно. Ключи служат стабильными идентификаторами. Они говорят React: «Этот элемент тот же самый, что и раньше, даже если теперь он находится в другом месте». Ошибетесь здесь — и вы замените детерминированные обновления на гадание на кофейной гуще.

Минимальное исправление

Предупреждение обычно появляется внутри вызова map. Вы должны назначить уникальное значение атрибуту key на верхнеуровневом элементе, возвращаемом итератором.

Вот паттерн, который встречается в каждом коде, вызывающем это предупреждение:

const UserList = ({ users }) => {
  return (
    <ul>
      {users.map((user) => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
};

React видит три тега <li> и понятия не имеет, какой из них какой. Исправление заключается в одном атрибуте:

const UserList = ({ users }) => {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

Ключ key должен быть назначен элементу непосредственно внутри колбэка map. Если вы вынесете <li> в отдельный компонент UserItem, ключ всё равно должен принадлежать компоненту в месте его вызова:

{users.map((user) => (
  <UserItem key={user.id} user={user} />
))}

Размещение ключа внутри UserItem на его внутреннем <div> не уберет предупреждение и не исправит поведение реконсиляции. React ищет ключ на элементе, возвращаемом итератором.

Почему использовать индекс в качестве ключа опасно

Велик соблазн заглушить предупреждение, используя второй аргумент map:

{users.map((user, index) => (
  <li key={index}>{user.name}</li>
))}

Это уберет шум в консоли, но не решит фундаментальную проблему. Индексы массива — это не идентификаторы. Это позиции, а позиции меняются.

Представьте список из трех пользователей, отрендеренных в следующем порядке:

  1. Alice (индекс 0)
  2. Bob (индекс 1)
  3. Charlie (индекс 2)

Если вы удалите Alice, Bob переместится на индекс 0, а Charlie — на индекс 1. React сравнивает новое дерево со старым. Он видит, что под индексом 0 теперь находятся данные Bob, и мутирует существующий узел DOM, который раньше отображал Alice. Если этот узел был в фокусе, курсор останется в первой строке, но текст изменится на Bob. Если строка содержала <input> с локальным состоянием, это состояние останется привязанным к индексу 0. Пользователь вводит текст в строку, которая выглядит как строка Bob, но состояние принадлежит Alice. Тот же хаос происходит при сортировке, фильтрации или добавлении элементов в начало списка. Единственное безопасное место для ключа-индекса — это действительно статический список: без переупорядочивания, фильтрации, вставок и удалений. Хороший пример — жестко заданные навигационные ссылки, которые никогда не меняются. Все остальное требует настоящего идентификатора.

Где найти стабильный ключ

Лучший ключ — это уникальный идентификатор, который уже существует в вашей модели данных. Первичные ключи базы данных, такие как id, идеальны, потому что они гарантированно уникальны и сохраняются между рендерингами. Если ваш бэкенд возвращает объекты с uuid, slug или каким-то другим естественным уникальным полем, используйте их.

Если в ответе вашего API нет уникального поля, у вас есть два практических пути. Во-первых, поговорите со своей командой бэкенда и попросите их добавить id. Передача реляционных данных без первичного ключа — это «плохой запах» (code smell), и исправление этого в источнике устранит двусмысленность во всем вашем стеке. Во-вторых, если вы генерируете элементы полностью на стороне клиента — например, список дел (todo list), где пользователи создают задачи до того, как они попадут на сервер — генерируйте ID один раз в момент создания. Библиотеки вроде uuid или nanoid созданы именно для этого. Сгенерируйте ID, когда пользователь отправляет форму, сохраните его в объекте и используйте этот ключ навсегда.

Никогда не генерируйте ключ внутри пути рендеринга. Вызов Math.random() или Date.now() во время рендеринга компонента создает новое значение при каждом проходе. React видит новый ключ, считает его совершенно новым элементом, уничтожает старый узел DOM и создает новый. Любое состояние внутри этого элемента сбрасывается. Фокус теряется. Производительность падает, потому что React выполняет ненужную работу с DOM. Случайно сгенерированный ключ хуже, чем отсутствие ключа вовсе.

Фрагменты, компоненты и область видимости

Менее очевидная ловушка связана с React Fragments. Если вы итерируетесь по данным (map) и вам нужно вернуть несколько соседних элементов без обертки <div>, вы можете воспользоваться сокращенным синтаксисом:

{items.map((item) => (
  <>
    <dt>{item.term}</dt>
    <dd>{item.definition}</dd>
  </>
))}

Сокращенная запись <>...</> не поддерживает пропсы, а значит, вы не сможете добавить ключ (key). В этом случае используйте полный явный синтаксис:

{items.map((item) => (
  <React.Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.definition}</dd>
  </React.Fragment>
))}

React необходим этот ключ в Fragment, чтобы он мог отслеживать пару как единое целое при перерендерах.

Еще один тонкий момент: ключи (keys) не являются пропсами в обычном понимании. Если вы напишете <ListItem key={item.id} />, компонент ListItem не сможет прочитать props.key. React использует его внутренне для ведения учета. Если вашему компоненту действительно нужен идентификатор для собственной логики, передайте его отдельно под другим именем, например itemId.

Практические правила для вашей безопасности

  • Отдавайте предпочтение ID из базы данных. Они уникальны, могут быть числовыми или строковыми и стабильны.
  • Используйте uuid или nanoid для данных, существующих только на стороне клиента. Генерируйте ID один раз при создании записи, а не внутри рендера компонента.
  • Никогда не выводите ключ из индекса массива, если список может меняться. Сортировка, фильтрация и удаление приведут к визуальным багам и ошибкам в состоянии.
  • Никогда не используйте Math.random(), Date.now() или любое значение, которое меняется между рендерами. Это вызывает ненужное размонтирование (unmounting) и повторное монтирование (remounting) компонентов.
  • Помните, что Fragment требует полной формы записи, если он используется внутри map и ему нужен ключ.
  • Размещайте ключ на элементе, который возвращает map, а не внутри дочернего компонента.

Главный вывод

Проп key — это не просто декоративное правило линтера. Это способ, с помощью которого React сохраняет идентичность элементов при перерендерах. Думайте об этом как о первичном ключе (primary key) в таблице базы данных. Когда эта идентичность стабильна, React может точно перемещать, обновлять и удалять элементы. Если же ключ отсутствует или нестабилен, вы расплачиваетесь поврежденным состоянием UI и медленной работой механизма reconciliation. Исправьте это один раз на уровне данных, и ваши списки будут вести себя предсказуемо, как бы сильно они ни росли или ни менялись.