Компонент рендерится, когда меняется его собственный стейт, когда рендерится его родитель или когда меняется контекст, который он читает. Изменение пропсов само по себе не является триггером: пропсы меняются только потому, что отрендерился родитель.
Стейт: сеттер useState или dispatch из useReducer с новым значением. Если новое значение равно текущему по Object.is, React прерывает обновление: дети не рендерятся (сам компонент React может один раз вызвать).
Рендер родителя: по умолчанию каждый дочерний компонент рендерится снова, изменились его пропсы или нет. Пропустить ребёнка с равными пропсами React позволяет memo (или React Compiler).
Контекст: каждый компонент, который читает его через useContext или use, рендерится, когда value провайдера меняется по ссылке, даже внутри memo-родителя.
Внешние сторы: подписка через useSyncExternalStore (Redux, Zustand) рендерит компонент, когда меняется выбранный снимок.
Не являются триггерами: мутация объекта или ref, изменение переменной модуля. React не следит за значениями, он реагирует только на вызов сеттеров.
Рендер означает вызов функции, которая возвращает элементы; DOM он затрагивает, только если результат отличается.
ReactSenior
Рендеринг и reconciliation
Что вызывает рендер, фазы render и commit, reconciliation и key, Fiber, конкурентный рендеринг, transitions, Suspense, гидратация
Закрыто вопросов
0 / 20
- 0Повторить
- 7Часто спрашивают
Вопросы
Компонент представляет собой функцию, которую вы пишете; элемент является простым неизменяемым объектом (type, props, key), который создаёт JSX, чтобы описать, что рендерить; экземпляр означает то, что React хранит для элемента, смонтированного на позиции в дереве: fiber с его стейтом, хуками и эффектами.
Элементы дешёвые и одноразовые: каждый рендер создаёт новые; React сравнивает их с предыдущими и выбрасывает.
Компоненты вызывает React, а не вы: <Avatar /> создаёт элемент, а прямой вызов Avatar() выполняет его как обычную функцию внутри вызывающего, и его хуки становятся хуками вызывающего.
Экземпляры живут между рендерами, пока на этой позиции сохраняются тип и ключ; именно там хранятся значения useState.
Почему это важно: стейт не хранится ни в элементе, ни в функции. Это объясняет, почему компонент, объявленный внутри другого, теряет стейт (на каждом рендере новый тип) и почему элемент, переданный вниз без изменений как children, можно пропустить.
В фазе рендера React вызывает ваши компоненты и вычисляет, что изменилось; в фазе коммита он применяет эти изменения к DOM и запускает refs и эффекты. Рендер обязан быть чистым, потому что его можно приостановить, повторить или выбросить; коммит синхронный и всегда выполняется до конца.
Фаза рендера: вызывает функции компонентов, сравнивает новые элементы с предыдущим деревом (reconciliation) и собирает список изменений. Ничего видимого ещё не происходит. При конкурентном рендеринге эту работу может прервать более срочное обновление, и она начнётся заново.
Фаза коммита: вставляет, обновляет и удаляет DOM-узлы, привязывает refs, затем синхронно, до отрисовки браузером, выполняет колбэки useLayoutEffect.
После коммита: браузер отрисовывает кадр, а колбэки useEffect выполняются после этого (пассивные эффекты), поэтому не блокируют кадр.
Почему это важно: побочные эффекты в теле рендера (запросы, подписки, мутации) могут выполниться дважды или для рендера, который так и не закоммитится. Их место в эффектах или обработчиках событий.
Рендер, который вернул тот же результат, всё равно тратит CPU, даже если коммиту нечего делать.
Parent render, Child render, Child layout effect, Parent layout effect, Child effect, Parent effect. Рендер идёт сверху вниз, потому что родитель должен выполниться, чтобы получить своих детей; эффекты выполняются снизу вверх во время и после коммита, поэтому эффект родителя видит уже смонтированных детей.
Фаза рендера: React вызывает Parent, получает элемент <Child /> и затем вызывает Child. В DOM пока ничего нет.
Фаза коммита: DOM вставляется, refs привязываются, затем layout-эффекты выполняются начиная с ребёнка, синхронно, до отрисовки браузером.
Пассивные эффекты (useEffect) выполняются после отрисовки, тоже начиная с ребёнка.
Почему с ребёнка: эффект родителя может измерить DOM-узел ребёнка или поставить на него фокус через ref, поэтому дети должны быть полностью готовы до его запуска.
**Под StrictMode** в разработке каждый рендер вызывается дважды, а каждый эффект выполняется, очищается и выполняется снова, поэтому лог длиннее, но порядок тот же.
Reconciliation (согласование) сравнивает дерево элементов нового рендера с предыдущим и решает, какие DOM-узлы сохранить, обновить или заменить. React делает это за O(n) с помощью двух эвристик: элемент другого типа означает другое поддерево, а key идентифицирует детей между рендерами.
Общий алгоритм сравнения деревьев работает за O(n³); эвристики жертвуют точностью ради скорости и верны почти для любого реального UI.
Другой тип на той же позиции (div на section, ProfileA на ProfileB): React размонтирует всё поддерево, уничтожая DOM-узлы и стейт, и монтирует новое.
Тот же тип DOM-элемента: узел сохраняется, обновляются только изменившиеся атрибуты и стили.
Тот же тип компонента: экземпляр и его стейт сохраняются, компонент рендерится с новыми пропсами.
Списки: дети сопоставляются по key; без ключей React сопоставляет по индексу, и вставка в начало делает изменёнными все элементы.
Стейт принадлежит позиции в дереве, а не функции компонента: {isAdmin ? <Panel /> : <Panel />} сохраняет один и тот же стейт в обеих ветках.
«Виртуальный DOM» представляет собой именно эти объекты элементов; скорость достигается тем, что реальный DOM трогается только там, где сравнение нашло изменение, а не тем, что сравнение бесплатное.
Один раз, при монтировании: Expensive создаётся в App и попадает в Counter как children, поэтому при ререндере Counter проп children остаётся тем же объектом, и React его пропускает. В разработке под StrictMode лог при монтировании появится дважды из-за двойного вызова рендера.
Клик меняет стейт в Counter, поэтому рендерится только Counter; App не рендерится, и созданный им ранее элемент <Expensive /> переиспользуется как есть.
Когда React встречает элемент того же типа с тем же самым объектом пропсов и без изменений стейта или контекста внутри, он пропускает его, не вызывая компонент.
Если бы Counter сам рендерил <Expensive />, каждый клик создавал бы новый элемент с новыми пропсами, и Expensive писал бы в лог на каждом клике.
Этот приём «поднять содержимое наверх как children» является самой дешёвой оптимизацией рендеров: без memo и хуков, только за счёт того, где создаётся элемент.
React выбрасывает результат, который компонент только что вернул, и сразу вызывает его снова с новым стейтом, до рендера детей и до коммита. Это допустимо только под условием, которое останавливает повтор, чтобы скорректировать стейт при смене пропса; безусловный вызов зацикливается до ошибки «Too many re-renders».
Только собственный стейт: изменение стейта родителя или другого компонента во время рендера вызывает предупреждение «Cannot update a component while rendering a different component».
Приём: храните предыдущий проп в стейте, сравнивайте во время рендера и обновляйте оба значения. Дети рендерятся один раз и уже со скорректированным стейтом.
Почему не эффект: useEffect(() => setSelection(null), [items]) коммитит и отрисовывает один кадр с устаревшим выделением, а затем заново рендерит всё поддерево.
Сначала попробуйте варианты проще: вычислите значение во время рендера (selection можно получить из id) или сбросьте всё через key.
Конкурентный рендеринг позволяет React готовить новый UI в фоне, приостанавливать его ради чего-то более срочного и выбрасывать, если он больше не нужен. React 18 включил его для всех приложений на createRoot, но действует он только на обновления, которые вы пометили как несрочные.
Включается для каждого обновления отдельно: startTransition, useTransition и useDeferredValue помечают низкоприоритетную работу; обычный setState по клику остаётся синхронным и срочным.
Это не параллелизм: JavaScript по-прежнему выполняется в одном потоке. React рендерит небольшими порциями и между ними уступает поток браузеру, поэтому ввод остаётся отзывчивым во время тяжёлого рендера.
Автоматический батчинг: начиная с 18-й версии батчатся и обновления в промисах, таймаутах и нативных обработчиках, а не только внутри событий React.
Suspense во время transition оставляет на экране уже показанный контент вместо спиннера, а потоковый SSR с выборочной гидратацией построен на том же механизме.
Последствия: рендер может выполниться больше одного раза на коммит, поэтому должен быть чистым; внешним мутабельным сторам нужен useSyncExternalStore, чтобы избежать tearing.
Отдельного «concurrent mode» больше нет; ReactDOM.render, legacy root, удалён в React 19.
Срочное обновление рендерится синхронно и коммитится до следующего кадра; transition рендерится в фоне, может быть прерван и держит текущий UI, пока новый не готов. Если пользователь печатает посреди transition, React бросает этот рендер, коммитит нажатие и запускает transition заново.
**isPending** из useTransition равен true, пока transition выполняется, поэтому можно приглушить старый контент вместо того, чтобы замораживать страницу.
Suspense внутри transition не заменяет уже видимый контент на fallback; React держит старый экран, пока у нового не появятся данные.
Не для контролируемых инпутов: собственный стейт инпута нужно обновлять срочно, иначе ввод начнёт лагать; в transition кладите только дорогое следствие (фильтрацию, переключение вкладок).
React 19: колбэк может быть async, и тогда он становится Action; isPending остаётся true, пока промис не завершится. Обновления стейта после await пока нужно оборачивать в отдельный startTransition.
Transition не делает медленный компонент быстрее, он делает его рендер прерываемым.
useDeferredValue(value) во время срочного рендера возвращает предыдущее значение, а затем в фоне ререндерит с новым, поэтому медленная часть UI отстаёт от быстрого ввода, не блокируя его. Используйте его для дорогого рендеринга, а debounce для случаев, когда дорогим является сетевой запрос.
Нет фиксированной задержки: на быстром устройстве отложенный рендер завершается почти сразу, на медленном ждёт; debounce везде ждёт свои 300 мс.
Прерываемость: каждое новое нажатие бросает устаревший фоновый рендер, поэтому не приходится ждать, пока отрисуются промежуточные значения.
**Используйте вместе с memo на медленном дочернем компоненте: без него ребёнок всё равно рендерится со старым значением во время срочного прохода, и выигрыша нет.
Индикатор устаревания**: сравните value !== deferredValue, чтобы приглушить неактуальные результаты.
Сеть: откладывание не уменьшает количество запросов; это делает debounce (или TanStack Query с ключом после debounce).
React 19 добавил аргумент initialValue, который используется в первом рендере, до того как реальное значение будет отложено.
Компонент приостанавливается, когда читает что-то ещё загружающееся: React прекращает рендерить это поддерево, показывает fallback ближайшей границы Suspense и повторяет рендер, когда ожидаемый промис завершится. React 19 сразу коммитит этот fallback, а приостановленных соседей пре-рендерит уже потом.
Что приостанавливает: lazy, пока грузится его чанк, use(promise), пока промис не завершится, библиотеки с поддержкой Suspense вроде useSuspenseQuery из TanStack Query, асинхронные серверные компоненты. Загрузка в useEffect не приостанавливает.
От приостановленного первого рендера ничего не сохраняется: при повторе React рендерит дерево заново, поэтому промис должен приходить из кэша, loader-а или фреймворка. Промис, созданный во время рендера, каждый раз новый и никогда не успевает завершиться.
Контент, который уже был виден: если срочное обновление заставляет его приостановиться, React прячет его (сохраняя стейт) и показывает fallback; внутри transition он вместо этого оставляет старый контент на экране.
Изменение в React 19: раньше React рендерил всех соседей приостановленного компонента до коммита fallback; теперь fallback коммитится сразу, а соседи «прогреваются» отдельным рендером, поэтому их lazy-запросы всё равно стартуют рано.
Ошибки: отклонённый промис уходит в ближайший error boundary, а не в Suspense.
Гидратация означает, что React на клиенте берёт HTML, отрендеренный на сервере, строит своё дерево поверх существующего DOM и навешивает обработчики, а не создаёт DOM заново. Выборочная гидратация гидратирует каждую границу Suspense отдельно, начиная с той, с которой взаимодействует пользователь.
Точка входа: hydrateRoot(document.getElementById('root')!, <App />) вместо createRoot.
До React 18: гидратация работала по принципу «всё или ничего»; вся страница должна была скачать JS, отрендериться и гидратироваться, прежде чем заработает хоть одна кнопка.
Потоковый SSR: renderToPipeableStream (Node) или renderToReadableStream (Web streams) отправляют сначала каркас, а HTML каждой границы Suspense по мере готовности её данных.
Выборочная гидратация: React гидратирует границы независимо; если пользователь кликнул внутри ещё не гидратированной, React повышает её приоритет, гидратирует её первой и воспроизводит клик заново.
Контракт: первый клиентский рендер должен дать тот же результат, что и сервер. Чтение window, Date.now() или localStorage во время рендера нарушает его и вызывает расхождение гидратации (hydration mismatch); такие чтения нужно переносить в эффект.
Fiber представляет собой reconciler React начиная с 16-й версии: каждый экземпляр элемента становится fiber-узлом, единицей работы, которую React может приостановить, продолжить или выбросить. Старый stack reconciler обходил всё дерево одним синхронным рекурсивным вызовом, и большое обновление блокировало ввод до своего конца.
Что такое fiber
Узел на каждый экземпляр компонента или host-элемент, который хранит его тип, пропсы, стейт (список хуков), эффекты и указатели на child, sibling и return (родителя).
Связный список вместо стека вызовов: React обходит дерево циклом, которым управляет сам, поэтому может остановиться после любого fiber и продолжить позже.
Двойная буферизация: дерево current соответствует тому, что на экране; обновления строят дерево workInProgress, связанное через alternate, а коммит меняет их местами.
Что это сделало возможным
Прерываемый рендеринг: все конкурентные возможности (transitions, useDeferredValue, Suspense) опираются на то, что React уступает поток между fiber-узлами.
Приоритеты: каждое обновление несёт lane; React сначала обрабатывает срочные lanes и может выбросить наполовину сделанную низкоприоритетную работу.
Error boundaries, фрагменты, порталы и возврат массивов появились вместе с Fiber, потому что новое дерево умело их представлять.
На собеседовании главное назвать компромисс: работа рендера стала прерываемой, поэтому рендер обязан быть чистым, а коммит остаётся синхронным.
React проходит новых детей слева направо, переиспользует fiber с тем же ключом и перемещает DOM-узел, только когда его элемент теперь стоит после того, который раньше шёл за ним. Худший случай: перенос последнего элемента в начало. React оставляет этот элемент на месте и перемещает все остальные узлы после него.
Алгоритм
Первый проход: сравнивает старых и новых детей на одинаковых индексах, пока ключи совпадают; добавления и удаления в конце дёшево обрабатываются здесь же.
Проход по Map: на первом несовпадении оставшиеся старые fiber-узлы складываются в Map по ключу, и каждый новый ребёнок забирает свой старый fiber (стейт и DOM), если совпадает тип.
Перемещения: React отслеживает наибольший старый индекс среди уже переиспользованных (lastPlacedIndex); переиспользованный элемент с меньшим старым индексом перемещается, остальные остаются на месте. Старые fiber-узлы, оставшиеся в Map, удаляются.
Шага с наибольшей возрастающей подпоследовательностью нет (в Vue 3 и Inferno он есть), так что React меняет минимальное число перемещений на простой однопроходный алгоритм.
Последствия
Последний элемент в начало перемещает n - 1 узлов; первый элемент в конец перемещает один.
Индексы в качестве ключей превращают перестановку в «каждый элемент получил новые пропсы»: перемещений нет, но перерендериваются все строки, а стейт остаётся привязан не к той строке.
Нестабильные ключи (Math.random(), id, сгенерированные во время рендера) перемонтируют каждый элемент на каждом рендере.
Для длинных списков, которые часто переупорядочиваются, виртуализация (меньше узлов) помогает больше, чем подбор порядка.
Каждому обновлению назначается lane, бит в битовой маске, который кодирует его приоритет, и React всегда сначала рендерит самые срочные ожидающие lanes. Несрочные lanes рендерятся порциями примерно по 5 мс, после чего React уступает поток браузеру и проверяет, не пришло ли что-то срочнее.
Lanes
Приоритет зависит от того, где произошло обновление: клик или нажатие клавиши получают синхронную lane, непрерывные события вроде scroll или pointer move получают lane выше обычной, обычные обновления получают default lane, а startTransition назначает одну из transition lanes. Idle и offscreen-работа находятся в самом низу.
Битовая маска, а не одно число: React может объединить несколько lanes в один рендер и отслеживать, какие из них ожидают, приостановлены или уже выполнены.
Голодание (starvation): низкая lane, которую постоянно прерывают, со временем помечается как просроченная и рендерится синхронно, поэтому transition нельзя откладывать бесконечно.
Time slicing и прерывание
Синхронная lane: рендерится за один проход без уступок потока, поэтому срочные обновления ощущаются мгновенными.
Конкурентные lanes: рабочий цикл проверяет shouldYield() между fiber-узлами; пакет Scheduler ставит следующую порцию задачей через MessageChannel, чтобы события ввода успели выполниться.
Прерывание: если срочное обновление приходит посреди transition, React выбрасывает недостроенное дерево workInProgress, рендерит и коммитит срочное обновление, а затем заново запускает transition поверх нового стейта.
Напрямую с этим работать почти не приходится; зато это объясняет, почему transition может рендериться несколько раз и почему побочные эффекты в фазе рендера опасны.
Сигнал представляет собой реактивное значение, которое запоминает, кто его читает: при изменении обновляются только эффекты и DOM-привязки, которые его читали, а функция компонента заново не запускается. React вместо этого перезапускает компонент с новым снимком состояния и сравнивает результат, обменивая лишнюю работу на более простую модель.
Как работают сигналы
Отслеживание: чтение count() в Solid или Angular или count.value в Preact внутри эффекта или шаблона подписывает этот код; запись уведомляет ровно этих подписчиков.
Компоненты выполняются один раз в Solid: функция строит DOM и привязки, а дальнейшие обновления идут прямо в текстовый узел или атрибут, без diff виртуального DOM.
На той же идее построены ref во Vue, руны Svelte 5 и MobX, а предложение TC39 нацелено на добавление сигналов в сам JavaScript.
Почему React продолжает перерендеривать
Обычный JavaScript в рендере: значения состояния остаются обычными переменными, поэтому деструктуризация, ранний return и любые выражения просто работают. У сигналов есть правила отслеживания: в Solid деструктуризация пропсов или чтение сигнала вне отслеживаемой области молча теряют реактивность.
Рендер видит один снимок: каждое значение зафиксировано на время рендера, и именно это позволяет конкурентному рендерингу готовить новый UI в фоне и выбрасывать его. Общие изменяемые сигналы делают такую согласованность намного сложнее.
Стоимость ограничивается иначе: мемоизация пропускает неизменившиеся поддеревья, а React Compiler теперь расставляет её автоматически, что сокращает разрыв, не меняя модель.
На практике
Точечные обновления выигрывают в UI с множеством мелких частых изменений (живой дашборд, сетка электронной таблицы); React выигрывает экосистемой и кодом, который читается сверху вниз.
Сторы в стиле сигналов, такие как Jotai или MobX, подключаются к React, но они сужают круг перерендеривающихся компонентов, а не отменяют рендер.
Во время transition React отказывается прятать уже видимый контент: если новый экран приостанавливается внутри границы, которая уже показывает контент, React держит старый UI (с isPending равным true), пока не придут данные. Границы, которые монтируются впервые, по-прежнему показывают свои fallback-и.
Правила
Срочное обновление, которое приостанавливается: ближайшая граница сразу переключается на fallback, даже если показывала контент. Это и есть эффект «страница мигает спиннером».
Transition, который приостанавливает уже раскрытую границу: React ждёт и держит старый контент; роутеры (React Router, Next.js) оборачивают навигацию в transitions именно поэтому.
Новые границы внутри приходящего экрана могут показывать fallback-и, поэтому каркас страницы рендерится, а медленные части подгружаются потоком.
Как управлять поведением
Расставляйте границы осознанно: одну вокруг каркаса страницы, внутренние вокруг независимо медленных виджетов. Слишком много границ заставляют страницу появляться кусками, как попкорн; React придерживает раскрытие (примерно 300 мс), чтобы сгруппировать их.
Принудительный свежий fallback для контента, который является другой сущностью, а не обновлением: key на границе, например <Suspense key={productId}>, делает её новой границей.
Показывайте прогресс во время ожидания: используйте isPending (или состояние навигации роутера), чтобы приглушить старую страницу или показать полосу загрузки сверху.
Чего избегать: оборачивать в transition инпут, который запускает поиск, иначе поле перестанет реагировать, пока результаты приостановлены.
setTab выполняется после await, вне синхронной области startTransition, поэтому React считает его обычным срочным обновлением. Срочное обновление, из-за которого Posts приостанавливается, скрывает уже показанную вкладку и выводит fallback; оберните обновление после await в отдельный startTransition.
Почему пометка теряется: React помечает обновления как transition, только пока колбэк выполняется синхронно. После await код продолжается в более позднем микротаске, и JavaScript пока не умеет переносить туда этот контекст; документация React ожидает, что ограничение снимет предложение AsyncContext.
Что видит пользователь: пока идёт await, isPending равен true; затем срочный setTab сразу коммитится, старая вкладка получает display: none, появляется спиннер, а isPending уже снова false, хотя спиннер ещё на экране.
С исправлением обновление входит в transition: React оставляет старую вкладку видимой, isPending остаётся true, пока у Posts нет данных, а затем подменяет вкладку одним коммитом.
**Каждый await сбрасывает пометку**: action с несколькими await требует startTransition вокруг каждого обновления состояния, которое идёт после них.
**useActionState применяет возвращённое значение сам**: если вернуть новое состояние из action вместо вызова сеттера после await, оно закоммитится внутри transition.
flushSync(fn) из react-dom синхронно рендерит и коммитит обновления, сделанные внутри fn, поэтому после возврата DOM уже обновлён. Он нужен только тогда, когда императивный код сразу после обновления должен увидеть новый DOM: прокрутить к новому элементу, измерить его или передать в библиотеку не на React.
Обычное поведение: обновления батчатся и коммитятся после завершения обработчика, поэтому listRef.current.lastChild сразу после setItems всё ещё показывает старый список.
Типичные случаи: прокрутка или фокус к только что отрендеренному элементу, событие браузера beforeprint (снимок для печати делается синхронно), интеграционный код, который сразу читает layout.
Цена: для этого обновления отключаются батчинг и конкурентный рендеринг, а ожидающие границы Suspense могут быть вынуждены показать fallback.
Не вызывайте его из рендера или эффекта: React выдаёт предупреждение («flushSync was called from inside a lifecycle method») и не может применить обновления, пока уже рендерит.
Часто более чистой альтернативой будет эффект или ref-колбэк на новом элементе.
React поднимается до ближайшего error boundary и рендерит его fallback; если границы нет, он размонтирует весь корень, и страница становится пустой. React 19 сообщает о каждой такой ошибке один раз через опции корня: onCaughtError, если её обработала граница, и onUncaughtError, если нет.
Сначала повтор: если ошибка случилась при конкурентном рендере, React один раз синхронно повторяет рендер дерева, прежде чем считать её настоящей, на случай если её вызвало несогласованное промежуточное состояние.
**onRecoverableError: ошибки, которые React исправил сам, в основном расхождения гидратации, после которых он перешёл на клиентский рендер. Их стоит логировать: они стоят производительности и указывают на разный вывод сервера и клиента.
Один лог на ошибку**: React 18 повторно бросал пойманные ошибки, и они появлялись в консоли дважды; React 19 логирует каждую один раз и передаёт в колбэки errorInfo.componentStack.
Куда отправлять: в Sentry или ваш сервис логирования из этих колбэков, так вы получите один отчёт на ошибку со стеком компонентов.
Что сюда никогда не попадает: ошибки в обработчиках событий и в асинхронном коде после рендера. Это обычные ошибки JavaScript (window.onerror, unhandledrejection).