Zustand против Redux Toolkit против Context: менеджеры состояния в React по метрикам Renders и размеру бандла
Одно приложение для управления корзиной, созданное с использованием восьми шаблонов состояния React, с измерением количества ненужных отрисовок и размера сжатого пакета: что говорят цифры о Zustand и Redux Toolkit.
Источники: I Built the Same React App With All 8 State Management Patterns. The Numbers Picked Three Winners.
Какой менеджер состояния React выбрать? Этот вопрос возникает каждые несколько месяцев, и решение в основном зависит от личных предпочтений. Лучший ответ можно получить, создав одну и ту же небольшую функциональность с использованием каждого из распространённых подходов, зафиксировав поведение с помощью единого набора тестов и подсчитав два фактора, которые действительно различаются: частоту перерисовки компонентов и количество килобайт, добавляемое каждым вариантом. Ниже приведён результат эксперимента с восемью подходами — от Zustand и Redux Toolkit до простого Context — объяснение того, почему получены именно такие цифры, а также инструкция, которую можно использовать для анализа собственного менеджера состояния.
Как организован эксперимент
Программа-тест намеренно минимальна: корзина покупок с кнопками «яблоко» и «банан», отображающимся итоговым значением и значком темы, значение которого никогда не меняется. Каждая реализация проверяется с помощью одного и того же поведенческого теста, который трижды нажимает на кнопку «яблоко» и дважды — на кнопку «банан», после чего проверяется наличие идентичных результатов. Во время выполнения теста счётчик фиксирует каждую перерисовку каждого компонента. Отдельно esbuild измеряет, сколько каждый из подходов добавляет к минифицированному, сжатому пакету, при этом React исключается, поскольку каждый вариант влияет на размер пакета одинаково.
Всего в соревновании участвуют восемь решений:
- Использование передаваемого через props состояния
useState - Контекст в сочетании с
useReducer - Redux Toolkit
- Zustand
- Jotai
- Valtio
- MobX
- Ручно написанный хранилище на основе
useSyncExternalStoreбез каких-либо зависимостей
Все восемь вариантов соответствуют общему контракту, поэтому любые различия касаются лишь стоимости, а не корректности:
✓ lifted useState › passes the shared cart contract
✓ Context + useReducer › passes the shared cart contract
✓ Redux Toolkit › passes the shared cart contract
✓ Zustand › passes the shared cart contract
✓ Jotai › passes the shared cart contract
✓ Valtio › passes the shared cart contract
✓ MobX › passes the shared cart contract
✓ useSyncExternalStore › passes the shared cart contract
Tests 8 passed (8)
Здесь перечислены версии библиотек и среда выполнения, использованные для измерений, а также репозиторий, в котором хранятся восемь реализаций, общий тест и оба скрипта для измерений:
Tested with react@19.2.8, zustand@5.0.15, @reduxjs/toolkit@2.12.0, react-redux@9.3.0,
jotai@2.20.2, valtio@2.3.2, mobx@7.0.3, mobx-react-lite@5.0.3, node 22.
Repo: github.com/noorjsdivs/state-patterns — 8 implementations, 1 shared test, 2 measurements.
Два варианта, сохраняющих справедливость сравнения
Оба они связаны с ошибками, встречающимися в реальных приложениях, а не с правилами проведения тестирования.
Во-первых, каждая реализация создаёт свой хранилище внутри компонента, а не на уровне модуля. Таким образом каждое запущенное приложение начинает работу с чистого листа, и не происходит утечек состояния между тестами.
Во-вторых, индикатор темы существует исключительно в качестве нейтрального элемента. Он подписывается на значение, которое никогда не изменяется при нажатии, поэтому любая его отрисовка во время теста является бесполезной работой, за которую можно винить только паттерн состояния.
Что намеренно исключено из объёма рассмотрения
Конкурс касается исключительно общего состояния клиента. TanStack Query не участвует, поскольку он управляет кэшем серверных данных, что представляет собой отдельную проблему с другими правилами. Состояние URL отсутствует, так как строка адреса находится в ведении роутера. Вашему приложению, вероятно, нужны оба варианта, но ни один из них не конкурирует в рамках этого конкретного соревнования.
Первый сюрприз: размер кода практически идентичен
Прежде чем перейти к интересным цифрам, стоит отметить один скучный факт, заслуживающий внимания. Все восемь реализаций состоят от 39 до 58 строк кода. Самый сложный вариант — Redux Toolkit с 58 строками — на 19 строк длиннее самого простого подхода, lifted state, который содержит 39 строк. На таком уровне аргумент о необходимости «шаблонного кода», который так часто упоминается в обсуждениях управления состоянием, сводится к 19 строкам. Значимые различия находятся в других аспектах, и они становятся видимыми только при использовании инструментов анализа.
Таблица результатов рендеринга
Вот сколько раз каждый компонент отрисовывался при пяти кликах, включая первоначальную загрузку:
5 clicks (3 apple, 2 banana) Apple Banana Total Theme(idle)
lifted useState 6 6 6 6
Context + useReducer 6 6 6 6
Redux Toolkit 4 3 6 1
Zustand 4 3 6 1
MobX 4 3 6 1
useSyncExternalStore 4 3 6 1
Jotai 5 4 7 2
Valtio 2 2 2 1
Из этих примеров вырисовываются три разных сценария.
Поднятое состояние и Context перерисовывают всё
Благодаря использованию поднятого useState и единственного Context каждый компонент перерисовывается при каждом нажатии. Иконка темы отображалась шесть раз, хотя её значение никогда не менялось, а кнопка с бананом отображалась при каждом нажатии на яблоко. В этом и заключается конкретный механизм распространённого предупреждения о том, что Context не является менеджером состояния. useContext подключает компонент к всему значению Context, поэтому каждый раз, когда это значение меняется, перерисовывается каждый компонент, использующий его. Размещение всего состояния в одном Context фактически является формой поднятого состояния с дополнительным уровнем.
Обычным решением является разделение состояния между несколькими Context или мемизация компонентов, которые его потребляют, но это здесь не измерялось. Приведённый пример отражает использование Context в том виде, в котором оно чаще всего реализуется: один провайдер, хранящий одно значение.
Четыре API, один идентичный результат
Redux Toolkit, Zustand, MobX и реализованный вручную хранилище данных дают совершенно одинаковый результат: каждый компонент отрисовывается один раз при загрузке и снова только тогда, когда меняется та часть данных, которую он читает. Их API совершенно не похожи друг на друга, но их поведение во время выполнения совпадает, поскольку все четыре основаны на одной и той же идее. Компоненты прослушивают определенную часть состояния, а не всё хранилище целиком, и им сообщается о изменениях только для этой части. Чтобы узнать подробнее о том, как происходит выбор данных и проверка их идентичности в одной из этих библиотек, ознакомьтесь с темой о том, как React Redux решает, когда перерисовывать компоненты.
Подозрительно низкие цифры Valtio
Сначала столбец Valtio кажется сломанным счётчиком: два перерисовывания для кнопки, нажатой три раза. Однако показатели верны. Valtio группирует уведомления о изменениях в очереди микозадач, поэтому несколько быстрых синхронных обновлений сливаются в одно перерисовывание компонента, и окончательные значения остаются правильными.
Есть важное ограничение. Человек, нажимающий кнопку в нормальном темпе, вызывает одно перерисовывание за каждое нажатие, поскольку каждое нажатие завершается до начала следующего. Преимущество проявляется только тогда, когда обновления поступают пачками, как это бывает с сообщениями WebSocket, потоковыми данными или событиями перетаскивания. Если ваше приложение имеет такую характеристику, этому столбцу следует уделить пристальное внимание.
Постоянное дополнительное перерисовывание у Jotai
Jotai выполнял отрисовку каждого компонента на один раз больше, чем группа, основанная на селекторах, включая две отрисовки значка темы «idle». Его степень детализации верна, поскольку компоненты по-прежнему реагируют только на используемые ими элементы, а дополнительная отрисовка происходит одинаково во всех колонках. Такая закономерность указывает на проблему в начальной последовательности монтирования с использованием Provider хранилища, а не на утечку подписок, однако точная причина не была установлена. Считайте это открытым вопросом, а не вердиктом против Jotai.
Оценка размера пакета
Вот сколько каждый подход добавляет к сжатому пакету, учитывая как собственный код подхода, так и его библиотеки, причем React считается внешним компонентом:
lifted useState 0.4 KB
useSyncExternalStore 0.5 KB (zero dependencies)
Context + useReducer 0.5 KB
Zustand 0.7 KB
Valtio 2.7 KB
Jotai 4.4 KB
Redux Toolkit 10.7 KB
MobX 13.2 KB
Самый тяжелый вариант в 33 раза превосходит по размеру самый легкий. Другими словами, Redux Toolkit и MobX вместе занимают 23,9 КБ, в то время как остальные шесть подходов в сумме составляют 9,2 КБ.
Интересно то, как этот список взаимодействует с показателями производительности отрисовки. Только два подхода сочетают идеальное поведение при отрисовке с размером менее одного килобайта: Zustand и ручно написанный хранилище данных. Redux Toolkit достигает аналогичных показателей при размере 10,7 КБ, а MobX — 13,2 КБ. Это не обязательно является недостатком; это цена, и она имеет смысл только тогда, когда вы знаете, что она покупает.
Три практичных варианта и стоимость каждого
Для совместного использования состояния клиента в типичном приложении измерения указывают на три инструмента, которые стоит рассмотреть.
Zustand в качестве стандартного решения
Zustand обеспечивает идеальную степень детализации отрисовки при размере 0,7 КБ и 54 строках кода, а его API практически не требует пояснений для нового коллеги: это хук, принимающий селектор. В этом тесте он воспроизводил поведение Redux во время выполнения при примерно в пятнадцать раз меньшем размере.
Этот результат зависит от одного принципа: всегда выбирать конкретный фрагмент данных. Вызов вида useCart(s => s) подключает компонент к всему хранилищу данных, что воспроизводит проблему Context. Эффективность достигается за счёт использования узких селекторов, а не названия библиотеки. Если селектор возвращает новый объект или массив при каждом вызове, потребуется также инструмент для проверки поверхностного равенства, иначе каждое обновление будет считаться изменением.
Реализация хранилища useSyncExternalStore вручную
Именно этот вариант делает эксперимент наиболее привлекательным. Полная реализация, включая хранилище, занимает 58 строк, добавляет 0,5 КБ, полностью соответствует структуре колонки отрисовки Redux и не требует проверки или обновления каких-либо зависимостей. useSyncExternalStore — это примитив, предоставляемый самим React для безопасной подписки на внешние хранилища, в том числе при одновременной отрисовке. Для авторов библиотек или команд, предпочитающих минимальное количество зависимостей, этого достаточно. Однако при этом вы сами отвечаете за код: если вам понадобятся инструменты разработки, промежуточные компоненты и механизмы сохранения данных, их придется разрабатывать сами.
Valtio для импульсных обновлений
Метод группировки микрозадач у Valtio был измерим, реален и уникален среди конкурентов, причем 2,7 КБ — это разумная цена за него. Написание прямой мутации вида state.apples++ и немедленное обновление именно тех компонентов, которые нужны, кажется почти слишком удобным, но статистика отрисовок подтверждает, что такое поведение действительно имеет место.
Почему остальные проигрывают в этом соревновании, хотя и не являются плохими решениями
Формат теста играет важную роль, и он отдаёт предпочтение небольшому, однородному состоянию. У других вариантов есть преимущества, которые этот инструмент не может использовать:
- Размер библиотеки Redux Toolkit в 10,7 КБ оправдан наличием инструментов разработчика, возможностей отладки с использованием механизма временных путешествий, промежуточных компонентов и стандартов, работающих в крупных организациях. При наличии пятидесяти разработчиков над одним кодовым базисом именно эти стандарты являются настоящим продуктом.
- Модель наблюдаемости у MobX наиболее эффективна в коде, состоящем из большого количества классов, чего нет в этом приложении.
Правило дизайна, которое не зависит от личных предпочтений
Каждая реализация здесь создавала свой хранилище внутри компонента. Хранилище, определённое на уровне модуля, сохраняется после демонтирования компонента, благодаря чему корзина покупок остаётся активной после выхода из системы и приветствует следующего пользователя, вошедшего в тот же устройство. Это также приводит к утечке состояния между тестами и между запросами во время отрисовки на сервере. Если вы выберете одно правило код-ревью из этого сравнения, пусть это будет следующее: ограничивайте область применения хранилищ составом компонентов, обычно создавая их в провайдере, если только вы не хотите, чтобы они имели глобальный срок существования.
Запуск того же теста в собственном приложении
Вы можете воспроизвести этот тест для своего собственного состояния за один день:
- Выберите наиболее спорный элемент общего состояния в вашем приложении и создайте вокруг него структуру из четырёх компонентов: два компонента для записи данных, один для чтения полученных значений и один пассивный компонент-наблюдатель.
- Добавьте счётчик рендеринга в каждый компонент. Объект на уровне модуля плюс один вызов
track()в теле каждого компонента занимает примерно десять строк кода. Значение счётчика «наблюдателя» представляет собой «налог» на использование Context — оно измеряется точно, а не оценивается приблизительно. - Измерьте производительность каждого кандидата с помощью команды
esbuild --bundle --minify, указав React как внешний модуль. Это займёт несколько секунд, и в отчёте появится столбец с количеством килобайт. - Если вы используете Context, а «наблюдатель» повторно рендерит компоненты, разделите Context или перейдите на хранилище на основе селекторов, затем снова запустите счётчик и включите значения до и после в свой pull request.
- Повторите всю процедуру, когда React Compiler станет частью процесса сборки, поскольку автоматическая мемоизация может существенно изменить данные столбца с информацией о состоянии.
Открытые вопросы
Два потока остаются неразрешёнными. Меньший из них является причиной дополнительной обработки данных у Jotai. Больший — это React Compiler. В строке с параметром lifted-state отображается значение 6/6/6/6 именно потому, что ничто в ней не сохраняется в памяти, а мемоизация — это именно то, что автоматизирует компилятор. Очевидным следующим экспериментом будет проверка, сможет ли компилятор привести эту строку в соответствие со складами данных, основанными на селекторах, в реальном производственном коде, а не только в демо-версии.
Что показывают показатели обработки данных и размеры пакетов
- В небольших масштабах различия в базовом коде между библиотеками управления состоянием незначительны; настоящие различия проявляются в поведении при обработке данных и размере пакетов.
- Один единственный контекст, хранящий изменяющееся состояние, заставляет перерисовываться все компоненты, включая те, которые никогда не читают изменённые значения.
- Магазины, основанные на селекторах, будь то Redux Toolkit, Zustand, MobX или реализованный вручную магазин
useSyncExternalStore, используют один и тот же эффективный паттерн отрисовки. - Zustand и ручно написанный магазин обеспечивают такое поведение при размере менее килобайта; более крупные библиотеки увеличивают свой размер за счёт инструментов и конвенций, а не процесса отрисовки.
- Метод группировки операций в Valtio оказывается полезным для потоков обновлений с периодическими всплесками, а не для обычных кликов.
- Создавайте магазины внутри дерева компонентов, чтобы состояние не могло существовать дольше сессии, которая его сгенерировала.