Каждый раз, когда я пытался разобраться, как устроен React Update Lifecycle, у меня возникали одни и те же вопросы. Что такое Virtual DOM? Где заканчивается Render Phase? Когда обновляется DOM? Почему useLayoutEffect срабатывает раньше useEffect? И в какой момент React вообще применяет изменения?
Поэтому я решил наглядно визуализировать жизненный цикл обновления в React 18+, разбить весь процесс на фазы и объединить их в одну схему. Под схемой — детальный разбор каждого шага.
Схема

1. Trigger: что запускает обновление React
Есть два базовых триггера обновления:
-
Первичный render приложения: React впервые строит дерево от root‑компонента.
-
Изменение state: компонент, reducer, context provider или внешний store сообщает React, что данные изменились.
Все остальные обновления являются следствием этих двух триггеров. Например, если родительский компонент изменил state, React повторно рендерит этот компонент и передаёт обновлённые props дочерним. Аналогично при изменении value у Context.Provider React повторно рендерит компоненты, использующие этот context.
Примеры вызова обновления:
setCount(currentValue + 1);setUser((currentUser) => ({ ...currentUser, profile: { ...currentUser.profile, city: 'Prague', },}));
Для объектов и массивов важна новая ссылка. Обычно это делается через spread‑синтаксис, как в примере с setUser выше.
Неправильный вариант — мутировать старый объект и передавать ту же ссылку:
user.profile.city = 'Prague';setUser(user); // ссылка на объект осталась прежней, React не обнаружит измененияitems.push(nextItem);setItems(items); // ссылка на массив осталась прежней, React не обнаружит изменения
Ещё одна ошибка — менять локальную переменную вместо state:
let isOpen = false;function openModal() { isOpen = true; // изменение переменной не вызывает ререндер}
Важно: если значение должно менять UI, React должен узнать о нём через state, props, context или внешний store, на который компонент подписан.
Обновление начинается с конкретной точки дерева компонентов. Если изменяется state компонента, React повторно выполняет этот компонент и затем обходит всё поддерево этого компонента. Если обновляется Root, обход начинается с корня дерева компонентов.
Вызов setState не меняет значение state внутри текущего render. Он ставит обновление в очередь.
function Counter() { const [count, setCount] = useState(0); function handleClick() { setCount(count + 1); console.log(count); // старое значение из текущего render } return <button onClick={handleClick}>{count}</button>;}
Переменная count внутри handleClick содержит значение состояния, доступное компоненту во время текущего рендера. Вызов setCount не изменяет эту переменную, а только планирует обновление состояния. Новое значение станет доступно после следующего рендера.
Batching: оптимизация очереди обновлений
React может сгруппировать несколько state updates из одного обработчика и выполнить один render с итоговым состоянием. Это называется batching.
function handleClick() { setCount(1); setCount(2); setCount(3); // В результате выполнится только 1 рендер, а итоговое значение count станет равным 3}
Если новое значение зависит от предыдущего, используйте functional update:
setCount((current) => current + 1);
Это особенно важно, если новое состояние вычисляется на основе предыдущего значения:
setCount((current) => current + 1);setCount((current) => current + 1);setCount((current) => current + 1);
Такой код не схлопывается в одно+1. React кладёт три update‑функции в очередь, а во время следующего render применяет их последовательно: 0 → 1 → 2 → 3. При обычном batching это приводит к одному render и одному commit с итоговым значением, а не к трём отдельным render/commit.
2. Render phase: вычисления, вызов компонентов и reconciliation
Во время Render Phase вычисляется следующее состояние интерфейса. На этом этапе физический DOM ещё не изменяется. React последовательно обрабатывает дерево компонентов: выполняет компонент, получает возвращённые им React Elements, выполняет сопоставление с предыдущим UI, а затем переходит к следующему компоненту.
Начиная с React 18, благодаря Concurrent Rendering, Render Phase больше не обязана выполняться как одна непрерывная операция. React может начать render, приостановить его, переключиться на более приоритетное обновление, а затем продолжить или полностью отбросить незавершённую работу.
Первый шаг — Выполнение компонента
Под «выполнением» подразумевается вызов метода render() для классовых компонентов или прямой вызов тела функции для функциональных компонентов. Именно на этом этапе React передаёт в компонент актуальные props, state и context.
Компонент должен вести себя предсказуемо: при одинаковых props, state и context он должен возвращать одинаковое описание UI. Он не должен изменять внешнее состояние, подписываться на события, выполнять запросы или вручную изменять DOM.
Частые ошибки во время рендера:
-
Изменение
propsнапрямую — нарушает однонаправленный поток данных.propsмогут содержать ссылки на объекты, принадлежащие родителю. Их мутация изменяет общие данные без уведомления React и может привести к неожиданному поведению других компонентов; -
Мутация
state— нельзя изменятьstateнапрямую. Для объектов и массивов мутация меняет данные по той же ссылке, поэтому React считает, что значение осталось прежним и не выполняет обновление. Для примитивных значений прямое присваивание, например,count = 1, также не обновляет состояние React, так как изменение происходит вне механизмаsetState; -
Создание подписок внутри тела компонента — каждый render может создавать новую подписку без удаления предыдущей, что приводит к утечкам ресурсов и повторному вызову обработчиков;
-
Ручное изменение DOM — React не знает об этих изменениях и при следующем commit может перезаписать их своими обновлениями;
-
Вызов
setStateв теле компонента — каждый render запускает новое обновление состояния, что может привести к бесконечному ререндору.
Плохой пример:
function UserList({ users }: { users: User[] }) { users.sort((a, b) => a.name.localeCompare(b.name)); // мутирует props return users.map((user) => <UserRow key={user.id} user={user} />);}
Нормальный вариант:
function UserList({ users }: { users: User[] }) { const sortedUsers = [...users].sort((a, b) => a.name.localeCompare(b.name)); return sortedUsers.map((user) => <UserRow key={user.id} user={user} />);}
В StrictMode React в development специально вызывает некоторые функции дважды. Повторный вызов помогает обнаружить побочные эффекты и убедиться, что компонент корректно работает при повторном выполнении.
Второй шаг — React Elements: результат выполнения компонентов
Во время выполнения компонента JSX преобразуется в обычные JavaScript‑объекты — React Elements. Каждый такой объект описывает элемент будущего интерфейса и содержит его тип, свойства, ключ и служебные данные.
Создание React Elements не означает выполнение вложенных компонентов. Родительский компонент лишь возвращает описание дерева элементов, сообщая, какие HTML‑теги и компоненты должны находиться в каждом узле этого дерева.
Если элемент представляет собой HTML‑тег, например <main>, в поле type записывается строка 'main'. Если же элемент представляет пользовательский компонент, например <UserCard />, в поле type сохраняется ссылка на функцию компонента, а не результат выполнения этой функции:
// В родительском компоненте App:<main> <UserCard user={user} /></main>
До начала выполнения компонентаUserCard, родительский компонент App вернёт объект, где поле type дочернего элемента содержит ссылку на сам компонент:
{ type: "main", props: { children: { type: UserCard, // Ссылка на функцию UserCard props: { user: user } } }}
Затем React продолжает обход дерева компонентов. Когда он встречает элемент с type: UserCard, то вызывает функцию этого компонента, передаёт ей актуальные props и получает новый набор React Elements:
function UserCard({ user }: { user: User }) { return ( <article className="card"> <h2>{user.name}</h2> <p>{user.email}</p> </article> );}
Результатом выполнения компонента снова становятся React Elements. На этот раз они описывают уже не другие пользовательские компоненты, а конечные host‑элементы — article, h2 и p.
JavaScript
{ type: "article", props: { className: "card", children: [ { type: "h2", props: { children: user.name, }, }, { type: "p", props: { children: user.email, }, }, ], },}
На этом этапе React уже получил описание нового состояния интерфейса, однако DOM остаётся без изменений. Перед тем как обновить страницу, React выполняет ещё один этап обработки.
Почему DOM вообще не меняется сразу? И что такое Virtual Dom?
DOM — объектная модель HTML‑документа, предоставляемая браузером. JavaScript взаимодействует с ней через DOM API: создаёт элементы document.createElement(), изменяет атрибуты, вставляет и удаляет узлы.
Операции с DOM дороже, чем работа с обычными JavaScript‑объектами, потому что DOM‑узел связан с несколькими внутренними подсистемами браузера:
-
системой вычисления стилей CSS;
-
layout, расчётом размеров и положения элементов;
-
конвейером отрисовки, rendering pipeline;
-
системой обработки событий;
-
деревом доступности, Accessibility Tree.
Кроме того:
-
чтение свойств, таких как
getBoundingClientRect(), может привести к синхронному пересчёту layout; -
множество изменений DOM может вызвать дорогостоящие этапы вычисления стилей, layout и paint;
-
вызовы DOM API обычно обходятся дороже, чем работа с объектами JavaScript в памяти.
Именно поэтому React не обновляет DOM после каждого вызова компонента. Сначала он вычисляет, что именно изменилось, работая с представлением интерфейса в памяти, и только затем применяет необходимые изменения к реальному DOM.
Такой подход принято называть Virtual DOM.
Virtual DOM — это не отдельная фаза и не внутренняя структура данных. Это концепция, согласно которой новое описание интерфейса сначала создаётся в памяти, затем сравнивается с предыдущим состоянием, и только после этого React обновляет реальный DOM.
В React 18 эта концепция реализована с помощью Fiber Reconciler, а сам процесс сравнения и поиска минимальных изменений называется Reconciliation.
Третий шаг — Reconciliation
Reconciliation — это алгоритм сопоставления нового описания интерфейса с текущим состоянием UI для определения минимального набора изменений, который потребуется применить к DOM.
Логика этапа:

При этом Reconciliation — это не универсальное глубокое сравнение всего дерева. Полный обход каждого объекта был бы слишком дорогим для больших приложений, поэтому React использует несколько простых эвристик:
-
Сначала React сопоставляет новые элементы с элементами предыдущего дерева. Если дочерним элементам задан
key, сопоставление выполняется по нему. Благодаря этому React может найти соответствующий элемент, даже если положение элемента относительно соседних элементов изменилось.Если
keyотсутствует, React сопоставляет элементы по их позиции относительно родителя.Именно поэтому для элементов, порядок которых может изменяться, рекомендуется использовать стабильные
key. Без них React ориентируется только на позицию элемента, из‑за чего при вставке, удалении или сортировке списка локальныйstateможет переехать к другому элементу. -
После того как React нашёл соответствующую пару элементов, сравниваются их типы. При совпадении типов существующий узел переиспользуется. Для компонентов сохраняются экземпляр и локальный
state, а при необходимости обновляютсяprops.Если типы различаются, React считает элементы разными. Старый компонент полностью размонтируется, локальный
stateбудет уничтожен, после чего React создаст и смонтирует новый компонент.
В результате Reconciliation для каждого элемента React принимает одно из двух решений: переиспользовать существующий узел или создать новый.
Простой пример:
// Былоreturn <UserCard user={oldUser} />;// Сталоreturn <UserCard user={newUser} />;
Тип и позиция совпали. React сохраняет тот же компонент и обновляет props.
Другой пример:
// Былоreturn <UserCard user={user} />;// Сталоreturn <AdminCard user={user} />;
Тип изменился. React размонтирует UserCard и смонтирует AdminCard. Локальный state внутри UserCard не сохранится.
Уникальный key в списках:
// Плохо для изменяемых списковitems.map((item, index) => <Item key={index} item={item} />);// Хорошоitems.map((item) => <Item key={item.id} item={item} />);
В первом примере индекс массива привязан к позиции элемента, а не к самим данным. Если из списка удаляется первый элемент с index = 0 и key={0}, следующий элемент списка сдвигается в начало и автоматически получает те же index = 0 и key={0}.
При Reconciliation React видит, что на позиции key={0} и тип компонента совпадает с предыдущим деревом. Алгоритм расценивает это как сохранение того же компонента с новыми props.
В итоге старый DOM‑узел и его локальный state остаются на месте, а обновляются только входные данные. Локальное состояние удалённого элемента переезжает к следующему элементу списка.
Использование уникального идентификатора решает эту проблему. Уникальный key связывает локальное состояние с конкретным элементом списка, а не с позицией этого элемента.
Важно: совпадение key не спасёт state, если изменился тип элемента. Если условный <div key="user"> меняется на <p key="user">, React всё равно уничтожит старый узел и сбросит его локальный state, поскольку совпадение key не отменяет проверку типа элемента.
key можно использовать и вне списков, когда React должен различать два варианта одного и того же компонента:
function AccountPanel({ isAdmin }: { isAdmin: boolean }) { return isAdmin ? <UserProfile key="admin" role="admin" /> : <UserProfile key="user" role="user" />;}
Без разных key React будет считать оба варианта одним и тем же UserProfile на той же позиции в дереве. С разными key он размонтирует старый вариант и создаст новый, поэтому локальный state профиля не переедет между ролями.
Внутреннее устройство Reconciliation в React 18
В React 18 алгоритм reconciliation реализован с помощью Fiber Reconciler.
До React 16 использовался Stack Reconciler, который обходил дерево компонентов синхронно с помощью рекурсивных вызовов функций. Пока обход всего дерева не завершался, React не мог прервать выполнение.
Fiber Reconciler сохранил тот же обход дерева, но изменил способ его выполнения. Вместо непрерывной рекурсии React обрабатывает дерево по одному Fiber за раз. Обработка каждого узла представляет собой небольшую задачу — unit of work. После завершения такой задачи React может сразу перейти к следующему узлу, приостановить рендер, переключиться на более приоритетное обновление или позже продолжить работу с того места, где остановился.
Каждый Fiber узел соответствует одному React Element и хранит информацию, необходимую для вычисления обновлений:
-
тип компонента или host‑элемента, например DOM‑узла;
-
props и локальный state;
-
ссылки на родительский, дочерний и соседние Fiber‑узлы, формирующие структуру дерева;
-
ссылку на соответствующий Fiber предыдущего дерева, что позволяет React сравнивать старое и новое состояние интерфейса;
-
flags, определяющие работу, которую необходимо выполнить во время Commit Phase.
Во время Render Phase React одновременно работает с двумя Fiber‑деревьями:
-
Current Tree — дерево уже отображённого в данный момент интерфейса.
-
Work‑in‑Progress Tree — новое дерево, которое строится параллельно во время текущего рендера.
Разделение на два дерева позволяет формировать изменения полностью в памяти, не затрагивая работающий интерфейс. До завершения следующей фазы, Commit Phase, пользователь продолжает беспрепятственно видеть Current Tree.
В упрощённом виде этот процесс выглядит следующим образом:

По мере построения Work‑in‑Progress Tree React не только формирует новое дерево, но и определяет, какие изменения потребуется применить к реальному DOM. Для этого Fiber‑узлы помечаются специальными маркерами — flags.
Примеры базовых маркеров:
-
Placement — вставить новый host‑узел или переместить существующий;
-
Update — обновить DOM‑атрибуты или текстовое содержимое.
-
Deletion — удалить узел или целое поддерево;
-
Другие специфические флаги для работы с visibility, passive/layout эффектами.
Concurrent Rendering
Concurrent Rendering — это ключевая возможность React 18 выполнять Render Phase не как одну непрерывную блокирующую операцию, а поэтапно, разбивая Render Phase на отдельные единицы работы — units of work.
Поскольку вычисления происходят изолированно в памяти, React получает гибкость управления потоком: он может поставить рендер на паузу, переключиться на критически важную задачу, вернуться назад или вовсе отменить ветку вычислений.
Сценарий 1: Пауза и возобновление, Resume
Допустим, приложение рендерит тяжёлый аналитический график: выполняется фоновое обновление данных — Рендер A. В этот момент пользователь нажимает кнопку открытия меню, инициируя высокоприоритетное событие.Concurrent Rendering 1 case.png

На схеме показано, как Рендер A приостанавливается. React мгновенно переключает ресурсы на обработку пользовательского клика — Рендер B, подготавливает изменения, передаёт их в Commit Phase и отображает меню. После этого React возвращается к прерванному Рендеру A и продолжает построение графика с того места, где остановился.
Сценарий 2: Отмена устаревших вычислений, Discard
Пользователь быстро вводит поисковый запрос. Первая нажатая клавиша запускает тяжёлую фильтрацию массива данных — Рендер A. Не дожидаясь окончания вычислений, пользователь нажимает следующую клавишу, изменяя поисковую строку.

В этот момент запускается новый Рендер B. Поскольку результаты Рендера A уже потеряли актуальность из‑за изменения поискового запроса, React полностью перезапускает процесс. Незавершённая ветка Рендера A просто удаляется из памяти — Discard, после чего React сразу начинает вычислять актуальное состояние интерфейса — Рендер C.
3. Commit phase: синхронное применение изменений
Commit Phase — это этап, на котором React переносит подготовленный результат вычислений в host‑среду. Для веб‑приложений это реальный DOM, изменяемый через пакет react-dom.
В отличие от фазы рендеринга, этот процесс всегда выполняется синхронно и не может быть прерван. React может отменить или перезапустить Render Phase, если данные устарели. Но если процесс перехода в Commit Phase уже начался, он гарантированно доводится до конца.
Далее Commit Phase будет рассмотрена через внутренние стадии React Fiber и react-dom. Такое разделение помогает понять порядок изменения DOM и время выполнения эффектов. При этом с точки зрения публичной модели React Commit Phase остаётся единым этапом, на котором React синхронно применяет подготовленные изменения к host‑среде.
К началу Commit Phase Work‑in‑Progress Tree уже полностью построено. Во время Render Phase React определил необходимые изменения и пометил соответствующие Fiber‑узлы маркерами: Placement, Update, Deletion и другими. Теперь остаётся последовательно обработать эти узлы, проходя через три внутренние стадии:
-
Before Mutation Phase — до изменения DOM.
-
Mutation Phase — непосредственное изменение DOM.
-
Layout Phase — после обновления DOM.
После завершения всех трёх стадий Commit Phase завершается, а управление передаётся браузеру для отрисовки.
Первая стадия — Before Mutation
На этом этапе реальный DOM ещё не изменён. React использует эту фазу для безопасного чтения текущего состояния интерфейса до того, как в него будут внесены правки.
Например, здесь вызывается метод жизненного цикла классовых компонентов getSnapshotBeforeUpdate. Этот метод позволяет зафиксировать параметры до обновления, например положение прокрутки, чтобы правильно скорректировать их сразу после мутации.
getSnapshotBeforeUpdate(prevProps: Props) { if (prevProps.messages.length < this.props.messages.length) { const list = this.listRef.current; return list ? list.scrollHeight - list.scrollTop : null; } return null;}
Вторая стадия — Mutation
Это этап непосредственного изменения host‑среды. На этой стадии React синхронно выполняет все работы, подготовленные во время Render Phase:
-
Применяет изменения к DOM: создаёт, обновляет, перемещает и удаляет узлы.
-
Отвязывает старые
ref: при удалении или изменении целевого элемента. -
Синхронно запускает функции очистки
destroyпредыдущихuseLayoutEffect: если изменились зависимости эффекта или компонент размонтируется.
Запуск очисток layout‑эффектов именно на этом этапе гарантирует, что работа предыдущих layout‑эффектов завершится в Mutation Phase ещё до того, как в следующей фазе — Layout Phase — начнут вызываться новые useLayoutEffect.
В самом конце Mutation Phase, когда все изменения DOM применены и старые эффекты очищены, React переключает внутренний указатель fiberRoot.current. С этого момента Work‑in‑Progress Tree официально становится новым Current Tree.
Третья стадия — Layout
Эта стадия выполняется после того, как изменения были внесены в DOM, но до того, как браузер успел отрисовать новый кадр. На этом этапе DOM уже соответствует новому состоянию интерфейса, поэтому React может безопасно выполнять операции, которым требуется доступ к обновлённой разметке: измерять размеры элементов, читать координаты прокрутки или синхронно корректировать интерфейс до появления изменений на экране.
Во время этой стадии React синхронно:
-
Прикрепляет актуальные
refк обновлённым host‑узлам. -
Запускает функции инициализации:
create-функцииновыхuseLayoutEffect. -
Вызывает методы жизненного цикла классовых компонентов:
componentDidMountиcomponentDidUpdate, а если в дереве возникли ошибки —componentDidCatchу Error Boundary.
Например, метод componentDidUpdate получает третьим аргументом snapshot, сохранённый на этапе Before Mutation. Это позволяет восстановить положение прокрутки после обновления списка:
componentDidUpdate(prevProps: Props, prevState: State, snapshot: number | null) {if (snapshot !== null && this.listRef.current) {this.listRef.current.scrollTop = this.listRef.current.scrollHeight - snapshot; }}
Например, хук useLayoutEffect позволяет безопасно измерить размеры элементов и при необходимости сразу скорректировать интерфейс:
const [height, setHeight] = useState(0);useLayoutEffect(() => { if (!elementRef.current) return; const height = elementRef.current.getBoundingClientRect().height; setHeight(height);}, []);
В этом примере после измерения элемента обновляется состояние компонента. React немедленно запускает новый синхронный цикл Render Phase и Commit Phase, не передавая управление браузеру. Благодаря этому все дополнительные изменения будут применены до отрисовки следующего кадра, и пользователь сразу увидит интерфейс в окончательном состоянии без промежуточного отображения.
Однако выполнять тяжёлые вычисления внутри useLayoutEffect не рекомендуется. Пока Layout Phase не завершится, браузер не сможет приступить к собственным этапам layout, paint и compositing, поэтому длительная синхронная работа напрямую задерживает отображение следующего кадра.
После завершения Layout Phase Commit Phase считается завершённой. Затем браузер приступает к отрисовке обновлённого интерфейса.
4. Browser Paint: отрисовка интерфейса браузером
К этому моменту React завершил все манипуляции с DOM в текущем кадре. Синхронные изменения внесены, Call Stack освобождается, и управление переходит к браузеру. Он запускает собственный конвейер отрисовки — Pixel Pipeline:
-
Recalculate Style — пересчёт CSS‑стилей для изменившихся элементов;
-
Layout (Reflow) — вычисление геометрии, размеров и точного положения узлов на экране;
-
Paint — растрирование визуальных элементов (текст, цвета, границы, тени) в слои;
-
Composite — композиция слоёв на GPU и финальный вывод готового кадра на дисплей.
Важно: Браузер выполняет только те шаги Pixel Pipeline, которые реально необходимы для пришедших изменений. Например, при смене
background-colorшагLayoutпропускается, а при трансформациях черезtransformилиopacityбраузер сразу переходит к Composite, минуя Layout и Paint.
5. Passive Effects Phase: выполнение асинхронных эффектов
На этом этапе React выполняет useEffect и другие пассивные эффекты. Фаза запускается после Render Phase и Commit Phase и не блокирует отображение интерфейса.
Во время Passive Effects Phase React последовательно выполняет два прохода:
-
Запускает функции очистки эффектов, созданных во время предыдущего выполнения
useEffect. -
Запускает функции инициализации новых эффектов.
Функция очистки выполняется не только при размонтировании компонента, но и перед каждым повторным запуском useEffect, если изменился хотя бы один элемент массива зависимостей.
Например, при изменении roomId React сначала завершает работу предыдущего подключения, а затем создаёт новое:
function ChatRoom({ roomId }: { roomId: string }) { useEffect(() => { const connection = createConnection(roomId); connection.connect(); return () => { connection.disconnect(); }; }, [roomId]); return <ChatMessages roomId={roomId} />;}
При изменении roomId React сначала запускает функцию очистки предыдущего эффекта connection.disconnect(), а затем функцию инициализации нового эффекта connection.connect().
Обычно React запускает useEffect после того, как браузер отрисовал обновлённый интерфейс. Однако это не является гарантией. В некоторых ситуациях, например при обработке пользовательских событий или при синхронных обновлениях, React успевает сбросить и выполнить useEffect до отрисовки следующего кадра.
Подводя итог
React обновляет интерфейс строго по шагам:
-
Render Phase: React рассчитывает изменения — эту фазу можно поставить на паузу или отменить.
-
Commit Phase: Изменения синхронно вносятся в DOM — без прерываний.
-
Passive Effects Phase: Запускается
useEffect. Обычно он срабатывает после отрисовки кадра браузером, но при пользовательских событиях React может выполнить его и до неё.
Именно это разделение на фазы объясняет пакетную обработку setState, разницу между useEffect и useLayoutEffect и почему нельзя делать сайд‑эффекты во время рендера.
Надеюсь, теперь стало понятнее, как устроен жизненный цикл обновлений и почему важно учитывать эти фазы в работе!
ссылка на оригинал статьи https://habr.com/ru/articles/1066970/