Головна / Статті

Zustand проти Redux Toolkit проти Context: менеджери стану в React за оцінками Renders та розміру бандлу

Одне додаток для кошика, створене за допомогою восьми шаблонів стану React, з вимірюванням кількості зайвих відображень та розміру стиснутого пакету: що говорять ці цифри про Zustand та Redux Toolkit.

Видавець: REACTAPP.TOP9 хв читання2182 слів

Джерела: I Built the Same React App With All 8 State Management Patterns. The Numbers Picked Three Winners.

Який менеджер стану React вибрати? Це питання постає кожні кілька місяців, і вирішення здебільшого залежить від особистих уподобань. Кращу відповідь можна отримати, створивши одну й ту саму невелику функціональність за допомогою кожного з поширених підходів, зафіксувавши поведінку за допомогою єдиної набору тестів та порахувавши два ключові фактори, які насправді відрізняються: частоту перерендерингу компонентів та кількість кілобайт, яку додає кожен варіант. Нижче наведено результати експерименту з вісьмома підходами — від Zustand та Redux Toolkit до звичайного Context — пояснення того, чому цифри є саме такими, та інструкція, яку можна використати для аналізу власного менеджера стану.

Як організовано експеримент

Тестовий додаток навмисно дуже малий: це кошик для покупок із кнопкою „Яблуко“, кнопкою „Банан“, підсумком та значком теми, значення якого ніколи не змінюється. Кожну реалізацію перевіряють за допомогою одного й того ж тесту поведінки, який тричі натискає на кнопку „Яблуко“ та двічі — на кнопку „Банан“, перевіряючи однакові результати. Під час виконання тесту лічильник фіксує кожне відображення кожного компонента. Окремо esbuild вимірює, скільки кожен підхід додає до мініфікованого, стиснутого пакету, за винятком React, оскільки кожен варіант призводить до однакових витрат.

Вісім конкурентів є наступними:

  • Використання useState, яке передається через props
  • Context у поєднанні з 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 та власноруч написаний store. 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, заснована на спостережуваних даних, є найкращою у коді, що складається з великої кількості класів, чого немає у цьому додатку.
  • Граф атомів Jotai проявляє свою перевагу, коли похідний стан стає глибоким та взаємопов’язаним, причому один похідний сум ледве досягає такого рівня.
  • Контекст залишається правильним інструментом для значень, які рідко змінюються, таких як тема, локалізація чи сесія. Іронічно, але саме стовпець з темою показав найгірші результати, оскільки стан кошика використовував той самий постачальник даних.
  • Модель „піднятого стану“ все ще є правильною для станів, які не діляться між елементами. Вона програла лише тому, що цей конкурс спеціально присвятований механізмам обміну даними.

Правило дизайну, яке не залежить від смаку

Кожна реалізація тут створювала свій магазин усередині компонента. Магазин, визначений на рівні модуля, залишається активним після демонтажу компонента, завдяки чому кошик покупок зберігає свої дані після виходу з облікового запису та привітає наступну особу, яка увійде в систему на тому самому пристрої. Це також спричиняє поширення даних стану між тестами та між запитами під час обробки на сервері. Якщо ви хочете взяти одне правило перевірки коду з цього порівняння, нехай це буде таке: обмежуйте діапазон дії магазинів до дерева компонентів, зазвичай створюючи їх у провайдері, якщо тільки ви не хочете їх глобального існування.

Запуск того самого конкурсу у вашому власному додатку

Ви можете відтворити ці вимірювання для власного стану вже за один день:

  1. Виберіть найбільш суперечливу частину спільного стану у вашому додатку та створіть навколо неї конструкцію з чотирьох компонентів: два компоненти для запису даних, один для читання похідних значень та один простою компонент-спостерігач.
  2. Додайте лічильник рендерингу до кожного компонента. Об’єкт на рівні модуля плюс один виклик track() у тілі кожного компонента займає близько десяти рядків коду. Це і є вашим „податком“ у форматі Context — він вимірюється, а не припускається.
  3. Виміряйте кожен кандидат за допомогою esbuild --bundle --minify, позначивши React як зовнішній модуль. Це займає кілька секунд та додає стовпець з кількістю кілобайт у результати аналізу.
  4. Якщо ви використовуєте Context, а компонент перерендерується, розділіть контекст або перейдіть на сховище на основі селекторів, потім знову запустіть лічильник та включіть значення до та після в прохання про зміни.
  5. Повторіть усю процедуру ще раз, коли React Compiler стане частиною процесу будування проекту, оскільки автоматична мемоізація може суттєво змінити дані про стан компонентів.

Відкриті питання

Два потоки залишаються незакріпленими. Той, що менший, є причиною зайвої обробки даних у Jotai. Більший — це React Compiler. У рядку lifted-state відображається 6/6/6/6 саме тому, що ніщо в ньому не зберігається у пам’яті, а саме це процес мемоізації автоматизує компілятор. Чи дозволить це привести цей рядок у відповідність із механізмами зберігання даних, заснованими на селекторах, у справжньому продакшн-коді, а не лише у демо-версії, — це очевидний наступний експеримент.

Що означають показники обробки даних та розміри бандлів

  • У невеликих масштабах різниці у базовому коді між бібліотеками стану є незначними; справжні відмінності проявляються у поведінці обробки даних та розмірах бандлів.
  • Один контекст, який зберігає змінюваний стан, змушує переобробляти дані кожним користувачем, включаючи компоненти, які ніколи не читають змінені значення.
  • Магазини, засновані на селекторах, чи то Redux Toolkit, Zustand, MobX, чи самореалізований магазин useSyncExternalStore, використовують однакову ефективну схему відображення.
  • Zustand та власноруч написаний магазин забезпечують таку поведінку при розмірі менше кілобайта; більш великі бібліотеки мають такий розмір завдяки інструментам та конвенціям, а не процесу відображення.
  • Метод блокування операцій у Valtio є корисним для потоків оновлень із різкими змінами, а не для звичайних кліків.
  • Створюйте магазини всередині дерева компонентів, щоб стан не існував довше, ніж сесія, яка його створила.