Оцінка менеджерів стану React: порівняння кількості відображень та вартості пакету
Одне додатко для кошика, створене за допомогою восьми шаблонів стану React, протестоване щодо зайвих відтворень та розміру стиснутого пакету, а також що показують ці цифри щодо Zustand, Valtio та Context.
Питання про те, який менеджер стану React обрати, постійно повертається, і дискусії зазвичай ґрунтуються на думках, а не на показниках. Кориснішим підходом є створення однакової невеликої функціональності за допомогою кожного з поширених патернів, збереження стабільної поведінки за допомогою спільного набору тестів та врахування того, що насправді відрізняється: як часто компоненти перерендеруються та скільки кілобайт додає кожен варіант. У цій статті описується такий експеримент із восьма патернами, пояснюється, чому отримуються саме такі цифри, та наводиться алгоритм для повторення цього тестування з власним менеджером стану.
Як налаштовується експеримент
Тестовий додаток навмисно мінімалістичний: це кошик для покупок із кнопкою „Яблуко“, кнопкою „Банан“, підсумком та значком теми, значення якого ніколи не змінюється. Кожна реалізація перевіряється за допомогою одного й того ж поведінкового тесту, який тричі натискає на кнопку „Яблуко“ та двічі — на кнопку „Банан“, перевіряючи ідентичність результатів. Під час виконання тесту лічильник фіксує кожне відображення кожного компонента. Окремо 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
З цих даних випливають три різні ситуації.
Lifted state та Context переробляють усе
З використанням піднесеного useState та єдиного Context кожен компонент оновлюється після кожного кліку. Індикатор теми відображався шість разів, хоча його значення ніколи не змінювалося, а кнопка з бананом відображалася після кожного кліку на яблуко. Саме це є конкретним механізмом поширеного попередження про те, що Context — це не менеджер стану. useContext підключає компонент до всього значення Context, тож щоразу, коли це значення змінюється, оновлюється кожен компонент-споживач. Розміщення всього стану в одному Context фактично є піднесеним станом з додатковим шаром.
Звичайним рішенням є розділення стану між кількома Context або мемізація компонентів-споживачів, проте це тут не вимірювалося. Цей приклад ілюструє Context у тому вигляді, у якому його найчастіше використовують: один постачальник, який містить одне значення.
Чотири API, один ідентичний результат
Redux Toolkit, Zustand, MobX та власноруч написаний store створюють абсолютно однакову поведінку: кожен компонент відрендеровується один раз під час ініціалізації та ще раз лише тоді, коли змінюється саме та частина даних, яку він читає. Їхні API зовсім не схожі, проте їхня робота під час виконання збігається, оскільки всі чотири засоби ґрунтуються на одній і тій самій основній ідеї. Компоненти прослуховують лише обрану частину стану, а не весь store, і їм повідомляють лише тоді, коли ця частина змінюється. Щоб детальніше дізнатися, як відбувається вибір частини даних та перевірка її рівності в одній з цих бібліотек, перегляньте як 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, заснована на спостережуваних даних, є найкращою у коді, який сильно залежить від класів, а такого коду у цьому додатку немає.
Правило проектування, яке не залежить від смаку
Кожна реалізація тут створює свій сховище всередині компонента. Сховище, визначене на рівні модуля, залишається активним після демонтажу компонента, завдяки чому кошик покупок зберігає свої дані після виходу з облікового запису та привітає наступну особу, яка увійде в систему на тому самому пристрої. Це також спричиняє поширення даних стану між тестами та під час обробки запитів на сервері. Якщо ви хочете взяти одне правило перевірки коду з цього порівняння, нехай це буде таке: обмежуйте сферу дії сховищ до дерева компонентів, зазвичай створюючи їх у провайдері, якщо тільки ви не хочете їх глобального існування.
Запуск того самого конкурсу у вашому власному додатку
Ви можете повторити ці вимірювання для свого власного стану протягом одного дня:
- Виберіть найбільш суперечливу частину спільного стану у вашому додатку та створіть навколо неї систему з чотирьох компонентів: два компоненти для запису даних, один — для читання похідних значень та ще один — як пасивний елемент.
track() у тілі кожного компонента займає близько десяти рядків коду. Кількість перерендерингів, яку фіксує цей лічильник, є вашим «податком» у контексті, виміряним точно, а не припущеним.esbuild --bundle --minify, вказавши React як зовнішній модуль. Це займає кілька секунд та додає стовпець з кількістю кілобайт у результати аналізу.Відкриті питання
Два потоки залишаються вільними. Той, що менший, є причиною зайвого відтворення елементів у Jotai. Більший — це React Compiler. У рядку lifted-state відображається 6/6/6/6 саме тому, що ніщо в ньому не мемоїзується, а мемоїзація — це саме те, що автоматизує компілятор. Чи дозволить це привести такий рядок у відповідність із механізмами зберігання даних, заснованими на селекторах, у справжньому коді для продакшену, а не лише в демо-версії, — це очевидний наступний експеримент.
Ключові висновки
- У невеликих масштабах різниця у базовому коді між бібліотеками для керування станом є незначною; справжні відмінності проявляються у поведінці відтворення елементів та розмірі збірки коду.
- Один контекст, який зберігає змінюваний стан, змушує до повторного відтворення кожен компонент, навіть ті, які ніколи не читають змінені значення.
- Механізми зберігання даних, засновані на селекторах, чи то Redux Toolkit, Zustand, MobX чи власна реалізація
useSyncExternalStore, призводять до однаково ефективної моделі відтворення елементів.