Коротка відповідь. Reconciliation – це процес, яким React вирішує, що саме змінити в DOM після ре-рендеру: він порівнює нове дерево елементів зі старим. Для списків це порівняння спирається на key: ключ каже React'у «це той самий елемент, що й минулого разу». Якщо ключем зробити індекс масиву, то при вставці чи видаленні елементів ключі «з'їжджають» – і React прив'язує стан (інпути, фокус, анімації) не до тих елементів. Розберімо механіку і повний приклад поломки.

Як React вирішує, що оновити

Після кожного оновлення стану React викликає компонент і отримує нове дерево елементів – легких об'єктів-описів. Далі порівнює його з попереднім за простими правилами:

  • Той самий тип елемента (div → div, TodoItem → TodoItem) – React зберігає DOM-вузол і стан компонента, оновлює лише пропси, що змінилися.
  • Інший тип (div → span, TodoItem → Note) – старе піддерево знищується разом зі станом, нове створюється з нуля.
  • Списки дітей – порівнюються за key: елементи з однаковими ключами вважаються «тими самими», навіть якщо переїхали на іншу позицію.

Головний наслідок, який перевіряють на співбесідах: стан живе за позицією в дереві та ключем, а не «всередині» твоїх даних. Це детально пояснено в офіційному розділі react.dev «Preserving and Resetting State» – якщо читати один першоджерельний матеріал про reconciliation, то саме його.

Повний приклад поломки: key={index} + інпути

Список гостей, у кожного – неконтрольований інпут для нотатки. Видаляємо першого гостя:

import { useState } from 'react';

type Guest = { id: string; name: string };

const initial: Guest[] = [
  { id: 'a', name: 'Оля' },
  { id: 'b', name: 'Тарас' },
  { id: 'c', name: 'Ніна' },
];

export function GuestListBroken() {
  const [guests, setGuests] = useState(initial);

  return (
    <ul>
      {guests.map((guest, index) => (
        <li key={index}>
          {guest.name}
          <input placeholder="нотатка" />
          <button onClick={() => setGuests((prev) => prev.filter((g) => g.id !== guest.id))}>
            видалити
          </button>
        </li>
      ))}
    </ul>
  );
}

Сценарій поломки: введи «алергія на горіхи» в нотатку Олі (перший рядок) і видали Олю. Текст «алергія на горіхи» залишиться в першому рядку – тепер біля Тараса.

Чому: до видалення ключі були 0:Оля, 1:Тарас, 2:Ніна. Після видалення масив став [Тарас, Ніна], а ключі – знову 0 і 1. React бачить: «елемент із key=0 був і є» – і зберігає його DOM разом зі значенням інпута, просто оновивши текст імені. Зник не «рядок Олі», а останній рядок (key=2). Стан інпутів прив'язався до позицій, а не до людей.

Фікс – один рядок:

{guests.map((guest) => (
  <li key={guest.id}>
    …
  </li>
))}

Тепер при видаленні Олі зникає вузол із key='a', а нотатки Тараса й Ніни лишаються біля своїх власників. Той самий ефект стосується контрольованих інпутів зі станом у дочірньому компоненті, фокуса, скролу, CSS-анімацій і будь-якого useState всередині елемента списку.

Коли index як key допустимий

Чесна відповідь «залежить» тут має чіткі межі. Індекс безпечний, коли виконуються всі три умови:

  1. список ніколи не переупорядковується і не фільтрується;
  2. елементи не додаються/не видаляються з середини;
  3. у рядках немає стану (інпутів, розкривних панелей, анімацій).

Типовий приклад – статичний список переваг на лендингу. Але навіть там key={item.id} не коштує нічого, тож правило за замовчуванням просте: стабільний ідентифікатор із даних. Якщо його немає – згенеруй при створенні запису (наприклад, crypto.randomUUID() у момент додавання), а не при рендері: ключ, створений у map, змінюється щорендеру і змушує React перестворювати все.

Ще два практичні наслідки reconciliation

Скидання стану через key. Ключі працюють не лише у списках. Змінюючи key, ти явно кажеш React'у «це інший елемент – знищ стан і створи заново»:

<ProfileForm key={userId} userId={userId} />

Перемкнули користувача – форма гарантовано чиста, без ручного «очищення» полів в ефекті. Це ідіоматичний патерн, а не хак.

Компонент, оголошений усередині компонента. Кожен рендер створює нову функцію, для React це щоразу «інший тип» – піддерево перестворюється, стан губиться:

function Parent() {
  // ❌ новий тип на кожен рендер
  function Child() { … }
  return <Child />;
}

Оголошуй компоненти на верхньому рівні модуля. Це часте питання-пастка після базових питань про keys.

Питання зі співбесід по цій темі

  • «Що таке reconciliation?» – процес порівняння нового дерева елементів зі старим, щоб мінімально оновити DOM.
  • «Навіщо key, якщо все й так працює?» – без key React порівнює дітей за позицією; key дає ідентичність елемента між рендерами.
  • «Чому не можна key={Math.random()}?» – ключ змінюється щорендеру → React вважає всі елементи новими → повне перестворення DOM і втрата стану.
  • «Чи впливають keys на продуктивність?» – так: стабільні ключі дозволяють переставляти вузли замість перестворення. Але головний ефект – коректність стану, продуктивність вторинна. Ширше про перформанс – у статті про оптимізацію React.
  • «Як скинути стан дочірнього компонента?» – змінити його key (приклад із формою вище).

Ці питання – частина типового блоку про рендеринг; повна карта підготовки є в гайді React співбесіда: як підготуватися, а потренувати механіку руками найшвидше на задачах live coding із TypeScript.

Практичне завдання

Відтвори зламаний приклад із гостями в пісочниці. Спочатку переконайся, що бачиш баг із key={index}. Потім: 1) полагодь через guest.id; 2) додай кнопку «перемішати список» і подивись, як поводяться нотатки в обох варіантах; 3) поясни вголос (собі або колезі), чому відбувається саме так. Пояснення вголос – найкращий тест, чи ти справді розумієш reconciliation, а не запам'ятав правило.