Автобатчинг в стейт-менеджерах на JavaScript — это механизм автоматического объединения нескольких обновлений состояния в один цикл рендеринга (или один вызов подписчиков) для оптимизации производительности приложения.

Актуальность проблемы

Как известно, избыточные рендеры всегда бьют по производительности. Особенно остро это ощущается в React с его философией иммутабельности, которой фреймворк следует с момента своего релиза в 2013 году.

Сам батчинг можно сравнить с курьером, который ждёт, пока соберутся все посылки для одного района, вместо того, чтобы ездить за каждой отдельно.

Как работает обновление состояния без батчинга?

Если говорить коротко, это прямой вызов функций обновления и уведомления подписчиков. К примеру, если в коде происходит синхронное изменение двух независимых полей, система выполнит два последовательных рендеринга (тиков) компонента. Итог такого подхода - неизбежная просадка FPS и лишняя работа для процессора.

Эволюция батчинга в экосистеме React

При любом изменении стейта React строит новое дерево Virtual DOM для изменённого компонента и его поддерева, а затем сравнивает его со старым.

До React 18 батчинг обновлений был полуавтоматическим. Он работал только внутри собственных обработчиков событий React (например, при кликах или вводе текста). Однако любой асинхронный код (setTimeout, fetch, промисы) ломал эту логику и вызывал отдельные рендеринги на каждое микроизменение состояния.

Начиная с React 18, внедрение механизма микрозадач кардинально изменило правила игры. Теперь React объединяет все синхронные вызовы setState в одну общую очередь независимо от контекста выполнения, обеспечивая единый рендеринг даже для асинхронных операций. Тем не менее, в сравнении с работой Vue 3, мы понимаем, что этого недостаточно.

Варианты решения проблемы

Если мы говорим про наиболее эффективные способы заставить React работать так же производительно, как это делает Vue 3 - это освободить его от тяжёлой работы, под которую он изначально не был рассчитан: в идеале - вся бизнес-логика вашего приложения. Я пробовал два варианта (оба работают):

  1. Абстрагировать от UI-слоя механизм вычислений. Из свежего, на личном примере - Reactive Engine (направленный граф вычислений). Небольшой спойлер: в составе библиотеки движка уже реализованы легковесные адаптеры для React 18+ и Vue 3.2+, которые позволяют безболезненно связать интерфейс с реактивным слоем вычислений. Так же можно посмотреть в сторону Reatom, Effector (ребята там тоже добились хороших результатов, но моя основная цель была в создании простого движка с околонулевым порогом входа - примеры на чистом JS также в наличии);
  2. Либо убираем вычисления в Web Worker (Dedicated или Shared - в зависимости от того, что будет уместно в контексте задачи), максимально освободив основной поток (с выносом в отдельный тред с контекстом);

Мы, кстати, плавно подошли к сути движка @pravosleva/reactive-engine - термину Fine-grained Reactivity. Это паттерн, который исторически развивался от академического Functional Reactive Programming (сформулированного Конэлом Эллиоттом и Полом Худаком в 1997 году) к концепции Dataflow programming (вычислений, управляемых потоком данных, наподобие ячеек в Excel).

Мелкозернистая реактивность (Fine-grained Reactivity) — это архитектурный паттерн управления состоянием, где зависимости отслеживаются на уровне отдельных атомарных примитивов (сигналов/атомов), а не компонентов целиком. Термин не является консорциумным стандартом (как W3C), но стал индустриальным стандартом де-факто в дизайне современных интерфейсных движков (SolidJS, Vue, Angular) благодаря своей способности радикально сокращать нагрузку на Main Thread и оптимизировать пользовательские метрики вроде INP.

Влияние на Core Web Vitals

На примере позитивного опыта использования @pravosleva/reactive-engine перечислю три момента, ради чего всё это задумывалось и какие метрики выигрывают в первую очередь:

  1. Interaction to Next Paint (INP) — метрика задержки интерфейса при взаимодействии пользователя со страницей. Благодаря механизмам подписки на атомарные части состояния, обновление происходит минуя глобальный репроцессинг дерева React. Компоненты, не связанные с изменившимся полем, вообще не участвуют в цикле обновления. INP существенно снижается, так как Main Thread освобождается мгновенно, позволяя браузеру выполнить Paint сразу после клика. На вопрос Почему длительные синхронные задачи (Long Tasks) снижают INP, найти ответ можно в Спецификации Interaction to Next Paint.
  2. Cumulative Layout Shift (CLS) — метрика визуальной стабильности страницы (непроизвольные сдвиги контента). За счет встроенного автобатчинга (транзакций) и синхронизированного вычисления выводимых данных, реактивный движок гарантирует, что все связанные изменения произойдут одновременно в рамках одного кадра. Элементы интерфейса перерисовываются строго один раз, предотвращая промежуточные "мигания" и микро-сдвиги макета.
  3. Total Blocking Time (TBT) — лабораторная метрика, которая напрямую коррелирует с общей отзывчивостью интерфейса. Поскольку мелкозернистая реактивность разбивает тяжеловесный рендеринг на плоский граф быстрых микрозадач, время блокировки основного потока падает, предотвращая появление Long Tasks (задач длиннее 50 мс). Подтверждение тезиса о том, почему плоский граф реактивности производительнее тяжелого VDOM-диффинга можно получить в Документации по Total Blocking Time.

Итак, связь между автобатчингом (примененным совместно с реактивным подходом в JS), его влиянием на рендеринг и итоговыми показателями Core Web Vitals выглядит следующим образом:

МетрикаТрадиционный подход (Redux/Context без мемоизации)Реактивный движок (Fine-grained Reactivity)
INP (Interaction to Next Paint)Высокий из-за избыточного VDOM reconciliation при частых кликах/вводах.Низкий (Идеально). Обновляются строго изолированные DOM-узлы.
CLS (Layout Shift)Возможны скачки при рассинхронизации асинхронных потоков UI.Минимальный. Строгий батчинг склеивает обновления в один цикл отрисовки.
Длительность Long Tasks (TBT)Высокая. Длинный стек вызовов от корня приложения к дочерним элементам.Низкая. Плоский граф зависимостей, точечные быстрые микрозадачи.

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

Инструмент / ФреймворкНаличие автобатчингаМеханизм под капотом и особенности реализации
Vue 2Да (из коробки)Очередь обновлений на базе микрозадач (nextTick). Изменения реактивных свойств буферизируются и применяются синхронно в конце текущего тика.
Vue 3Да (из коробки)Усовершенствованный планировщик задач (Scheduler) на Native Promises. Разделяет очереди на пред-рендеринг, рендеринг и пост-эффекты, гарантируя железную стабильность.
React (до v18)ЧастичноБатчил обновления только внутри собственных встроенных обработчиков событий (click, change). Асинхронный код (fetch, setTimeout) ломал батчинг и приводил к множественным рендерам.
React 18+Да (из коробки)Полноценный Automatic Batching через стратегию микрозадач (Root уровень). Объединяет setState в любых контекстах. Для принудительного пропуска используется flushSync.
AngularДа (через Zone.js / Signals)Классически полагается на Zone.js, который перехватывает асинхронные операции и запускает проверку изменений (Change Detection) один раз по их завершении. В современных версиях с Signals переходит на локальный мелкозернистый шедулинг.
@pravosleva/reactive-engineДа (из коробки)Направленный граф вычислений со встроенным планировщиком транзакций. Группирует изменения атомарных данных на уровне микрозадач до их доставки в UI-слой (через легкие адаптеры).
EffectorДа (из коробки)Родной очерёдный планировщик (Kernel/Ядро движка). Все связанные события и эффекты выстраиваются в строгую синхронную топологическую очередь («шаги» графа) до уведомления подписчиков.
ReatomДа (из коробки)Механизм транзакций и атомарных вычислений. Изменения зависимых атомов происходят внутри изолированной транзакции, и подписчики узнают только о финальном зафиксированном состоянии.
ValtioДа (через прокси)Базируется на Proxy. Отслеживает мутации объекта в течение макрозадачи и использует микрозадачу (через Promise) для уведомления слушателей о финальном слепке (snapshot).
Redux / Redux ToolkitНет (требуются надстройки)По умолчанию каждый dispatch(action) синхронно и мгновенно запускает всех подписчиков. Для батчинга требуется ручное оборачивание в batch() из react-redux или сторонние middlewares.
MobXДа (через экшены)Автоматически группирует изменения внутри функций, обёрнутых в action или runInAction. Модификации стейта применяются без промежуточных рендеров, до завершения выполнения экшена.

Таблица наглядно показывает водораздел в индустрии: современные прокси- и сигнал-решения (Vue 3, Effector, @pravosleva/reactive-engine) внедряют батчинг на уровне ядра самого движка, тогда как классический React долгое время шел к этому на уровне UI-потока, а Redux до сих пор требует явного контроля со стороны разработчика.