Как устроен createRoot в React v18+: root API, concurrency и миграция с ReactDOM.render

Подробно разбираем createRoot в React v18+: что меняется в инициализации приложения, как работает root API, где типично ошибаются и как отвечать на интервью.

30 марта 2026 г.18 минLexicon Team

Введение

createRoot в React v18+ часто воспринимают как незначительную замену одной строчки кода: раньше был ReactDOM.render, теперь надо написать что-то из react-dom/client. Из-за этого тему часто воспринимают как неглубокую, хотя на практике она затрагивает архитектуру инициализации всего приложения, модель обновлений и границу между старым и новым режимом работы React.

Если сформулировать коротко, createRoot создает современный корень React для конкретного DOM-контейнера и возвращает объект root, через который приложение монтируется и размонтируется. Это не просто косметическое API-изменение. Именно через такой корень React 18+ активирует новую модель планирования обновлений, automatic batching и возможности, на которых строятся startTransition, useDeferredValue и другие механики современного рендеринга.

Тему полезно изучать вместе с разбором React Fiber, потому что createRoot сам по себе не объясняет внутреннюю архитектуру reconciler, но именно он открывает современный путь к ней со стороны пользовательского кода.

В статье разберем, как устроен createRoot, что реально меняется после миграции с ReactDOM.render, где команды чаще всего ошибаются в production, когда нужен hydrateRoot, и как спокойно отвечать на интервью, не повторяя миф о том, что React 18 «просто стал параллельным».

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Что делает createRoot и почему React сменил входную точку

До React 18 типичный вход выглядел так:

import ReactDOM from "react-dom";
import { App } from "./App";

ReactDOM.render(<App />, document.getElementById("root"));

Такой код монтировал приложение, но работал через legacy root. В React 18+ современный вариант выглядит иначе:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";

const container = document.getElementById("root");

if (!container) {
  throw new Error("Root container #root not found");
}

const root = createRoot(container);

root.render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Здесь есть три важных изменения.

Во-первых, API переехал в react-dom/client, потому что React явно разделил клиентское монтирование и серверные сценарии. Во-вторых, createRoot возвращает объект root, а не сразу выполняет рендер. В-третьих, приложение теперь работает через современный корневой объект, а не через совместимый режим для старого поведения.

Это важно не только для синтаксиса. Как и в разборе concurrent rendering, ключевая идея React 18+ не в том, что "все стало быстрее", а в том, что обновления можно планировать и приоритизировать гибче.

Архитектура: что находится между createRoot и реальным DOM

Если смотреть инженерно, createRoot(container) делает не "рендер компонента", а создаёт управляемый корень React, привязанный к конкретному DOM-узлу.

Упрощенная схема такая:

  1. Есть DOM-контейнер, например <div id="root"></div>.
  2. createRoot(container) создает внутренний корневой объект React, который знает, куда коммитить изменения.
  3. root.render(<App />) ставит обновление в очередь для этого корня.
  4. React строит work-in-progress дерево, сравнивает его с текущим и затем применяет commit в DOM.
  5. root.unmount() снимает дерево, очищает подписки и освобождает связь между контейнером и React.

С инженерной точки зрения root выполняет роль явной границы ответственности:

  • контейнер отвечает за физическую точку в DOM;
  • root отвечает за жизненный цикл React-дерева внутри этой точки;
  • React scheduler отвечает за порядок обработки обновлений;
  • commit-фаза отвечает за синхронное применение изменений в DOM.

Именно тут кандидаты часто упрощают тему до фразы "createRoot рендерит App". Это слишком слабый ответ. Корректнее сказать, что createRoot создает управляемый корень приложения, а root.render инициирует обновление дерева в рамках этого корня.

Если хочется связать это с общей моделью, полезно перечитать когда компонент действительно перерисовывается: createRoot не отменяет обычные триггеры ререндера, а меняет инфраструктуру, в которой эти обновления проходят.

Что реально меняется после перехода на createRoot

Самая частая ошибка при миграции звучит так: "Мы заменили ReactDOM.render на createRoot, значит теперь у нас concurrent rendering". Реальность аккуратнее.

После перехода вы получаете современный root API React 18+, а вместе с ним:

  • новую модель корня вместо legacy root;
  • automatic batching для большего числа сценариев;
  • корректную основу для современных механизмов планирования обновлений;
  • ожидаемое поведение API вроде startTransition;
  • более честное dev-поведение вместе со StrictMode, из-за которого команды начинают замечать скрытые дефекты в эффектах.

Но это не означает, что:

  • тяжёлая фильтрация в render чудесным образом станет дешевле;
  • большой DOM перестанет быть большим;
  • ошибки с useEffect исчезнут сами;
  • createRoot автоматически включит SSR-гидрацию.

Последний пункт особенно важен. Для SSR нужен не createRoot, а hydrateRoot. Детали хорошо стыкуются с разбором hydration в React: если HTML уже пришел с сервера, полная клиентская перерисовка не нужна и даже вредна.

createRoot, hydrateRoot и ReactDOM.render: сравнение подходов

КритерийReactDOM.rendercreateRoothydrateRoot
Основной сценарийСтарый client renderСовременный client renderПодключение React к SSR HTML
Возвращаемое значениеНет явного root-объекта для нового APIВозвращает root с render и unmountВозвращает гидрируемый root
Режим React 18+Legacy compatibility pathСовременный root APIСовременный root API для SSR
Работа с уже готовым серверным HTMLНетНет, обычно делает клиентский mountДа, это основная задача
Подходит для новых приложенийНетДаДа, если есть SSR
Типовая ошибкаОставить в React 18 и ждать новых возможностейСчитать, что он чинит производительность самИспользовать без реального SSR-контейнера

Практический выбор простой:

  • обычный SPA на React 18+ стартует через createRoot;
  • SSR/SSG-приложение гидрируется через hydrateRoot;
  • ReactDOM.render оставляют только в старом коде до миграции, но не как долгосрочную цель.

Код-пример: безопасная инициализация root для SPA

На практике хороший bootstrap-файл делает две вещи: явно проверяет контейнер и хранит root как единственную точку монтирования.

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";

const container = document.getElementById("root");

if (!container) {
  throw new Error("Unable to find #root container");
}

const root = createRoot(container);

root.render(
  <StrictMode>
    <App />
  </StrictMode>,
);

if (import.meta.hot) {
  import.meta.hot.dispose(() => {
    root.unmount();
  });
}

Почему здесь важен root.unmount()? В обычном SPA это может быть не так заметно, но в embedded-виджетах, microfrontend-сценариях, тестовых контейнерах и hot-reload цепочках отсутствие явного размонтирования приводит к утечкам подписок и "призрачным" обработчикам. Похожая логика хорошо видна и в статье про Strict Mode и двойные вызовы в dev: React довольно быстро наказывает код, который плохо переживает mount/unmount циклы.

Код-пример: когда нужен hydrateRoot, а не createRoot

Если страница уже отрендерена на сервере, код запуска отличается:

import { StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { App } from "./App";

const container = document.getElementById("root");

if (!container) {
  throw new Error("Unable to find #root container");
}

hydrateRoot(
  container,
  <StrictMode>
    <App />
  </StrictMode>,
);

В этом сценарии React не должен слепо перерисовать все дерево. Его задача другая: сопоставить уже существующий HTML со своим виртуальным деревом, навесить обработчики и продолжить жизненный цикл приложения.

Типичный production-сбой здесь выглядит так:

  1. команда делает SSR;
  2. затем по привычке вызывает createRoot;
  3. React начинает полный клиентский mount вместо корректной гидрации;
  4. появляются предупреждения, визуальные скачки и лишняя работа на main thread.

Если вы дебажите подобные кейсы, рядом почти всегда пригодится React DevTools для анализа commit и hydration-проблем.

Production pitfalls: где createRoot понимают неправильно

Ошибка 1. Считать createRoot "переключателем производительности"

Симптомы:

  • после миграции UX почти не изменился;
  • тяжелые списки все так же лагают;
  • команда разочарована, потому что ожидала мгновенного ускорения.

Последствие: начинают оптимизировать не тот слой системы. Проблема часто в объеме DOM, дорогих вычислениях, лишних ререндерах или плохом state flow, а не в самом способе создания корня.

Ошибка 2. Создавать root повторно на одном и том же контейнере

Такое встречается в виджетах, тестовых обвязках и самописных интеграциях. Вместо одного корня разработчики вызывают createRoot(container) несколько раз.

Симптомы:

  • предупреждения в консоли;
  • нестабильные mount/unmount циклы;
  • неочевидные утечки после размонтирования.

Правильная модель: на один контейнер должен быть один управляемый root.

Ошибка 3. Путать createRoot и hydrateRoot

Симптомы:

  • hydration mismatch;
  • мигание страницы после загрузки;
  • лишние DOM-изменения сразу после загрузки страницы.

Последствие: ухудшается время интерактивности и ломается UX на серверно отрендеренных страницах.

Ошибка 4. Игнорировать root.unmount в интеграционных сценариях

Проблема особенно заметна в:

  • microfrontend-встраиваниях;
  • в React-виджетах внутри не-React страницы;
  • end-to-end и component tests;
  • dev-hot-reload окружениях.

Если дерево снимается без root.unmount(), следом приходят утечки событий, таймеров и сетевых подписок. Основная проблема уже не в createRoot, а в плохом завершении жизненного цикла, что напрямую связано с архитектурой useEffect и cleanup.

Разбор производительности: где createRoot помогает, а где нет

createRoot полезен тем, что переводит приложение на современную модель корня React 18+. Это создает инфраструктуру, в которой urgent и non-urgent обновления можно обрабатывать гибче. Но bottleneck почти всегда лежит глубже.

На практике сначала проверяют четыре зоны:

  1. Сколько работы делает render-фаза.
  2. Сколько DOM-узлов реально участвует в commit.
  3. Есть ли каскад ререндеров через props или context.
  4. Нет ли дорогих эффектов уже после commit.

Если ваш экран фильтрует 20 000 строк на каждый символ, createRoot не устранит затраты на эту операцию. Тут помогают декомпозиция состояния, виртуализация списка, startTransition, useDeferredValue и профилирование. Подробный прикладной контекст есть в разборе batching в React и гайде по мемоизации.

Граница здравого смысла такая:

  • если проблема в приоритетах обновлений, новый root действительно важен;
  • если проблема в дорогом коде внутри компонентов, root API сам по себе не спасет;
  • если проблема в SSR-гидрации, нужно смотреть на hydrateRoot, а не на createRoot;
  • если проблема в неправильных эффектах, лечить нужно эффекты, а не bootstrap-файл.

Прокачай React за 7 дней

20 вопросов и разборов по React Hooks.

Начать

Практики для production-кода

Хорошая работа с createRoot обычно выглядит скучно, и это хорошо.

  • Храните инициализацию приложения в одном entry-файле, а не распределяйте root-логику по интеграциям.
  • Проверяйте наличие контейнера явно, чтобы ошибка была ранней и диагностируемой.
  • Используйте createRoot только для client-only старта, а для SSR применяйте hydrateRoot.
  • Не создавайте повторно root для уже занятого контейнера.
  • Для нестандартных сценариев размонтирования всегда вызывайте root.unmount().
  • Профилируйте реальные узкие места через browser Performance и React Profiler, а не делайте вывод, что "после React 18 все должно работать быстрее".
  • Держите bootstrap-код простым: чем меньше магии на старте, тем проще разбирать баги и hydration-проблемы.

Частые ошибки

Чаще всего разработчики ошибаются не в синтаксисе, а в модели.

Первая ошибка: говорить, что createRoot "включает многопоточность". Это неверно. React по-прежнему работает в основном потоке браузера, а новая модель касается планирования и приоритетов обновлений.

Вторая ошибка: считать createRoot обязательным решением любой проблемы рендеринга. Если причина лагов в том, что родитель каскадно дергает половину дерева через context, сначала нужно чинить архитектуру состояния. Здесь полезно держать рядом разбор Context API и границ его применения.

Третья ошибка: не различать render и commit. На интервью из-за этого ответы разваливаются уже на первом уточнении. createRoot создает корень, root.render инициирует обновление дерева, а commit к DOM остается отдельной фазой.

Четвертая ошибка: мигрировать bootstrap-файл, но не пересмотреть эффекты и dev-поведение под StrictMode. После этого команда видит "лишние" вызовы и обвиняет новый root API, хотя проблема в неидемпотентной логике компонентов.

Как отвечать на интервью

Сильный короткий ответ может звучать так:

"В React v18+ createRoot из react-dom/client создает современный корень приложения для DOM-контейнера. Он возвращает объект root, через который вызывают root.render() и при необходимости root.unmount(). Это замена legacy-подходу с ReactDOM.render. Сам createRoot не делает приложение автоматически быстрее, но переводит его на новую модель корня React 18+, которая нужна для modern features вроде automatic batching и механизмов приоритизации обновлений. Если HTML уже пришел с сервера, вместо createRoot используют hydrateRoot."

Если интервьюер копает глубже, добавьте еще три тезиса:

  1. createRoot не равен SSR-гидрации.
  2. createRoot не убирает стоимость тяжелого render-кода.
  3. Один DOM-контейнер должен иметь один root с предсказуемым жизненным циклом.

Такой ответ сразу показывает, что вы понимаете не только API, но и границы его ответственности. Для общей подготовки по похожим вопросам удобно держать под рукой React interview FAQ и разбор типичных ошибок кандидатов по React.

Пройти mock-интервью по React и разобрать вопросы глубже

В платформе Lexicon можно потренировать архитектурные и performance-вопросы по React с разбором ответов и типичных ошибок.

Перейти к тренировке

FAQ

Нужно ли оборачивать приложение в StrictMode при createRoot

Не обязательно технически, но почти всегда полезно в разработке. StrictMode помогает раньше увидеть утечки, неидемпотентные эффекты и хрупкий код жизненного цикла.

Можно ли вызвать root.render несколько раз

Да. Это нормальный способ обновлять дерево внутри одного и того же root. Ошибка не в повторном root.render, а в повторном создании нового createRoot для того же контейнера.

Можно ли использовать createRoot в тестах

Да, особенно если тестовая среда руками монтирует React-дерево в DOM-контейнер. Но при cleanup важно корректно вызывать root.unmount().

Почему после миграции на createRoot я стал чаще видеть странности в режиме разработки

Обычно не потому, что createRoot сломал приложение, а потому, что современный root и StrictMode лучше подсвечивают дефекты в эффектах, cleanup и интеграциях.

Нужно ли срочно переписывать старое приложение только ради createRoot

Если проект уже на React 18+, оставлять legacy root как долгосрочное решение обычно не стоит. Но сама миграция должна идти вместе с проверкой bootstrap-кода, SSR-сценариев, тестовой инфраструктуры и поведения эффектов.

Итоги

createRoot в React v18+ нужен не ради новой формы записи, а ради новой модели корня приложения. Он создает объект root, через который React управляет жизненным циклом дерева в конкретном DOM-контейнере. Это современный вход в клиентский рендер, но не магический тумблер производительности.

Если приложение стартует только на клиенте, используйте createRoot. Если страница уже пришла с сервера, берите hydrateRoot. А если после миграции появились странные эффекты, лаги или предупреждения, почти всегда проблема кроется не в единственной строке bootstrap-кода, а в том, как организованы рендеринг, эффекты и границы обновлений внутри самого приложения.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Автор

Lexicon Team

Читайте также