Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Реконсиляція в React». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Реконсиляція (reconciliation)** - це алгоритм React для порівняння дерев virtual DOM і знаходження мінімального набору змін у реальному DOM. ```jsx // Неправильно: випадковий ключ = повне перемонтування при кожному рендері items.map(item => <li key={Math.random()}>{item.name}</li>) // Правильно: стабільний ключ = оновлюється тільки змінений елемент items.map(item => <li key={item.id}>{item.name}</li>) ``` **Головне:** різний тип елемента - React розмонтовує все піддерево. Нестабільний або відсутній `key` - React втрачає зв'язок між елементами списку при зміні порядку або видаленні.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Реконсиляція (reconciliation)** - це процес, за яким React порівнює нове дерево virtual DOM зі старим і знаходить мінімальний набір змін для реального DOM. ## Теорія ### TL;DR - Аналогія: відеоредактор, який перерендерює тільки пікселі що змінились, а не весь фільм. - React тримає складність O(n) замість O(n³) двома припущеннями: різні типи елементів дають різні піддерева, а `key` вказує на стабільні елементи списку. - Різний тип (`<div>` на `<span>`) - повне розмонтування і перебудова. Однаковий тип - оновлення атрибутів, рекурсія в дітей. - Список без стабільних `key` порівнюється за позицією (індексом), що ламається при видаленні або перестановці. - Спочатку профайл через React DevTools Profiler, потім вже думай про `React.memo` або зміну ключів. ### Швидкий приклад ```jsx // Без стабільних ключів: React перемонтовує ВСІ елементи при будь-якій зміні списку function BadList({ items }) { return ( <ul> {items.map(item => ( <li key={Math.random()}>{item.name}</li> // новий ключ кожен рендер = повне перемонтування ))} </ul> ); } // Зі стабільними ключами: оновлюється тільки змінений елемент function GoodList({ items }) { return ( <ul> {items.map(item => ( <li key={item.id}>{item.name}</li> // React відстежує за id, перевикористовує DOM-вузли ))} </ul> ); } ``` Видали один елемент з `BadList` - кожен `<li>` перемонтується, фокус і локальний стан зникають. Видали з `GoodList` - зникає тільки той вузол. Ось і вся різниця. ### Як працює евристика диффінгу Точне порівняння двох довільних дерев - задача O(n³). React зводить це до O(n) двома ставками на твій код. Перша ставка: елементи різних типів дають повністю різні піддерева. Тому React зносить старе і будує нове з нуля. `<div>` перетворився на `<span>` - кожен дочірній елемент розмонтовується, `componentWillUnmount` спрацьовує, весь стан компонента стирається. Друга ставка: розробник сам вказує на ідентичність елементів списку через `key`. Без нього React порівнює за позицією. Додав елемент на початок - React вважає, що позиція 0 змінилась, позиція 1 змінилась, і перерендерює все. Елементи одного типу йдуть іншим шляхом. React залишає DOM-вузол, оновлює тільки атрибути що змінились, і рекурсивно обробляє дітей. Для компонентів екземпляр залишається живим, стан зберігається, оновлюються тільки пропси. ```jsx // Старий <div className="card" title="old title" /> // Новий <div className="card" title="new title" /> // React чіпає тільки атрибут title. className не змінюється. ``` ### Коли потрібні ключі Перебудова або фільтрація списку: кожен елемент у `.map()` потребує стабільного `key` зі своїх даних, не з поточної позиції. Перемикання між двома різними типами компонентів: прийми повне перемонтування. Минути його неможливо, і зазвичай це нормально. Перемикання між двома компонентами одного типу: React перевикористає екземпляр і збереже стан. Якщо тобі потрібен свіжий mount - дай кожному різний `key`. Динамічний список з видаленням або додаванням: `key={item.id}` - єдиний безпечний варіант. Індекс зсувається при видаленні з середини. ### Як це працює всередині Коли спрацьовує `setState`, пакет `react-reconciler` стартує від зміненого fiber-вузла і запускає `reconcileChildren`. Fiber-архітектура, що зʼявилась у React 16 і розвинулась у React 18, зробила реконсиляцію переривчастою. Замість одного синхронного проходу React розбиває роботу на частини через Scheduler з пріоритетними lanes. Concurrent Mode в React 18 додає `useTransition`, який позначає деякі оновлення як низькопріоритетні. Scheduler відступає перед терміновим відмальовуванням і виконує відкладений диф пізніше. Сам алгоритм не змінюється. Змінюється тільки момент запуску. Після диффінгу React збирає всі мутації в список ефектів і скидає їх у реальний DOM одним викликом `commitRoot`. Саме цей батч тримає кількість reflow мінімальною. ### Типові помилки **1. Індекс масиву як ключ** ```jsx // Видалення з середини зсуває всі індекси. // React вважає, що елементи змінились, хоча вони просто перемістились. {todos.map((todo, index) => ( <TodoItem key={index} todo={todo} /> ))} // Правильно: id зі своїх даних {todos.map(todo => ( <TodoItem key={todo.id} todo={todo} /> ))} ``` **2. Випадкове значення як ключ** ```jsx // Math.random() при кожному рендері = кожен елемент перемонтується щоразу {items.map(item => <li key={Math.random()}>{item.name}</li>)} // Правильно: стабільний id, згенерований один раз при створенні елемента {items.map(item => <li key={item.id}>{item.name}</li>)} ``` **3. Зайвий key на одному умовному дочірньому елементі** ```jsx // Зміна key на умовному елементі - розмонтування і монтування при кожному перемиканні function Switcher({ active }) { return ( <div> {active ? <ExpensiveChart key="chart" /> : <Summary key="sum" />} </div> ); } // ExpensiveChart втрачає WebGL-контекст при кожному перемиканні. // Правильно: без key для одиночних умовних елементів function Switcher({ active }) { return ( <div> {active ? <ExpensiveChart /> : <Summary />} </div> ); } ``` **4. Зміна типу елемента за умовою** ```jsx // Зміна isLoading - повне розмонтування, бо div !== span function Status({ isLoading }) { if (isLoading) return <div>Завантаження...</div>; return <span>Готово</span>; } // Правильно: залишай однаковий тип елемента function Status({ isLoading }) { return <div>{isLoading ? 'Завантаження...' : 'Готово'}</div>; } ``` **5. Припущення, що реконсиляція завжди зберігає стан** Щойно `key` змінюється або тип елемента змінюється, React розмонтовує все і втрачає стан. Бачив, як команди міняли `key` щоб "скинути" форму, не помічаючи, що це вбиває анімації і WebSocket-підписки в польоті. Якщо потрібно скинути тільки поля форми - роби це явно всередині компонента. ### Де зустрічається в реальних проектах - React core: `react-reconciler` у репозиторії facebook/react обробляє всі рендери. Його також можна використовувати окремо для не-DOM середовищ - canvas, native. - Next.js app router: клієнтська навігація дифить дерево компонентів через реконсиляцію. React Server Components надсилають серіалізоване дерево, яке клієнтський reconciler зливає з вже змонтованим. - Remix: після сабміту форми відбувається стандартний прохід реконсиляції. - React DevTools Profiler: показує які компоненти перерендерились, скільки часу зайняли і чому. Запускай перед тим, як думати про `React.memo`. ### Follow-up питання **Q:** Чому O(n) замість O(n³)? **A:** Точний алгоритм диффінгу двох дерев - O(n³). React зводить це до O(n) двома евристиками: порівнює тільки елементи одного рівня і використовує keys для ідентифікації елементів списку за даними, а не за позицією. **Q:** Що відбувається, якщо не додати keys у маппованому списку? **A:** React порівнює за позицією. Видаляєш перший елемент - всі наступні "змінились" за позицією, і всі перерендеряться або перемонтуються залежно від того, чи змінився тип чи тільки контент. **Q:** Чим stack reconciler відрізняється від Fiber? **A:** Старий stack reconciler обробляв все дерево синхронно і не міг перерватись. Fiber, що зʼявився в React 16, - це work loop на зв'язних списках: його можна поставити на паузу, продовжити або скасувати. Саме це дає Concurrent Mode в React 18. **Q:** Як `useTransition` впливає на реконсиляцію в React 18? **A:** Позначає оновлення як низькопріоритетне. Scheduler через модель lanes відкладає цей диф до того, як браузер відмалює термінові кадри. Сам алгоритм реконсиляції однаковий - змінюється тільки час запуску. **Q:** Як змусити React перемонтувати компонент без зміни його типу або позиції? **A:** Змінити `key`. React сприймає інший `key` як сигнал про новий елемент, розмонтовує старий екземпляр і монтує свіжий. Корисно для скидання компонента, але стеж за побічними ефектами - підписки і анімації теж зникнуть. ## Приклади ### Базовий: key і збереження стану в списку задач ```jsx function TodoList({ todos }) { return ( <ul> {todos.map(todo => ( // Стабільний id: React зіставляє цей todo між рендерами. // Фільтрація, перестановка, додавання - локальний стан у TodoItem виживає. <TodoItem key={todo.id} todo={todo} /> ))} </ul> ); } function TodoItem({ todo }) { const [editing, setEditing] = useState(false); return ( <li> {editing ? <input defaultValue={todo.text} /> : todo.text} <button onClick={() => setEditing(!editing)}>Редагувати</button> </li> ); } // Без key={todo.id}: відкрий редагування одного todo, додай інший. // Всі TodoItem перемонтуються і відкритий input втрачає курсор. // З key={todo.id}: рендериться тільки новий todo, решта зберігає стан. ``` Стан `editing` живе всередині кожного `TodoItem`. Зі стабільним `key` React знає, який екземпляр відповідає якому todo, і зберігає його. Без стабільного key - перемонтовує все. ### Середній: фільтрований список і фокус на input ```jsx function UserList({ users, filter }) { const visible = users.filter(u => u.name.includes(filter)); return ( <ul> {visible.map(user => ( <li key={user.id}> {user.name} <input placeholder="Нотатка про користувача" /> </li> ))} </ul> ); } // З key={user.id}: написав нотатку для Аліси, змінив фільтр, // повернув Алісу - її нотатка на місці. // // З key={index}: зміна фільтра зсуває індекси. // Аліса зникає з позиції 0, Боб займає позицію 0. // React перевикористовує DOM-вузол від Аліси для Боба - нотатка Аліси тепер у Боба. ``` Саме через цю помилку інпути в списках поводяться непередбачувано. Один пропс це виправляє. Причина - React порівнює за позицією, коли нема ключа для порівняння за ідентичністю. ### Сеньйорський: навмисне перемонтування через key для скидання форми ```jsx // Навмисна зміна key щоб отримати чистий компонент при зміні userId function ProfilePage({ userId }) { return <ProfileForm key={userId} userId={userId} />; } function ProfileForm({ userId }) { const [name, setName] = useState(''); useEffect(() => { fetchUser(userId).then(user => setName(user.name)); }, [userId]); return ( <form> <input value={name} onChange={e => setName(e.target.value)} /> <button type="submit">Зберегти</button> </form> ); } // При зміні userId змінюється key. // React розмонтовує старий ProfileForm і монтує свіжий з порожнім станом. // Не потрібно вручну скидати кожне поле в useEffect. // // Компроміс: також розмонтовуються анімації і підписки всередині. // Використовуй цей патерн, коли чистий аркуш - саме те що потрібно. ``` Цей патерн часто зустрічається в адмін-панелях, де клік між записами має давати повністю свіжу форму. Простіше ніж скидати десяток полів вручну, але обовʼязково задокументуй навіщо тут `key` - інакше наступний розробник його прибере.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.