Этапы отрисовки и фиксации, снимки состояния, группировка обновлений, идентичность дерева, StrictMode и конкурентный рендеринг.
React проще отлаживать, если помнить: компонент вычисляет следующий вид интерфейса, а не редактирует экран по шагам.
Вы разберётесь, почему компонент вызывается повторно, когда React сохраняет или сбрасывает состояние и зачем он группирует обновления.
Понадобятся чистые функции-компоненты, свойства, состояние и понимание ссылок на объекты. В конце вы соберёте интерактивное дерево, где можно увидеть этапы отрисовки и фиксации, а также намеренно сохранить или сбросить состояние элемента.
Обновление ставит работу в очередь. Сначала React вызывает компоненты и вычисляет следующее дерево (этап отрисовки, render). Затем он фиксирует результат: вносит необходимые изменения в DOM (этап фиксации, commit). Первую часть React может повторить или отбросить, поэтому во время вызова компонента нельзя измерять DOM и запускать внешние действия.
Порядок этапов виден по журналу — сообщения появятся именно в такой последовательности:
// src/features/demo/PhaseLog.tsx
function PhaseLog({ label }: { label: string }) {
console.log('1. отрисовка: компонент вызван'); // может повториться или быть отброшен
useEffect(() => {
console.log('3. эффект: DOM уже обновлён');
});
return <div ref={() => console.log('2. фиксация: ref получил узел')}>{label}</div>;
}Отсюда следует практическое правило: element.getBoundingClientRect() во время вызова компонента вернёт размеры прошлого кадра, а отправка аналитики оттуда же может выполниться дважды или ни разу — React вправе отбросить незавершённую отрисовку.
Проверьте себя. Запишите в журнал вызов компонента, обратный вызов ref и useEffect. Сравните порядок сообщений.
Частая ошибка. Считать, что каждый вызов компонента обязательно изменяет DOM.
Вызов функции setCount не меняет переменную count в уже работающем обработчике. Он просит React выполнить компонент ещё раз с новым снимком (snapshot) состояния. Поэтому старый обработчик продолжает видеть прежние свойства и состояние. Если новое значение зависит от предыдущего, передавайте функцию обновления.
Сравните два обработчика: они выглядят похоже, но дают разный результат.
function addThreeWrong() {
// count в этом вызове — это снимок, он равен 0 все три раза
setCount(count + 1); // ставит в очередь «стать 1»
setCount(count + 1); // ставит в очередь «стать 1»
setCount(count + 1); // итог: 1
}
function addThree() {
setCount(value => value + 1);
setCount(value => value + 1);
setCount(value => value + 1); // итог: 3
}Каждая функция получает результат предыдущего обновления, поэтому итог увеличивается на три. Тот же механизм объясняет «устаревшее» значение в асинхронном коде:
function handleSave() {
setStatus('saving');
void save().then(() => {
// здесь status по-прежнему прежний: это снимок того вызова компонента
console.log(status); // не 'saving'
});
}Проверьте себя. Сравните три вызова setCount(count + 1) с тремя вызовами, принимающими функцию.
Частая ошибка. Ожидать, что count изменится между вызовами setCount внутри одного обработчика.
React собирает обновления, сделанные в одном обработчике, и применяет их вместе (batching). Пользователь не видит промежуточные состояния, а DOM меняется реже. Это не означает, что React сам объединит объекты состояния. flushSync нужен лишь для редких интеграций, которым требуется уже обновлённый DOM прямо сейчас.
Три обновления в одном обработчике дают одну фиксацию:
function handleSubmit() {
setStatus('saving'); //
setError(null); // одна фиксация DOM на все три
setAttempts(n => n + 1);
}Принудительная синхронная фиксация нужна там, где следующая строка обязана видеть новый DOM:
function handleAddRow() {
flushSync(() => setRows(list => [...list, createRow()])); // DOM обновлён здесь
listRef.current?.lastElementChild?.scrollIntoView(); // измеряем уже новый узел
}Вызывать flushSync после каждого обновления — значит отказаться от группировки: вместо одной фиксации браузер получит три, каждая со своим пересчётом разметки. Это заметно на списках и таблицах.
Проверьте себя. Подсчитайте фиксации DOM после нескольких обновлений в одном событии и проверьте итоговое состояние.
Частая ошибка. Вызывать flushSync после каждого обновления ради привычной пошаговой последовательности.
keyСостояние хранится у React и связано с местом компонента в дереве. Одинаковые тип и key в той же позиции сохраняют состояние. Если тип или key изменился, React создаёт новое поддерево. По этой же причине не объявляйте один компонент внутри другого: при каждом вызове получится новый тип.
key — это способ явно сказать «это другая сущность, состояние не переносить»:
// без key состояние формы перенесётся на другую заявку
<TicketForm ticket={selected} />
// с key при смене заявки форма пересоздаётся с чистым состоянием
<TicketForm key={selected.id} ticket={selected} />Объявление компонента внутри компонента ломает это правило полностью:
// ❌ Row — новый тип функции на каждой отрисовке Table: состояние сбрасывается всегда
function Table({ rows }: { rows: Row[] }) {
function RowView({ row }: { row: Row }) {
const [open, setOpen] = useState(false); // будет теряться при любом обновлении Table
return <tr onClick={() => setOpen(!open)}>{open ? row.details : row.title}</tr>;
}
return <tbody>{rows.map(row => <RowView key={row.id} row={row} />)}</tbody>;
}React сравнивает типы по идентичности функции. Новая функция на каждой отрисовке означает новый тип, поэтому старое поддерево размонтируется вместе со всем состоянием — включая фокус, введённый текст и позицию прокрутки.
Проверьте себя. Переключите две карточки одного типа с разными key и проследите, как сбрасывается форма.
Частая ошибка. Объявлять компонент строки внутри родительского компонента.
React сопоставляет соседние элементы предыдущего и нового дерева по типу и key, а затем обновляет изменившиеся свойства DOM-узлов. Детали устройства Fiber не являются контрактом приложения: нельзя опираться на число внутренних узлов или порядок их обхода.
Ключ по индексу совпадает с доменным до первой перестановки — и расходится с ней:
// ❌ после удаления первой строки ключи сдвигаются: состояние строк «съезжает»
{tickets.map((ticket, index) => <TicketRow key={index} ticket={ticket} />)}
// ✅ ключ привязан к сущности, а не к позиции
{tickets.map(ticket => <TicketRow key={ticket.id} ticket={ticket} />)}Случайный ключ — обратная крайность: он гарантированно размонтирует и создаёт узел заново на каждой отрисовке.
// ❌ полный сброс поддерева каждый раз: потеря фокуса, прокрутки, анимаций
<TicketRow key={Math.random()} ticket={ticket} />Такой приём иногда используют как «способ обновить компонент», но он решает не ту задачу: если нужно сбросить состояние при смене сущности, ключом должен быть её идентификатор.
Проверьте себя. Поменяйте тип корневого элемента поддерева и проследите за сбросом его состояния.
Частая ошибка. Использовать случайный key как универсальный способ «обновить» компонент.
В режиме разработки StrictMode намеренно повторяет вызов компонента, подключение и очистку эффекта, а также обратные вызовы ref. Так становятся заметны мутации и забытая очистка. В рабочей сборке пользовательское действие не дублируется.
Двойной вызов проявляет именно те дефекты, которые в рабочей сборке дают редкие невоспроизводимые баги:
// ❌ StrictMode покажет два обработчика: очистка не снимает подписку
useEffect(() => {
window.addEventListener('resize', () => setWidth(window.innerWidth));
// возврата нет — обработчик остаётся навсегда
}, []);
// ✅ симметричная очистка: сколько добавили, столько и снимаем
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);Обход через флаг модуля выглядит как исправление, но лишь скрывает симптом:
// ❌ утечка осталась: при повторном монтировании подписка уже не создастся вовсе
let didRun = false;
useEffect(() => {
if (didRun) return;
didRun = true;
subscribe();
}, []);После такого «исправления» компонент перестанет работать при повторном открытии страницы — а именно это делает навигация в одностраничном приложении.
Проверьте себя. Добавьте подписку с очисткой и убедитесь, что после повторной проверки остаётся один обработчик события.
Частая ошибка. Ставить флаг модуля didRun, скрывая утечку вместо её исправления.
React может приостановить вычисление дерева, продолжить его позже или отбросить, но фиксирует только согласованный результат. Это не означает, что компонент параллельно работает в отдельном потоке. Переходы отмечают несрочные обновления: ввод остаётся отзывчивым, пока тяжёлый список пересчитывается.
Разделение по срочности выражается одной строкой:
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setQuery(event.target.value); // срочно: символ должен появиться сразу
startTransition(() => setFilter(event.target.value)); // несрочно: список подождёт
}Если поменять роли и обновлять значение поля внутри перехода, ввод начнёт «залипать»: React вправе отложить несрочное обновление, и символ появится с задержкой. Управляемое поле всегда обновляется срочно.
Проверьте себя. Оставьте обновление поля ввода срочным, а пересчёт большого списка оберните в startTransition.
Частая ошибка. Поместить обновление управляемого поля в переход и получить задержку при наборе.
Диагностика не должна сама вызывать новые отрисовки или менять ссылки на свойства. Счётчик вызовов компонента полезен только вместе с данными о фиксациях и причинах обновления. React DevTools Profiler показывает длительность фиксаций, а console.log внутри компонента — все вызовы, включая отброшенные.
Счётчик в состоянии — это диагностика, которая сама создаёт то, что измеряет:
// ❌ каждое обновление счётчика вызывает новую отрисовку: бесконечный цикл
const [renderCount, setRenderCount] = useState(0);
setRenderCount(renderCount + 1);
// ✅ ref не вызывает отрисовку и не участвует в сравнении свойств
const renderCount = useRef(0);
renderCount.current += 1;Даже корректный счётчик отвечает только на вопрос «сколько раз вызван», но не «сколько это стоило». Сто вызовов по 0.05 мс дешевле одного на 30 мс, а часть вызовов может быть отброшена и вообще не дойти до DOM. Поэтому число сообщений сопоставляют с длительностью фиксаций в Profiler.
Проверьте себя. Сопоставьте сообщения компонента с фиксациями Profiler для перехода, прерванного новым вводом.
Частая ошибка. Судить о производительности только по числу сообщений в консоли, не измеряя время.
Исправьте форму редактирования каталога: состояние переезжает между строками, быстрые обновления теряются, компонент пишет во внешнее хранилище во время вычисления, а StrictMode проявляет побочный эффект. Добавьте журнал этапов, не меняя видимое поведение.
Работа готова, когда состояние связано с идентификатором записи, внешние действия запускаются из обработчика или эффекта, а серия быстрых обновлений всегда даёт одинаковый результат.
Далее: События и локальное состояние