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

Механізм рендерингу React не порівнює ваш UI піксель за пікселем. Він будує легке дерево об'єктів, яке називається virtual DOM, порівнює нове дерево з попереднім і обчислює найменший набір змін, необхідних для real 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> не прибере попередження і не виправить поведінку reconciliation. 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, коли користувач надсилає форму, зберігайте його в об'єкті та використовуйте як ключ назавжди.

Ніколи не генеруйте ключ безпосередньо під час рендерингу (render path). Виклик Math.random() або Date.now() під час рендерингу компонента створює нове значення при кожному проході. React бачить новий ключ, вважає його абсолютно новим елементом, знищує старий DOM-вузол і створює новий. Будь-який стан всередині цього елемента скидається. Фокус втрачається. Продуктивність падає, тому що React виконує зайву роботу з DOM. Випадково згенерований ключ гірший, ніж його відсутність.

Менш очевидна пастка пов'язана з React Fragments. Якщо ви перебираєте дані за допомогою map і вам потрібно повернути кілька сусідніх елементів без обгортки <div>, ви можете скористатися коротким синтаксисом:

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

Скорочений запис <>...</> не підтримує props, а це означає, що ви не можете додати key. У такому разі перейдіть на повний явний синтаксис:

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

React потребує цього key у Fragment, щоб він міг відстежувати пару як єдине ціле під час рендерингу.

Ще один тонкий момент: keys не є props у звичному розумінні. Якщо ви напишете <ListItem key={item.id} />, компонент ListItem не зможе прочитати props.key. React використовує його внутрішньо для ведення обліку. Якщо вашому компоненту дійсно потрібен ідентифікатор для власної логіки, передайте його окремо під іншим ім'ям, наприклад itemId.

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

  • Віддавайте перевагу ID з бази даних. Вони унікальні, числові або рядкові та стабільні.
  • Використовуйте uuid або nanoid для даних, що існують лише на клієнті. Генеруйте ID один раз під час створення запису, а не під час рендерингу компонента.
  • Ніколи не виводьте key з індексу масиву, якщо список може змінюватися. Сортування, фільтрація та видалення призведуть до візуальних помилок та помилок у стані.
  • Ніколи не використовуйте Math.random(), Date.now() або будь-яке значення, що змінюється між рендерингами. Це призводить до зайвого розмонтування та повторного монтування.
  • Пам'ятайте, що Fragments потребують повної форми, якщо вони знаходяться всередині map і потребують key.
  • Розміщуйте key на елементі, який повертає map, а не всередині дочірнього компонента.

Головний висновок

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