Стейт-менеджмент в 2026: где граница между серверным и клиентским состоянием
PR, где серверные данные скопировали в Redux-слайс, и протухший кэш следом. Как развожу серверное и клиентское состояние и когда RTK всё ещё оправдан.
Месяц назад на ревью пришёл PR, который меня остановил. Джун, которого я менторю, добавил в Redux-слайс список расшаренных ссылок — тех самых, что мы уже получаем через RTK Query. Логика была понятная: пусть лежит в сторе, чтобы достать откуда угодно. Он подписывался на запрос, в useEffect клал ответ в слайс, компоненты читали уже из слайса.
// антипаттерн из того PR: серверные данные переложили в слайс
const { data } = useGetSharedLinksQuery();
useEffect(() => {
if (data) dispatch(setLinks(data)); // вторая копия тех же данных
}, [data]);
// дальше компоненты читают из слайса — и он протухает, пока кэш RTK Query свежий
Работало. Ровно до момента, когда ссылку отзывали в другой вкладке: при следующем возврате фокуса RTK Query перезапрашивал список и обновлял свой кэш, а слайс держал старый. Два источника правды, и UI показывал тот, что протух.
Я не стал расписывать в комментарии, почему слайс тут лишний. Оставил один вопрос: эти данные чьи, наши или бэка? Баг был не в useEffect и не в подписке. Он был в том, что серверные данные положили в хранилище, придуманное для клиентского состояния. Эту путаницу я в ревью отмечаю чаще всего, и почти весь стейт-менеджмент в 2026 устроен вокруг того, чтобы её не допускать.
Две разные вещи, которые называли состоянием
Лет пять назад состоянием считали всё, что не пропс. Redux был хранилищем общего назначения: туда клали и ответ бэка, и флаг открытого модального окна, и черновик формы. Пока приложение маленькое, это удобно. На масштабе потребительского файлового менеджера этот подход разваливается, потому что у этих вещей совершенно разный жизненный цикл.
Серверное состояние я не контролирую. Оно живёт на бэке, приезжает асинхронно и может устареть в любой момент, потому что его поменял другой клиент, другая вкладка, фоновая джоба. Ему нужны дедупликация запросов, кэш с TTL, инвалидация, рефетч на фокусе, статусы загрузки и ошибки, ретраи. Это снимок серверных данных, который надо держать актуальным.
Клиентское состояние я контролирую целиком. Открыт ли дровер, какие строки выбраны, что юзер набрал в фильтре, но ещё не применил. Оно синхронное, живёт ровно столько, сколько нужно UI, и никакой инвалидации не требует. Прогнать его через ту же машинерию, что и серверное, можно, но тогда я плачу за инвалидацию там, где инвалидировать нечего, и пишу кэш руками там, где библиотека сделала бы это за меня.
Карта на сегодня
Если разложить то, чем команды реально пользуются в 2026, картина простая.
Серверное состояние закрывают специализированные библиотеки. TanStack Query, если стор не привязан к Redux. RTK Query, если вы уже в экосистеме Redux Toolkit и не хотите тащить второй инструмент. Apollo, если у вас GraphQL и нужен нормализованный кэш по схеме. Все трое делают одно: кэш по ключу запроса, дедуп, инвалидация, фоновый рефетч, статусы. Разница в интеграции и в том, GraphQL у вас или REST.
Клиентское состояние закрывают вещи попроще. useState и useReducer для локального. Context, когда значение нужно поддереву и меняется редко. Zustand или Jotai, когда есть действительно глобальное клиентское состояние, которое читает много кто и которое часто меняется. Zustand даёт один стор с селекторами, Jotai — атомы, которые собираются снизу вверх. Выбор между ними у меня обычно про вкус команды, реже про возможности.
Отдельно стоит Effector: состояние как граф из событий, сторов и эффектов, логика живёт вне компонентов. В русскоязычных командах его берут заметно чаще, чем можно решить по англоязычным обзорам, и на сложной клиентской логике он хорош. Из того же лагеря, что Zustand, есть ещё Valtio с прокси-подходом, но это уже на любителя.
Отдельно про Context, потому что его часто берут не по адресу. Я держу его для редкого: тема, локаль, текущий юзер. Для горячего он плох: любое изменение значения перерендеривает всех потребителей разом, без селекторов. Эту дырку и закрывают Zustand с Jotai — подписка на нужный срез вместо всего значения.
Redux Toolkit стоит в обеих колонках сразу, и отсюда вечная путаница. RTK Query — это серверное состояние. Слайсы и стор — клиентское. Когда говорят «у нас Redux», обычно имеют в виду и то и другое, и спорить «нужен ли Redux» без этого разделения бессмысленно.
Где Redux Toolkit всё ещё на своём месте
Хоронить Redux стало модно, и зря. Файловый стор у нас в Облаке по-прежнему на RTK, и переписывать его я причин не вижу.
Файловые сценарии — это нормализованная сущностная модель. Файлы, папки, шары, права, текущее выделение, операции в процессе. Одни и те же сущности торчат в десятке мест: дерево, список, превью, тулбар, корзина. Им нужны общие селекторы, оптимистичные апдейты с откатом, когда операция падает, аккуратная инвалидация после batch-операций. Когда мы переделывали корзину под batch-восстановление и окончательное удаление, обращения о потере данных упали примерно на 24%, и держалось это на предсказуемом сторе, где состояние операции и состояние списка не разъезжались. На Zustand это пишется тоже, но связность тут такая плотная, что машинерия RTK — нормализованные слайсы, девтулы с time-travel, мидлвары, типизированные thunk’и — окупается.
Правило, к которому я пришёл: RTK оправдан, когда клиентского состояния много, оно нормализованное и сильно связано между фичами. Если состояние — это пяток флагов и текущий таб, Redux тут из прошлого, по привычке.
Может, библиотека стейта вам и не нужна
Самый недооценённый ответ на вопрос «какой стор взять» — никакой.
Уберите серверное состояние в query-библиотеку, и от «глобального состояния» остаётся на удивление мало. Дальше выясняется, что половину оставшегося естественно держать прямо в URL.
В Авито Путешествиях фильтры отелей жили в query-параметрах. Не копия в сторе с синхронизацией в URL, а URL как единственный источник: компонент читает из строки и пишет обратно при изменении.
// URL — единственный источник, в стор ничего не дублируем (Next App Router)
const params = useSearchParams();
const router = useRouter();
const sort = params.get('sort') ?? 'date';
const setSort = (next: string) => {
const q = new URLSearchParams(params);
q.set('sort', next);
router.replace(`?${q}`);
};
// типобезопасные search-параметры удобнее держать на nuqs
Это чинило диплинки, восстановление контекста после «назад», расшаривание ссылки на выдачу. Повторные поиски были симптомом того, что контекст терялся, и просели на 14%. Стора под фильтры не было вообще: useState для черновика инпута и URL для применённого значения.
Там же мы фильтровали предложения по номерам на клиенте, без похода на бэк. Выглядит как кандидат в стор, но по сути это производное значение: отображаемый список считается из загруженного списка и выбранных фильтров.
// производное значение, а не состояние: считается из двух вещей под рукой
const visible = useMemo(
() => offers.filter((o) => matches(o, filters)),
[offers, filters],
);
Держать это в сторе значит хранить то, что в любой момент пересчитывается из двух вещей, которые и так под рукой. Время на выбор номера упало на 17%, отдельного состояния под результат не появилось.
После этих вычетов на собственно глобальное клиентское состояние остаётся тонкий слой: тема, текущий юзер, что-то про онбординг. Под это Context или маленький Zustand-стор; полноценный стейт-менеджер сюда не нужен.
Куда тянут сигналы и серверные компоненты
На эту картину действуют две силы, каждая в свою сторону.
Серверные компоненты убирают часть состояния из браузера в принципе. Если данные читаются и рендерятся на сервере в RSC, на клиенте кэш под них не нужен: клиентской загрузки нет, кэшировать нечего. Мутации уходят в Server Actions, инвалидация — в revalidate на стороне фреймворка. TanStack Query это не отменяет там, где есть живая клиентская интерактивность, но кусок «глобального состояния», который раньше неизбежно оседал в сторе, теперь может вообще не доезжать до клиента. В Next с App Router граница «что серверное, что клиентское» из метафоры стала буквальной линией в дереве компонентов.
Сигналы тянут с другой стороны. Идея в точечной реактивности: значение знает своих подписчиков, и при изменении пересчитывается только то, что от него зависит, без прогона рендера сверху. Я это уже видел. MobX, с которым я работал на одном из прежних проектов, по сути ровно это: observable плюс автотрекинг зависимостей. SolidJS построен на сигналах с самого начала, Angular перешёл на них недавно, в Preact они есть, в TC39 лежит предложение сделать сигналы примитивом языка. React в сигналы пока не пошёл. Его ответ на ту же проблему производительности — React Compiler, который мемоизирует за вас и оставляет модель «перерендеривается весь компонент» на месте.
Куда это сойдётся, честно не знаю. Ставлю на то, что серверные компоненты дальше будут отъедать у клиентских стор-библиотек именно серверное состояние, а сигналы останутся под капотом фреймворков и стор-движков и в прикладной код у большинства команд не вылезут.
Как я выбираю под фичу
Когда приходит новая фича, я иду по состоянию сверху вниз, и стор-библиотека всплывает почти в конце.
Сначала: это серверные данные? Тогда query-библиотека, и копию в глобальный стор не кладём. Девять споров из десяти про стейт-менеджмент закрываются на этом шаге.
Дальше: это можно положить в URL? Если состояние осмысленно расшарить ссылкой или восстановить после перезагрузки (фильтр, таб, открытый элемент, страница пагинации), его место в URL.
Дальше: оно локально для одного поддерева? Тогда useState или useReducer рядом с местом использования, при необходимости проброшенные через Context. Поднимать состояние выше, чем им пользуются, я научился не торопиться.
И только потом: это правда глобальное клиентское состояние, которое читает много кто и часто меняет? Тогда Zustand или Jotai, а если у фичи уже есть нормализованный связный стор и нужна его машинерия — RTK.
Этот порядок отвечает и на вопрос с того ревью. Список ссылок — серверные данные, шаг один, query-библиотека, никакого слайса.
Где у меня нет чистого правила
На словах граница между серверным и клиентским состоянием чёткая, на практике течёт, и течёт ровно там, где интереснее всего.
Возьмите редактирование. Поверх вьюера у нас встроен PDF-редактор, по нашим замерам он дал около 10% к удержанию в файловых сценариях. Документ приезжает с бэка — это серверное состояние. Но пока юзер его правит, есть локальный редактируемый буфер, который к серверу отношения не имеет до сохранения. Это и есть та самая легитимная копия серверных данных в клиентском состоянии, ровно то, за что я завернул PR в начале. Правило «никогда не копируй серверные данные в стор» здесь ломается: ни форму, ни черновик, ни оптимистичный апдейт без копии не сделать. Разница между хорошей копией и тем багом тонкая. У формы копия живёт явно и осознанно, под понятным жизненным циклом. В том PR она появилась случайно, побочкой от «пусть полежит в сторе».
Оптимистичные апдейты — та же история, но острее. Когда я локально применяю переименование файла до ответа сервера, чьё это состояние? Оно одновременно серверное (кэш query-библиотеки, который я подкручиваю руками) и клиентское (моё предположение о будущем ответе). Граница проходит прямо через одну операцию, и обе библиотеки претендуют на это состояние. Чистого ответа, где этому жить, у меня нет. На практике держу оптимистику внутри кэша query-слоя, потому что инвалидация всё равно его, но шов в этом месте всегда чувствуется, и в код-ревью именно тут чаще всего находится рассинхрон.
Так что единого стора у меня в голове больше нет, и это нормально. Под новую фичу я почти никогда не начинаю с выбора библиотеки. Сначала развожу состояние по типам, и к моменту, когда дело доходит до «а что ставим под глобальное», глобального почти не остаётся. Спорю я теперь про одно: где проводить шов между query-кэшем и клиентским стором, когда оптимистика и черновики садятся на оба сразу. Хорошего общего ответа я не нашёл и подозреваю, что его нет. Он каждый раз про конкретную фичу.