React Compiler: мемоизация уходит из кода

скорость≈ 14 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

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

Разделы темы:·

React Compiler ничего не делает в браузере: это шаг сборки. Сборка и так переписывает ваш код — превращает <h3> в вызов функции (статья «JSX — это не HTML»; в Next.js это делает SWC, в Expo — Babel). React Compiler — ещё одно такое преобразование поверх. На вход идёт код без единого useMemo, на выходе — код с кешем.

Кеш устроен незамысловато: массив ячеек, который живёт при компоненте столько же, сколько его состояние. В одних ячейках лежат входы, при которых считали, в других — результаты. Перед каждым вычислением стоит проверка: вход тот же — берём готовое, вход другой — считаем заново и кладём в ячейку.

Соберём такой массив руками — просто чтобы увидеть механику. Кнопка меняет состояние Page и к заголовку отношения не имеет; жмём её трижды. Сколько раз напечатается «рендер Title» и сколько — «считаем заголовок»?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Четыре отрисовки и один расчёт. Функция Title вызывалась при каждой перерисовке родителя — React не спрашивает разрешения, как показано в статье «Триггер, рендер, коммит», — но внутри неё работа выполнилась однажды: вход не менялся. Ровно это и делает компилятор, только ячейки заводит сам и не для одной строки, а для каждого значения в компоненте: вычислений, обработчиков, объектов и элементов разметки.

Писать в useRef во время рендера нельзя — это одно из правил, о которых пойдёт речь ниже. Здесь мы вручную изображаем внутренний механизм React; в вашем коде эти строки пишет компилятор.

Компилятор не переписывает вашу логику — он расставляет вокруг неё проверки «менялось или нет». t0, t1 — его собственные имена для промежуточных значений, $ — тот самый массив ячеек, _c — функция из react/compiler-runtime, которая заводит массив при первой отрисовке (в React 19 этот рантайм уже внутри пакета react).

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Читать такой код не нужно — но полезно один раз увидеть, чтобы понимать, что происходит: никакой магии, обычные if и сравнения по !==. И заодно это способ проверить, что компилятор действительно включён: в собранном файле появляются вызовы _c.

Ручная мемоизация держится на массиве зависимостей, а массив — это текст, который пишет человек. Система типов следит за ним лишь наполовину: без второго аргумента useMemo не вызвать.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Это всё, что типы могут сказать: массив на месте. Что в нём лежит — вопрос не типов, а данных, которыми пользуется вычисление. Забыли одну переменную — и значение застынет. Цена сто рублей, штук становится две; что напечатает последняя строка?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«100 × 2 = 100». Компонент перерисовался, count новый, а sum остался от первой отрисовки: React честно исполнил договор — price не менялся, значит, пересчитывать нечего. Ошибка не в React и даже не в useMemo; ошибка в списке, который написал человек и забыл обновить, когда добавлял в формулу второй множитель.

Компилятор списков не пишет. Он смотрит, из чего вычислено значение, и ставит проверку ровно на эти входы — на price и на count. Забыть зависимость невозможно, потому что её никто не перечисляет: она выводится из кода вычисления, а код и есть источник правды.

С useCallback та же болезнь протекает злее: устаревает не число на экране, а замыкание внутри обработчика — то самое замыкание из части I. Дважды нажимаем «плюс один», потом «показать». Что скажет обработчик и что будет на экране?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На экране двойка, в обработчике ноль. Функция создана при первой отрисовке и замкнула тогдашний count; пустой массив зависимостей запретил создавать её заново. Такую ошибку не видно ни в разметке, ни в тестах на рендер — она всплывает в проде, когда кнопка отправляет на сервер устаревшее значение.

Правило exhaustive-deps в eslint ругалось бы на оба примера. Но правило только подсказывает, а решает человек — и строчка eslint-disable-next-line стоит ровно одно движение пальцем.

Кеш компилятора не ограничивается числами и функциями: в ячейку попадает и результат JSX-выражения. Это важнее, чем кажется. Элемент — обычный объект-описание; если при новой отрисовке родитель вернул тот же самый объект, React сравнивает ссылки и внутрь компонента не заходит вовсе.

Проверим руками, без всякого компилятора: Heavy печатает строку при каждой отрисовке, а его элемент положен в ячейку. Клик меняет состояние Panel. Сколько строк добавит один клик и какие именно?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одну — «рендер Panel». Heavy не перерисовался, хотя никакого memo вокруг него нет и родитель отработал заново. React дошёл до места, где стоит элемент, увидел тот же объект, что и в прошлый раз, и остановился: описание не менялось — значит, и поддерево менять не из чего.

Отсюда следствие, ради которого всё и затевалось. memo сравнивает пропсы уже после того, как родитель их пересоздал; компилятор просто не пересоздаёт — ни объект пропсов, ни элемент. Поэтому под компилятором memo в большинстве мест лишний: он остаётся полезен разве что на границе с кодом, который компилятор не обработал, — со сторонним пакетом или с файлом, который он пропустил.

КтоЧто кешируетОткуда берутся зависимости
useMemo, useCallbackодно значение или одну функциюиз массива, который пишете вы
memoрезультат компонента, если пропсы совпалисравнение пропсов по ссылке при каждой отрисовке родителя
React Compilerвсе значения компонента, включая элементы разметкивыводятся из кода на сборке

Приём старше компилятора, и в этой главе он попадается третий раз: элемент создаётся не там, где меняется состояние, а выше, и приходит пропсом children — объект-описание переживает перерисовку сам собой.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вывод тот же, что в двух прошлых статьях: App заявку не подавал, элемент <Heavy /> тот же объект, внутрь React не заходит. Разница — в цене: приём требует перестраивать дерево под оптимизацию, а компилятор даёт то же самое, не трогая структуру.

Кеш безопасен ровно настолько, насколько предсказуем код. Компилятор исходит из того, что компонент — чистая функция: те же пропсы и то же состояние дают тот же результат, а во время отрисовки наружу ничего не течёт. Требование не новое — это правила React, о которые вы уже спотыкались в статьях про рендер и про эффекты. Новое здесь то, что за нарушение платят сразу.

Компонент Stamp считает свои вызовы во внешней переменной — обычная «безобидная» отладочная строчка. Его элемент положен в ячейку, как это сделал бы компилятор. Что окажется на экране после двух кликов?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«метка #1»: счётчик застыл. Компонент рассчитывал на то, что его функция выполняется при каждой отрисовке, — и как только вызовы прекратились, он начал врать. Заметьте, что без мемоизации этот код «работал». Это худший вид ошибки: она лежит тихо и ждёт, пока кто-нибудь включит оптимизацию.
ПравилоЧего нельзя в теле компонентаКуда это переносится
Отрисовка чистаменять переменные снаружи, править DOM, отправлять запросыобработчик события или эффект
Пропсы и состояние не меняютitems.push(x), props.user.name = "..."новый массив или объект (статья «Объекты и массивы в состоянии»)
Ссылки не читают и не пишут при отрисовкеref.current в теле функцииобработчик или эффект (статья про useRef)
Хуки — только на верхнем уровневызов хука в условии, цикле или после раннего returnразделить компонент (статья «Правила хуков и почему они такие»)

Что будет, если правило всё-таки нарушено? Компилятор чаще всего это видит и такой компонент просто не трогает: оставляет как был, сборка проходит, приложение работает — только ускорения не будет. В Next.js это поведение по умолчанию, за него отвечает настройка panicThreshold: "none" — «пропускать то, что не компилируется».

Но видит он не всё. Мутацию, спрятанную в функции из соседнего модуля, статический анализ не поймает — и тогда кеш вступит в силу поверх кода, который на него не рассчитан. Поэтому правила остаются вашей ответственностью, а не заботой сборки. Смотреть за ними глазами не нужно: свежие версии eslint-plugin-react-hooks включают правила самого компилятора, и редактор подчёркивает ровно те места, из-за которых компонент будет пропущен.

Строгий режим React не случайно вызывает компоненты дважды: он ловит ровно эти нарушения заранее — до того, как их найдёт компилятор.

Компилятор выпущен плагином Babel, пакетом babel-plugin-react-compiler, но работает так уже не везде. В Next.js его не приходится прописывать руками: пакет ставится в зависимости разработки, а в конфигурации поднимается флаг верхнего уровня.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В Vite флага reactCompiler нет, а @vitejs/plugin-react Babel больше не везёт: для компилятора его либо подключают отдельно, либо берут экспериментальный порт на Rust. Путь через Babel — тот, что в документации React: в зависимости разработки ставят @rolldown/plugin-babel, @babel/core и babel-plugin-react-compiler, а в конфиг добавляют готовый пресет.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, vite build --minify false на шаблоне react-ts; vite 8.3.0, @vitejs/plugin-react 6.1.1, @rolldown/plugin-babel 0.2.4, babel-plugin-react-compiler 1.0.0, @babel/core 8.0.5; сборка 14.09.2026, grep по ней 15.09.2026; macOS arm64, Node 26.5.0
$ grep -n "function App" -A5 dist-compiler/assets/index-*.js
11515:function App() {
11516- const $ = (0, import_compiler_runtime.c)(24);
11517- const [count, setCount] = (0, import_react.useState)(0);
11518- let t0;
11519- let t1;
11520- if ($[0] === Symbol.for("react.memo_cache_sentinel")) {

В собранном App тот же кеш, что в начале статьи: $ на 24 ячейки заводит функция c из рантайма компилятора, а memo_cache_sentinel — знак пустой ячейки, как наш EMPTY. Второй путь обходится без Babel. Порт компилятора на Rust — пакет oxc-transform-react; ставят его той версии, которую допускает peer-диапазон plugin-react, — самая свежая в него может не попасть. Включается порт одной опцией.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, vite build --minify false на копии шаблона react-ts; vite 8.3.0, @vitejs/plugin-react 6.1.1, oxc-transform-react 0.145.0 — старшая версия в диапазоне peer у plugin-react; папки @babel и babel-plugin-react-compiler на время сборки переименованы, после возвращены; macOS arm64, Node 26.5.0, 14.09.2026
$ grep -n "function App" -A5 dist-rust/assets/index-*.js
11515:function App() {
11516- const $ = (0, import_compiler_runtime.c)(24);
11517- const [count, setCount] = (0, import_react.useState)(0);
11518- let t0;
11519- let t1;
11520- if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
$ # тот же проект с plugins: [react()] — для сравнения
11495:function App() {
11496- const [count, setCount] = (0, import_react.useState)(0);
11497- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [

Строки те же, что у сборки через Babel, хотя Babel в ней не участвовал, а без compiler: true у той же функции нет ни ячеек, ни проверок. В Next порт на Rust работает внутри Turbopack и включается второй настройкой рядом с первой — пакет Babel-плагина тогда не нужен.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 (Turbopack), babel-plugin-react-compiler не установлен; «…» — остальной лог сборки опущен; macOS arm64, Node 26.5.0, 14.09.2026
$ npx next build
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config.ts took 56ms
- Experiments (use with caution):
✓ turbopackRustReactCompiler
Creating an optimized production build ...
✓ Compiled successfully in 1564ms
…
.next/static/chunks/0m9b9g22yzobb.js (next.config: reactCompiler: true, experimental.turbopackRustReactCompiler: true; babel-plugin-react-compiler НЕ установлен):
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,79474,(t,e,r)=>{"use strict";var c=t.r(71645).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;r.c=function(t){return c.H.useMemoCache(t)}},932,(t,e,r)=>{"use strict";t.i(47167),e.exports=t.r(79474)},20501,t=>{"use strict";var e=t.i(43476),r=t.i(932),c=t.i(71645);t.s(["Price",0,function(t){let i,s,u,o=(0,r.c)(8),{base:n}=t,[_,T]=(0,c.useState)(1);return(o[0]!==_?(i=()=>T(_+1),o[0]=_,o[1]=i):i=o[1],o[2]!==n)?(s=n+" ₽",o[2]=n,o[3]=s):s=o[3],o[4]!==_||o[5]!==i||o[6]!==s?(u=(0,e.jsxs)("button",{onClick:i,children:[_," × ",s]}),o[4]=_,o[5]=i,o[6]=s,o[7]=u):u=o[7],u}],20501)}]);
$ # тот же конфиг без turbopackRustReactCompiler
▲ Next.js 16.3.5 (Turbopack)
✓ Running next.config.ts took 15ms
Creating an optimized production build ...
> Build error occurred
Error: Turbopack build failed with 1 error:
./next.config.ts
Error: Failed to resolve package babel-plugin-react-compiler while attempting to resolve React Compiler
React compiler is enabled in next.config.ts. We attempted to resolve React Compiler relative to the next package. Is babel-plugin-react-compiler installed in your node_modules directory?
at ignore-listed frames

Лог честно предупредил об эксперименте, а Price в куске собран с кешем: o=(0,r.c)(8) — восемь ячеек, o[0]!==_ — проверка входа. Без второй настройки тот же флаг ищет Babel-плагин, а раз пакета нет, сборка падает. Оба порта пока экспериментальные, а настройка Next работает только под Turbopack. Какой путь где действует и с какой версии, сведено в таблицу с датой в статье про компилятор файла.

Рантайм-часть в React 19 уже внутри пакета react; для проектов на 17 и 18 ставят отдельный react-compiler-runtime. По умолчанию компилятор работает в режиме infer: сам решает, что в файле компонент и хук, и обрабатывает всё, что нашёл.

Один компонент можно вывести из-под компилятора директивой — строкой в начале тела функции, как "use strict" из части I. Это временная мера для места, которое пока не привели в порядок.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Существующие useMemo и useCallback компилятор не выбрасывает: он старается сохранить обещанное ими, а если сохранить не может — не трогает такой компонент целиком. Поэтому включать компилятор можно, не устраивая чистку: сначала флаг, потом, попадая в файл по другому делу, снимаете обёртки. Снимать стоит — чем меньше рукописных массивов, тем меньше мест, где значение застынет.

Было в кодеПод компилятором
useMemo вокруг вычисленияудалить: то же самое сделает компилятор, и зависимость не забудет
useCallback ради стабильной ссылкиудалить
memo вокруг компонентаобычно не нужен; оставить на границе с необработанным кодом
useRef, чтобы значение пережило отрисовкуоставить: это память, а не мемоизация
useMemo, без которого компонент врётчинить логику: правильность не должна зависеть от кеша

Кеш не бесплатен: сборка идёт дольше, а в готовый файл добавляются те самые ячейки и проверки. Для обычного приложения обмен выгодный, но «включили — и везде стало быстрее» не обещает никто: компонент, который тормозит из-за десяти тысяч узлов в разметке, от кеша не оживёт.

Последняя строка таблицы — правило, которое переживает любой компилятор. Мемоизация не входит в договор React: и useMemo, и компилятор имеют право пересчитать значение когда угодно. Если компонент ломается без кеша, он сломан и с кешем — просто реже. Кеш про скорость, а не про поведение; что именно тормозит на самом деле, разбиралось в первой статье этой главы.

  • React Compiler — шаг сборки: он читает компоненты и дописывает кеш из ячеек, который вы раньше писали руками через useMemo и useCallback.
  • Зависимости он выводит из кода, поэтому забытая зависимость и застывшее значение перестают быть возможными.
  • В ячейки попадают и элементы JSX: тот же объект-описание — React не заходит в поддерево, и memo нужен куда реже.
  • Плата — правила React: чистая отрисовка, никакой мутации пропсов и состояния, ссылки и побочные действия вне тела компонента.
  • Нарушение компилятор обычно замечает и просто пропускает компонент: сборка не падает, но и ускорения не будет.
  • Правильность не должна зависеть от мемоизации — ни ручной, ни автоматической: кеш про скорость, а не про поведение.

1/4Внутри компонента написано const sum = React.useMemo(() => price * count, [price]). Пропс price остаётся равным 100, а count меняется с 1 на 2. Что покажет компонент?

2/4Компилятор встретил компонент, который во время отрисовки увеличивает переменную из внешнего модуля. Что произойдёт при сборке?

3/4Что, кроме чисел, строк и функций, компилятор кладёт в ячейки кеша?

4/4В проекте включили компилятор. Нужно ли сразу удалить все useMemo и useCallback, чтобы он заработал?

Зависимость, которая устарела

Список должен отфильтроваться по запросу, но после клика печатает всё подряд. Найдите причину и почините двумя способами — с массивом зависимостей и без него. Какой из двух переживёт следующую правку кода?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вычисление читает items и query, а в массиве перечислен только items. Пока пропс items тот же объект, значение берётся из кеша — фильтрация с новым запросом не выполняется.

Первый способ — дописать зависимость:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Второй — убрать мемоизацию совсем:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Фильтрация трёх строк дешевле, чем сравнение зависимостей вокруг неё; useMemo здесь никогда не окупался. Переживёт правку второй способ: у него нет списка, который можно забыть обновить, добавив в формулу ещё один пропс. Под компилятором так и пишут — а кеш, если он нужен, появится на сборке.

И отдельно про строку const ITEMS над компонентами. Если бы массив создавался прямо в разметке — items={["кот", "код", "кодер"]}, — в List при каждой отрисовке приходил бы новый объект, зависимость items менялась бы всегда, и useMemo честно пересчитывал бы всё заново. Ошибка бы спряталась, а мемоизация перестала бы работать вовсе — заметить такое можно разве что с секундомером. Ручная мемоизация чувствительна к подобным мелочам; зависимости, выведенные из кода, — нет.

Почему компилятор пропустит этот компонент

В Row два нарушения правил React — из-за них компилятор оставит компонент без оптимизации. Назовите оба и перепишите компонент так, чтобы нумерация и текст пришли снаружи, а тело функции стало чистым.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первое нарушение — lastNumber += 1: отрисовка меняет переменную вне компонента, и результат зависит от того, сколько раз функцию вызвали. Второе — seen.current += 1: запись в ссылку во время отрисовки, та же побочность, только спрятанная в useRef.

Оба счётчика не нужны: номер строки — это данные, и они приходят от родителя.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь Row — чистая функция от пропсов: компилятор его оптимизирует, а число вызовов перестаёт быть видимым снаружи. Считать отрисовки для отладки по-прежнему можно — но console.log, а не записью в состояние программы.

Убрать ручную мемоизацию

Проект перевели на компилятор. Перепишите Card так, как его написали бы сегодня, и скажите, что изменится в поведении.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Уходят все три обёртки — и вместе с ними три массива зависимостей:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В поведении не меняется ничего: компилятор заведёт ячейки под title, style, handlePick и под сам элемент кнопки — с теми же зависимостями, только выведенными из кода. Разница в другом: строка [onPick, user.name] больше не может разойтись с телом функции при следующей правке.

Пока компилятор не включён, такой код тоже верен — просто объект стиля и обработчик создаются заново при каждой отрисовке. Это и есть нормальная цена работы React, о которой шла речь в первой статье главы.