open atlas
↑ К треку
React с нуля до senior RCT · 01 · 02

Reconciliation и keys: где на самом деле живёт ваше состояние

React сравнивает по типу элемента и key, а не по «экземплярам»: тот же тип на той же позиции сохраняет fiber и его состояние, смена типа размонтирует поддерево. Index в роли key привязывает состояние к позиции — пересортировка молча отдаёт состояние одной строки другой.

RCT Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Баг-репорт читался как история о привидениях: «Я печатал ответ своему руководителю, и черновик переехал в переписку с кандидатом. Я чуть не отправил кандидату обсуждение зарплаты». Сайдбар мессенджера показывал переписки, отсортированные по последней активности; в каждой строке жил неконтролируемый input с черновиком, и всё это рендерилось с key={index}. Пока инженер поддержки печатал в строке 0, в другом треде пришло новое сообщение, список пересортировался, и строкой 0 стала другая переписка. React сделал ровно то, что ему сказали: элемент на позиции 0 имел тот же тип и тот же key, что и раньше, поэтому он сохранил существующий fiber — а именно fiber владел состоянием инпута. Текст черновика никуда не «переезжал». Он остался прикручен к позиции 0, пока данные под ним уехали. Ни исключения, ни предупреждения, ни упавшего теста — diff был корректен по правилам React; просто правилам сообщили, что позиция и есть идентичность. Трое пользователей наступили на это раньше, чем баг сумели воспроизвести: для репро требовалось фоновое событие точно посреди набора текста.

Diff — это эвристика, а не поиск по дереву

Настоящий минимальный diff двух деревьев стоит O(n³) — для тысячи элементов это порядка миллиарда сравнений, мертворождённая идея для UI, который ре-рендерится на каждое нажатие. React отказывается играть в эту игру и ставит на две эвристики, почти всегда верные в реальных интерфейсах. Первая: элементы разных типов порождают разные деревья — если на позиции был div, а стал section, или был UserProfile, а стал LoginForm, React внутрь не заглядывает. Он размонтирует всё старое поддерево (уничтожая всё состояние в нём, выполняя cleanup-эффекты, удаляя DOM) и монтирует новое с нуля. Вторая: разработчик может пометить стабильную идентичность между рендерами через key. С этими двумя допущениями reconciliation выполняется за O(n).

Само сравнение идёт позиция за позицией. Тот же тип на той же позиции: React сохраняет существующий fiber (внутренний узел, где живут состояние хуков, DOM-узел и флаги изменений), обновляет его пропсы и рекурсивно спускается в детей — экземпляр компонента выживает, его хуки выживают. Другой тип: полный снос этого поддерева. Поэтому же сравниваются именно описания-элементы, а не «экземпляры» компонентов — элементы пересоздаются свежими объектами на каждом рендере и не несут никакого состояния. Это одноразовые описания; персистентная сущность, с которой их сопоставляют, — fiber.

Состояние живёт на fiber, а key — правило сопоставления

Если ты когда-нибудь удивлялся, почему инпут сохраняет набранный текст между ре-рендерами — вот ответ: всё, что выдаёт useState, хранится на fiber — не в вашем JSX и не в замыкании функции: функция отработала и вернулась, её локальные переменные исчезли. Так что у вопроса «где текст моего инпута?» есть точный ответ: на том fiber, который React сопоставил вашему элементу при последнем reconciliation. Для одиночного ребёнка сопоставление идёт по позиции и типу. Для массива соседей позиция бессмысленна — списки пересортировывают, фильтруют, дополняют сверху — поэтому соседей React сопоставляет по key: старый fiber с key «a» соединяется с новым элементом с key «a», куда бы тот ни переехал. Сопоставленный fiber сохраняет состояние и свой DOM-узел, а перемещение стоит одну перестановку DOM-узла вместо сноса.

Теперь механизм поломки из пролога, точно. С key={index} ключ и есть позиция — вы переформулировали дефолт и отключили весь механизм сопоставления. Пересортируйте данные, и React видит: на позиции 0 всё ещё key 0, тот же тип → тот же fiber → то же состояние. Fiber, державший черновик для руководителя, теперь получает пропсы переписки с кандидатом. Всё, что живёт на fiber или его DOM-узле, прилипает к позиции вместо того, чтобы следовать за данными: текст неконтролируемого инпута, чекбоксы, фокус, позиция скролла, useState внутри строки, даже мемоизированные значения. Удаление первого элемента — тот же баг в другом костюме: каждый fiber сдвигается на новый элемент данных, а размонтируется последний fiber — отсюда «я удалил первую строку, но пропали заметки второй, а первая сохранила заметки удалённого тикета».

const [convos, setConvos] = useState(initialConvos);
// convos пересортируется при каждом новом сообщении в треде

// Баг: key повторяет позицию. После пересортировки fiber (и состояние
// неконтролируемого инпута на нём) на позиции 0 сопоставляется с тем,
// какой разговор теперь оказался на позиции 0.
{convos.map((c, index) => (
  <ConversationRow key={index} convo={c} />
))}

// Исправление: key = стабильная идентичность из данных. Fiber следует за
// разговором куда угодно; React переставляет DOM-узел.
{convos.map((c) => (
  <ConversationRow key={c.id} convo={c} />
))}

Два следствия, о которых сеньоров спрашивают на ревью. key={Math.random()}противоположный баг, и строго хуже: ни один key никогда не совпадает, поэтому каждый рендер размонтирует и заново монтирует все строки — состояние стирается на каждом рендере, эффекты переигрываются, DOM перестраивается целиком; таблица на 200 строк превращается из горстки точечных патчей в 200 циклов снос-и-создание на каждое нажатие. И второе: key обязан быть уникален только среди соседей, не глобально — item.id годится, даже если другой список в приложении использует те же id.

Почему это работает

Почему команда React не стала диффить по идентичности «экземпляров» компонентов и не обошлась без key вовсе? Потому что идентичности экземпляров не существует. Функция компонента отрабатывает и возвращает простые объекты-элементы; convos.map на каждом рендере порождает новый массив новых объектов. Ничто в JavaScript не связывает «второй элемент массива прошлого рендера» со «вторым элементом массива этого рендера», кроме того, что React может прочитать с объектов: тип, позиция, key. Key — это разработчик, одалживающий React единственный факт, который тот не может вывести сам: какое описание относится к той же логической сущности, что и раньше. Поэтому же предупреждение про key — warning, а не ошибка: сопоставление по индексу честно годится для списка, который никогда не пересортировывается, не фильтруется и не держит состояния — статический список ссылок в футере ничего не теряет.

Идентичность элемента vs идентичность компонента: сбросы, которых вы не заказывали

Обратная сторона правила «тот же тип сохраняет состояние»: тот же тип компонента на той же позиции сохраняет состояние даже между совершенно разными ветками JSX. Отрендерите <Counter person="Taylor" /> в true-ветке тернарника и <Counter person="Sarah" /> в false-ветке: переключение не сбрасывает счётчик. React видит позицию 1, тип Counter — оба раза: тот же fiber, пропсы обновлены, состояние сохранено. Если логически это разные счётчики, это баг, и лечится он разными key у веток — key="taylor" и key="sarah" — превращая key в осознанный рычаг сброса. Тем же рычагом сбрасывают форму при смене сущности: <ProfileForm key={userId} /> перемонтирует всю форму при переключении пользователя, вместо того чтобы правки одного пользователя протекли в профиль другого.

И обратный сценарий отказа: определение компонента внутри другого компонента. Внутренняя функция пересоздаётся на каждом рендере родителя, поэтому её идентичность каждый раз другая; для функциональных типов React сравнивает по ссылке, видит «другой тип» на той же позиции — и размонтирует поддерево: каждый рендер родителя стирает состояние ребёнка, сбрасывает фокус, переигрывает эффекты монтажа. Выглядит как «мой инпут теряет фокус на каждом нажатии» и входит в число самых частых не-багов в трекере React. Поднимите определение на уровень модуля; нужны данные родителя — передайте пропсы.

Выбери лучший вариант

Сайдбар чатов рендерит список переписок, отсортированных по последней активности. В каждой строке — неконтролируемый черновик. Приходит новое сообщение и пересортировывает список посреди набора текста. Какая стратегия key верна?

Викторина

Сортируемый список строк с неконтролируемыми инпутами использует key={index}. Список пересортировался, пока пользователь печатал в верхней строке. Почему набранный текст оказывается под другим элементом данных?

Викторина

Разработчик определил ChildInput как функцию внутри тела компонента Parent. Пользователи жалуются, что инпут теряет фокус и очищается на каждом нажатии. Каков механизм?

Вспомните перед уходом
  1. 01
    Объясните точный механизм, которым key=index портит состояние при пересортировке, включая то, где состояние физически живёт.
  2. 02
    Когда React размонтирует поддерево, хотя вы не убирали его из JSX, и как использовать key, чтобы форсировать или предотвращать сбросы осознанно?
Итог

Reconciliation — это ответ React за O(n) на задачу за O(n³), построенный на двух эвристиках: разные типы элементов порождают разные деревья, а key помечает стабильную идентичность. Каждый рендер производит новенькие объекты-элементы — чистые описания без состояния — и React сопоставляет их с персистентным деревом fiber, где на самом деле живут состояние хуков, значения неконтролируемых инпутов, фокус и DOM-узлы. Тот же тип и key на позиции: fiber выживает, пропсы обновляются, дети согласуются дальше. Смена типа: всё поддерево размонтируется — состояние уничтожено, cleanup-и выполнены, DOM удалён — и строится заново. Для массивов соседей сопоставление идёт по key, и потому index-в-роли-key — канонический продакшен-баг: key переформулирует позицию, и пересортировка сопоставляет fiber позиции 0 (вместе с набранным в него черновиком) с тем элементом данных, что теперь занял позицию 0, а удаление сдвигает все пары на единицу, размонтируя последнюю строку вместо удалённой. Случайные key ломаются в другую сторону, перемонтируя каждую строку на каждом рендере. Стабильный id из данных заставляет состояние и DOM следовать за логическим элементом ценой перестановки узла вместо сноса. Идентичность режет в обе стороны: тот же тип компонента на той же позиции сохраняет состояние между ветками JSX, хотели вы того или нет, а смена key — осознанный рычаг сброса: key, равный userId, перемонтирует форму при смене сущности. Скрытая смена типа — определение компонента внутри другого компонента, пересоздающее ссылку функции каждый рендер, — перемонтирует поддерево на каждом рендере родителя и выглядит как потеря фокуса инпутом на каждом нажатии. Тип плюс key — это идентичность; идентичность решает, выживет ли состояние. Теперь, когда встретишь состояние, «прилипшее» не к тем данным, или инпут, теряющий фокус на каждом нажатии — ты знаешь, с какого правила начинать разбор.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.