Почему архитектура важнее выбора стейт-менеджера в React
- пятница, 2 октября 2026 г. в 00:00:07
TL;DR. Собрал одно и то же CRUD-приложение на восьми стейт-менеджерах (effector, zustand, mobx, react-query, rxjs, reatom, xstate, jotai) и увидел: дело не в выборе библиотеки, а в архитектуре. Три правила, которые реально решают:
не тяните одну независимую фичу напрямую в другую — связывайте их через компоненты и хуки, а не импортом модели в модель;
один стор — одна сущность, без общего “божественного” стора на всё приложение;
создавайте и удаляйте модели вместе с компонентом — не превращайте их в постоянный глобальный стор без необходимости.
Итог: для серверных данных обычно достаточно tanstack react query, глобальный стейт-менеджер нужен реже, чем кажется, а если нужен — почти любой подойдет, если соблюдать эти правила. Демо — tables-frontend.onrender.com, код — в репозитории.
Когда я только начинал свой путь разработчика мне было интересно изучать новые технологии. Учил все подряд. Все знал. Но что лучше или хуже и уже тем более почему — сказать не мог. Именно об этом — как писать код правильно — я собираюсь сейчас рассказать. И буду это делать через призму выбора стейт-менеджера в React.
Вначале название этой статьи было — “Как выбрать стейт-менеджер в React?”. Но в процессе работы выяснилось, что смысл сместился к архитектурным принципам — это заметил Андрей Грищенков, когда проводил ревью черновика, за что ему отдельное спасибо. Так я сменил название, хотя ответ на этот вопрос все же будет.
Далее я иногда буду применять термин “глобальный стейт-менеджер” — это менеджер данных, который может объявить стор где-то в стороне от реакт-компонентов. Хотя по большей части определения стейт-менеджер и глобальный стейт-менеджер являются синонимами.
Итак. Представим вы техлид и вашей команде предстоит реализовать новый проект на реакте. И нужно это сделать так, чтобы не переписывать проект через год, когда станет понятно, что:
скорость реализации однотипных задач остается низкой даже после реализации всех необходимых инструментов (фабрики, хуки, компоненты и тд).
поддержка существующих решений станет затруднительной, потому что количество связей в модулях стало огромным.
явно виден bus factor — кто написал код, тот и знает, как он работает.
генерация кода с помощью нейросетей увеличивает хаос в проекте.
Именно эти пункты и говорят нам, что архитектура проекта должна стоять на первом месте. Чтобы не спорить об этом в теории, я сделал эксперимент.
В этом проекте я собрал приложение, в котором на восьми подходах рассмотрел разницу между стейт-менеджерами: effector, zustand, mobx, react-query, rxjs, reatom, xstate и jotai. Для большинства есть статический и динамический вариант. Репозиторий: фронтенд-часть проекта
В src лежат папки с названиями стейт-менеджеров, у динамических вариантов к названию добавлено -dynamic-models. В каждой папке есть файл ui/CarsPage.tsx, и все эти страницы практически одинаковы. Каждая импортирует хуки с одинаковыми названиями, которые являются адаптерами к моделям данных соответствующего стейт-менеджера.
Отличий два. У mobx страница обернута в observer() — без него компонент не подпишется на изменения. У react-query перед вызовом refetch() стоит void: он возвращает промис, а остальные менеджеры возвращают ничего. Больше в страницах ни одной строчки, зависящей от библиотеки.
Страница не знает, какой стейт-менеджер под ней работает. Значит, выбор менеджера не определяет архитектуру — определяют её три правила, которые дальше и разберем.
Первый пункт — изолированность, то есть создание сторов под конкретные доменные сущности. Например, отдельное авто или весь каталог машин.
В противовес изолированности выступает божественный объект (God object) — единый стор, в который складывают все данные приложения сразу. Классический пример — Redux. Слайсы в Redux Toolkit делят код на модули, но на выходе все равно один стор и одно дерево состояния.
Второй пункт — принцип low coupling high cohesion. Данный принцип говорит нам о том, что в приложении между различными модулями должно быть как можно меньше связей, и внутри модуля связей может быть больше, так как модуль — это логически связанный функционал или юнит. Кто хочет чуть больше, можно почитать здесь.
Третий пункт — динамическое создание и удаление моделей данных. Динамический подход позволяет создавать независимые модели под жизненный цикл конкретного компонента или страницы.
В противовес этому выступает паттерн Key-Value в глобальных сторах — когда на клиенте создается объект, куда по ключам складываются модели данных. По сути это тот же механизм, что и кэш самого React Query: там данные тоже хранятся под ключом (например, ['todos']) в обычном key-value хранилище. Разница не в паттерне, а в сборке мусора: React Query сам чистит неиспользуемые записи через gcTime (по умолчанию — 5 минут после того, как последний потребитель отписался), а в ручном Key-Value в клиентских сторах — если разработчик не удалил запись по ключу сам, она остается в сторе навсегда.
Один стор — один тип данных. Название стора четко отражает его ответственность. Single responsibility principle.
Какие менеджеры позволяют держать отдельный стор на каждую сущность: effector, zustand, mobx, tanstack react query, rxjs, reatom, xstate, jotai. Redux (в том числе Redux Toolkit) в этот список не попадает. Слайсы разделяют код, но не стор - он один.
Здесь нас в основном интересует снижение связей между различными частями, поэтому high cohesion можно не рассматривать.
Чем меньше связей между частями приложения, тем проще приложение в поддержке, разработке, повышается переиспользуемость, ну и соответственно дешевле для бизнеса.
Как добиться снижения связей, если связи все равно должны быть?
Например, у нас есть таблица, в шапке таблицы есть кнопка. Кликаем на кнопку, совершаем действие. И в результате успешного действия нужно обновить табличные данные. То есть связь все же существует. Но как настроить связи так, чтобы каждая фича осталась независимой друг от друга? Ответ — завязаться на интерфейс. Что имею в виду — связь (она же зависимость) внедряется через пропсы.
Здесь снова немного теории. Прямой импорт зависимости — это жесткая связь: модуль сам находит и подключает конкретную реализацию. Внедрение зависимости — когда реализацию передают снаружи, через параметры. Второе и называется dependency injection, а первое — как раз то, от чего он избавляет.
Возвращаемся к low coupling. Вот как раз от прямого импорта зависимостей нужно избавляться. Теперь к стейт-менеджерам: effector, zustand, mobx, tanstack react query, и многие другие. У каждой фичи свой стор и методы (модель данных). Нужно лишь у каждой фичи создать контракт.
Допустим та кнопка в шапке таблицы — это загрузка данных в БД через импорт файла эксель. Пользователь жмет на кнопку, открывается модалка, выбирается файл, пользователь жмет Загрузить. Происходит запрос на бэк, бэк отвечает 200 ок. Показываем уведомление и обновляем табличные данные. Но как обновить табличные данные?
Можно подумать так: у нас есть сторы (модели) табличных данных и кнопка импорта. Нужно просто где-то в модели таблицы импортировать модель импорта и вызвать соответствующий метод обновления при успешном событии импорта. Это самый очевидный путь, и я считаю его ошибкой: именно так и повышается количество связей между модулями. Так в коде появляется два параллельных процесса:
Бизнес-логика в моделях данных,
Композиция компонентов. И если компоненты позволяют разработчику видеть как данные перетекают сверху вниз, то, так называемая, бизнес-логика в моделях данных создает хаос. Я видел модели на 2500 строк (подробнее об этом — в разделе про effector дальше). Разбираться в таком коде становится затруднительно. И далее простая задача по добавлению новой фичи становится задачей по добавлению этой фичи + задачей по тщательной проверке текущей бизнес-логики — ведь столько понаписано и нет уверенности, что все продолжит работать как прежде. Как это влияет на стоимость разработки вы уже догадываетесь.
Итак. Правильный ответ — нужно создать контракт у компонента кнопки импорта.
type ImportButtonProps = { onSuccess: () => void; params: Record<string, string>; };
Где-то в обертке таблицы мы создаем примерно такой код (хуки здесь условные):
function OrdersTableWidget() { const { refetch } = useTableData(); const params = useImportParams(); const headerButtons = [ <ImportButton key="import" onSuccess={refetch} params={params} />, ]; return <Table headerButtons={headerButtons} />; }
Таким образом кнопка импорта становится независимой, и мы можем переиспользовать ее на любом экране, а также несколько раз на одном экране: каждый экземпляр будет полностью независимым.
Этот приём есть в демо-проекте, который я собрал для статьи. В CarsPage после создания, редактирования и удаления вызывается list.refetch(). Сами фичи друг о друге не знают, связь живет только в странице.
Вернемся к стейт-менеджерам.
Если следовать правилу с созданием контракта и не импортировать одни сторы в файлы с объявлением других сторов (или где-то со стороны — что-то наподобие оркестратора моделей), то любой стейт-менеджер подойдет. Самое главное — реализовывать бизнес-логику связи фичей через компоненты — то есть контракт.
Здесь может возникнуть вопрос: а разве setFilters, который сбрасывает страницу, — не такая же связь между фичами, которую я только что запретил? Нет, и вот почему. filters, sort, paginate и list — не отдельные бизнес-фичи, а куски одной и той же фичи «показать таблицу с фильтрами». Они и так всегда используются вместе, на одном экране — просто разложены по отдельным моделям ради переиспользования.
Собрать их вместе можно так: каждый кусок — свой класс (FilterCarsStore, SortCarsStore, PaginateCarsStore, CarsListStore), и ни один из них не знает про остальные три. Поверх — один класс-обертка (CarsPageStore), который создает экземпляры всех четырех и определяет методы-обертки: например, setFilters вызывает метод стора фильтров и следом сбрасывает страницу в пагинационном сторе.
Экспортировать можно сам класс — его можно создать через new в другом месте и получить независимый экземпляр, так же как это уже устроено в динамических моделях этого проекта, только там один и тот же класс создают через new на каждый маунт компонента, а здесь четыре разных класса созданы по одному разу общей оберткой. А вот сам экземпляр не экспортируется. Он остается внутри файла, который наружу экспортирует только хук.
Такая обертка — инкапсуляция в прямом смысле: CarsPage.tsx не видит, что под одним хуком спрятаны четыре модели, он получает готовый результат. И тут работает тот же принцип, что и с пропсами в компонентах: OrdersTableWidget знает про ImportButton и передает ему данные, а ImportButton не знает, кто его вызвал. Точно так же CarsPageStore знает про все четыре класса и создает их, а сами классы не знают ни про обертку, ни друг про друга. Знание в обоих случаях течет только в одну сторону: от того, кто собирает части, к самим частям, а не наоборот.
Не стоит делать вывод, что такая обертка годится для сборки всегда, или что компонент — единственно правильное место. Дело не в том, где написан код, а в том, что он объединяет. Куски одной фичи — filters, sort, paginate, list — можно сшивать в обертке. А если в ту же обертку добавить, например, модель создания машины (create-car) — самостоятельную фичу, которая могла бы существовать и без этой таблицы, — вы получите тот же оркестратор, что описан в разделе про MobX: класс, который импортирует другие классы и настраивает связи между ними. Такую связь оставляем в компоненте, как в примере с onSuccess={refetch} из начала статьи.
Здесь дотошный читатель, вероятно, укажет на противоречие: несколькими абзацами выше я объяснял, что прямое создание зависимости внутри класса — это не dependency injection, а как раз то, от чего он избавляет. А в CarsPageStore ровно так и сделано: readonly filterStore = new FilterCarsStore();. По-хорошему это должно было бы выглядеть так:
const model = new CarsPageStore({ filterStore: new FilterCarsStore(), sortStore: new SortCarsStore(), paginateStore: new PaginateCarsStore(), listStore: new CarsListStore(), });
Смысл DI в том, чтобы подменить конкретную реализацию, когда она бывает разной — для тестов, для другого окружения. У каждого из этих четырех классов реализация всего одна, подменять нечего. Добавить конструктор с параметрами можно, но это будет оверинжиниринг: кода больше, а реальной проблемы, которую он решает, нет.
Рабочий пример на effector и mobx лежит в том же демо-проекте: вкладки Effector (page model) и MobX (page model) в репозитории.
Если обобщить мой опыт использования глобальных стейт-менеджеров, то можно вывести такое правило использования — из модели следует экспортировать только хуки, экспорт примитивов стейт-менеджера запрещен. Это правило обеспечит low coupling.
Проблемы, которые возникают при экспорте примитивов любого стейт-менеджера, одинаковы — увеличивается количество связей, и как следствие сложность проекта.
Правило не делает плохую композицию невозможной — оно делает любую композицию (хорошую и плохую) прослеживаемой из дерева компонентов, а не спрятанной в параллельном графе импортов между моделями. Клубок из хуков — это обнаружимая проблема (открыл компонент → одним переходом дошёл до хука → увидел, что он тянет). Клубок из моделей, импортирующих друг друга, — необнаружимая проблема, пока не пойдёшь специально её искать по всем файлам моделей. То есть граница та же самая, что мы уже выводили для фабрик: можно сшивать куски одной фичи в одном хуке, вызванном из одного конкретного места; нельзя тащить туда несвязанные фичи.
Практическое правило такое: если всю логику можно проследить из дерева компонентов через go to definition (в VS Code — Ctrl+Click или F12), значит с архитектурой, скорее всего, все в порядке.
Допустим, мы хотим отобразить несколько одинаковых компонентов в списке. Заранее мы не знаем количество этих компонентов.
В моей практике есть пример — в зависимости от метаданных виджет должен отрисовать одну, две или три таблицы друг под другом. Все они имеют одинаковую бизнес-логику и получают данные из одного эндпойнта. Эндпойнт понимает какие данные нужно отдать в зависимости от метаданных. То есть наполнение каждой таблицы (как и колонки таблицы) будет разным.
Динамический подход решает эту задачу очень просто.
Динамическое создание моделей также позволяет:
Упростить lazy-loading: логика и ее связи не загружаются в бандл сразу, а инициализируются по требованию — вместе с монтированием UI.
Избежать циклических зависимостей: разбивая монолитную модель на мелкие динамические модули, мы разрываем жесткие перекрестные импорты.
Например, в шапке таблицы у нас есть кнопка, которая открывает модалку. Эта кнопка и весь ее функционал находится под защитой пермишенов. Но действия внутри модалки могут повлиять на таблицу. Таким образом подписка на обновление табличных данных должна произойти после открытия модалки и должна удалиться после ее закрытия. Иначе — получаем лишние подписки и возможные утечки памяти, так как в глобальный стор можно положить что-то, что не даст сборщику мусора выполнить свою работу.
Итак, нам точно нужно динамическое создание и удаление моделей данных.
Из известных мне менеджеров под этот критерий подходят:
mobx — модель можно создать в компоненте, примеры есть в документации;
react-query — useQuery и useMutation сами по себе хуки;
zustand — для стора, которому нужны данные из компонента, документация рекомендует vanilla-стор (createStore) в React-контексте; в демо-проекте я обошелся useState(() => create(…));
jotai — по документации, значения атомов хранятся в сторе, а стор лежит в Provider; в демо-проекте у каждого экземпляра свой стор (createStore) и свои атомы, создаваемые в useState;
effector — с оговорками, о них ниже;
rxjs, reatom, xstate — динамические варианты для них тоже есть в демо-проекте.
Redux Toolkit в этот список не входит. Слайсы можно добавлять после создания стора (combineSlices и inject), но штатного удаления нет: в примере из документации редьюсер «удаляют», подменяя его пустым, а стор остается один.
Далее я рассмотрю effector, mobx и tanstack react query — как каждый из них соблюдает три правила и какие проблемы у меня возникали на практике.
Динамическая инициализация моделей.
В документации Effector правило такое: создавать юниты и связи между ними «не в рантайме, а статически на уровне модуля, чтобы избежать утечки памяти в вашем приложении». Объяснение в устройстве ядра: «Каждый юнит — это узел в графе, каждый узел хранит в себе информацию о состоянии, операциях и связи с зависимыми юнитами», и «Так как ссылки на объекты юнитов сохраняются в графе, то GC в Javascript не способен удалить их из памяти. Поэтому, например, если вы создадите юниты или связи между ними внутри React компонента, то у вас при каждом маунте компонента они будут создаваться по новой, а старые юниты все также будут жить и работать». На момент публикации этой статьи в документации было написано, что динамические модели обещают в следующем мажорном обновлении.
Я долго читал это как «динамика с эффектором под запретом». Потом проверил на практике, и выяснилось, что для одного сценария это неверно.
Эксперимент. Фабрика создает изолированную модель: события, сторы, эффект и sample между ними. Фабрика вызывается в useMemo, то есть модель живет ровно столько, сколько компонент. Каждый созданный юнит я регистрировал в FinalizationRegistry. Это встроенный механизм JS: его колбэк вызывается только после того, как движок реально собрал объект. После 20 переключений между табами и принудительной сборки мусора число созданных юнитов совпало с числом собранных. clearNode при этом нигде не вызывался.
Что из этого следует. Модель, все связи которой замкнуты внутри нее самой, собирается сборщиком мусора без clearNode. Чего я не проверял: модель, связанную через sample с долгоживущим статическим юнитом. Про нее я ничего не утверждаю. Но почти уверен, что именно от этого и предостерегают создатели библиотеки.
Проверить самому можно здесь. Переключитесь несколько раз с таба Empty на Effector (dynamic), затем в DevTools откройте вкладку Memory, нажмите «Collect garbage» и выведите в консоль window.__leakProbe. Колбэк вызывается не мгновенно, поэтому сборку стоит нажать пару раз с паузой. Либо запустите проект локально из репозитория, указанного в начале статьи.
Изолированность.
Здесь все хорошо. Эффектор позволяет не собирать глобальный божественный стор. Один стор — один тип данных.
Low coupling.
Здесь зависит от того, как подойти к задаче.
I. Статическое создание моделей.
Можно просто создавать события, сторы и эффекты и сразу экспортировать их. Это создает хаос в проекте, потому что можно где-то со стороны (в другой модели) импортировать событие или стор и проводить манипуляции со стором. Поддерживать такой код крайне сложно.
Можно создать фасад — публичный апи модели и экспортировать его. Немного лучше, потому что можно скрыть некие производные сторы, которые не должны торчать наружу, но это все равно создает возможность создать большую связанность, так как никто не запретит импортировать публичный апи и манипулировать сторами напрямую.
События, сторы и эффекты и остальные юниты не экспортируются. Экспортируются хуки. То есть в файле модели создаются все юниты, но к ним нет доступа вне этого файла. Доступ — через хуки, которые объявляются в этом же файле. Это позволит а) ограничить от импорта этой модели в другие модели и б) перенести взаимодействие на компоненты (контракт между фичами) — таким образом достигается инверсия зависимости.
II. Динамическое создание моделей (нежелательный способ согласно документации).
Создается фабрика, которая вызывается в компоненте в useMemo. Далее через useUnit достаем данные и методы модели — так low coupling полностью соблюдается.
Моя боль при работе с эффектором.
Так как документация эффектора говорит, что ключевое правило — создавать модели статически, — именно так и делали. К чему это привело?
К сильному раздуванию кодовой базы. Для реализации стандартного функционала требовалось скопировать почти весь код другой страницы. Вместо создания компонента таблицы с сортировкой, фильтрацией, пагинацией, которые являются частью компонента, все фабрики вызывались на уровне модуля. И так с каждой таблицей. Я переписал эту логику на хуки + tanstack и боль ушла.
Создавались огромные модели на 2000-2500 строк. Кто написал код, тот его и понимает. Даже просто читать такой код сложно. Нет четких границ бизнес-логики. Стор, созданный в фабрике и имеющий свое событие обновления, может быть обработан где-то со стороны (фабрика возвращает стор, sample вне фабрики может использовать его в качестве target) и эффектор никак не запретит это делать.
Магия. Попытка полностью описать бизнес-логику на эффекторе приводит к магическому исполнению кода. Например, по нажатию на кнопку нужно отправить запрос на бэк. В тело запроса нужно положить данные из трех сторов. В компоненте из модели импортируется событие, которое вешается на onClick кнопки. А дальше магия. Где-то в модели происходит импорт других моделей (ну или без импорта, но тогда файл модели разрастается до 2500 строк, и получается божественная модель), sample использует нужные сторы, делает проверку, собирает данные из сторов, и происходит запрос на бэк. Как это исправить? Нужно сделать так, чтобы событие получало все данные — вот и все. В компоненте достаем нужные данные из сторов, и прокидываем все данные в это событие. Так бизнес-логика становится более изолированной от других частей.
Динамические модели.
В документации MobX есть несколько примеров создания моделей данных в компоненте. А это значит Mobx может в динамику. На момент написания статьи в документации представлено два примера с useState, и один с useLocalObservable.
Изолированность.
Как и у Эффектора — тоже все есть. Создаем класс (он же модель данных), в котором объявляем нужный тип данных и методы управления данными.
Low coupling.
Снова зависит от того, как подойти к задаче.
Как мы на проекте делали неправильно — стор напрямую импортировал уже готовые сторы, созданные в других файлах (не классы, а их синглтон-инстансы), и следил за ними с помощью makeAutoObservable. Таким образом у нас появились оркестраторы сторов. Минус вайб. Мы так делали, потому что не сразу увидели, как усложняется код.
Как нужно — сторы не знают о существовании других сторов, и получают свои зависимости из аргументов методов. К этому мы и пришли через пару месяцев.
Моя боль при работе с mobx.
Она очень похожа на боль с effector — когда одни сторы импортируют другие сторы напрямую. Лечится это одинаково — взаимодействие должно происходить где-то в компонентах.
Реакт-квери является сервер-стейт-менеджером. Это значит, что клиентскую бизнес-логику написать на нем не получится. Но в чем заключается бизнес-логика? По моему опыту большая часть задач на фронте — это собрать данные, отправить их на бэк, получить ответ и отрисовать его. Нужно ли для этого заводить стейт-менеджер? Я считаю, в целом можно жить и без стейт-менеджера, а бизнес-логику реализовывать на хуках. Но есть минус отсутствия глобального стейт-менеджера: для глобальных данных придется завести контекст, и любое его изменение перерисовывает всё поддерево потребителей.
С динамикой у реакт-квери все в порядке. Основные хуки useQuery и useMutation — это хуки:)
Изолированность.
Здесь тоже все в порядке — оба хука создают изолированную модель серверного стейта, который можно инвалидировать откуда вам угодно, зная queryKey.
Low coupling.
Low coupling соблюдается. Хуки вызываются в реакте, а значит модели не живут отдельной жизнью, их границы четко видны. И в сравнении со статичными глобальными моделями — контракт между фичами легче выдерживать.
Боль при работе с react-query — если на странице несколько экземпляров одного и того же запроса с разными параметрами (например, три таблицы, у каждой свой queryParams), снаружи нельзя просто прочитать данные конкретного экземпляра: точный ключ — приватное состояние внутри его собственного компонента. Фабрика ключей (Effective React Query Keys) здесь не поможет — мы все равно не знаем точный ключ. Значит, состояние придется поднять выше и передать вниз через пропсы до нужного компонента.
Возвращаясь к четвертому пункту из списка в начале статьи — генерация кода нейросетями увеличивает хаос в проекте. Вот в чем дело.
LLM не имеет собственного мнения об архитектуре — она делает то, что видит вокруг, и идет кратчайшим путем к рабочему результату. GitHub прямо пишет об этом в документации Copilot — в разделе для JetBrains сказано: “GitHub Copilot will attempt to match the context and style of your code” (источник) — модель подстраивается под контекст и стиль вашего кода. Если в проекте уже есть прямой импорт одной модели в другую, LLM с высокой вероятностью повторит этот паттерн, потому что он у нее перед глазами, а не потому что она “выбрала” плохую архитектуру.
Отсюда практический вывод: правило «из модели экспортируются только хуки» ограничивает не только человека, который может забыть или полениться. Оно ограничивает архитектурно — LLM физически не может импортировать юнит, которого нет в экспортах файла, какой бы путь к результату ни казался ей короче. Хорошие правила в проекте — это заслон от хаоса, который приносит любой автор, включая нейросеть.
Выводы можете сделать самостоятельно, какой менеджер выбрать. Я же дам свое мнение, что применять на проекте.
Для серверного стейта — tanstack react query. Выглядит так, что tanstack продумал все кейсы общения клиента и сервера. Хорошая документация подскажет, как реализовать вашу фичу.
Как глобальный стейт — zustand, effector или mobx — не так важно, что именно. Здесь вопрос больше про необходимость глобального стейта и предпочтение команды — на чем писать бизнес-логику — с применением стейт-менеджера или же просто на хуках. А если внимательно посмотреть на каждый стейт-менеджер, то разницу можно увидеть по большей части лишь в стиле, который навязывает библиотека, а суть — одна — создавать стор и методы его управления.
По моему опыту, большая часть фичей в стандартной разработке не требует глобальных сторов. Поэтому я считаю наиболее удобным способом для реализации бизнес-логики — tanstack react query (еще есть альтернатива — SWR, но мне все же ближе react query) + хуки, либо react query + практически любой стейт-менеджер, который поддерживает динамическое создание моделей, и хуки-адаптеры к ним.
А если нам нужно создать какой-то глобальный стейт и он должен быть синглтоном?
Я бы выбрал Zustand и если точнее его метод create. Нужно лишь результат назвать как хук — с префиксом use. Так метод create создаст хук, который мы будем применять только в рамках компонентов, и защищает от создания сторов и связывания этих сторов напрямую, так как хуки можно вызывать только в реакте.