Оценка менеджеров состояния React: сравнение количества отрисовок и стоимости пакета
Приложение для управления корзиной, созданное с использованием восьми шаблонов состояния React, проанализированное с точки зрения количества ненужных отрисовок и размера сжатого пакета, а также выводы по поводу Zustand, Valtio и Context.
Вопрос о том, какой менеджер состояния React выбрать, постоянно всплывает, причем дискуссии обычно основаны на мнениях, а не на данных. Более полезным подходом является создание одной и той же небольшой функциональности с использованием каждого распространённого паттерна, сохранение стабильности поведения с помощью общего набора тестов и подсчёт того, что действительно отличается: как часто перерисовываются компоненты и сколько килобайт добавляет каждый вариант. В этой статье рассматривается подобный эксперимент с восемью паттернами, объясняется, почему получаются именно такие цифры, а также приводится рецепт для повторения этого эксперимента с использованием собственного менеджера состояния.
Как настроен эксперимент
Программа-тест намеренно минимальна: корзина покупок с кнопками «яблоко» и «банан», отображением общей суммы и значком темы, значение которого никогда не меняется. Каждая реализация проверяется с помощью одного и того же поведенческого теста, который трижды нажимает на кнопку «яблоко» и дважды — на кнопку «банан», после чего проверяется наличие идентичных результатов. Во время выполнения теста счётчик фиксирует каждую перерисовку каждого компонента. Отдельно 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
Из этих данных вырисовываются три разных сценария.
Lifted state и Context перерисовывают всё
Благодаря вынесенному useState и использованию единственного контекста каждый компонент рендерится при каждом клике. Иконка темы отображалась шесть раз, хотя её значение никогда не менялось, а кнопка с изображением банана рендерилась при каждом клике на яблоко. В этом и заключается конкретный механизм того распространённого предупреждения о том, что Context не является менеджером состояния. useContext подключает компонент к всему значению контекста, поэтому каждый раз, когда это значение меняется, рендерится каждый компонент-потребитель. Размещение всего состояния в одном контексте фактически эквивалентно вынесенному состоянию с дополнительным уровнем.
Обычное решение — разделить состояние между несколькими контекстами или использовать мемоизацию для компонентов-потребителей, но это здесь не измерялось. Приведённый пример отражает использование 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 как внешний модуль. Это займёт несколько секунд, и в отчёте появится столбец с количеством килобайт.Открытые вопросы
Два потока остаются нерегулируемыми. Меньший из них является причиной дополнительной обработки данных в Jotai. Больший — это React Compiler. В строке с параметром lifted-state отображается значение 6/6/6/6 именно потому, что ничто в ней не сохраняется в памяти для повторного использования, а именно это делает компилятор автоматически. Очевидным следующим экспериментом будет проверка, сможет ли он привести эту строку в соответствие с хранилищами данных, основанными на селекторах, в реальном производственном коде, а не только в демо-версии.
Основные выводы
- В небольших масштабах различия в шаблонном коде между библиотеками управления состоянием незначительны; настоящие различия проявляются в поведении при отрисовке и размере пакета кода.
- Один контекст, содержащий изменяющееся состояние, заставляет перерисовываться все компоненты, включая те, которые никогда не читают измененные значения.
- Хранилища данных, основанные на селекторах, будь то Redux Toolkit, Zustand, MobX или реализованное вручную хранилище
useSyncExternalStore, приходят к одинаковой эффективной модели отрисовки.