State Management
Server-state против client-state
Дашборд аналитики грузит список проектов из API и кладёт его в Redux-стор, чтобы был под рукой. Через минуту другой пользователь переименовал проект, но на экране всё ещё старое имя, потому что копия в сторе об этом не знает. Команда дописывает ручную инвалидацию: после каждой мутации диспатчит экшен, перетягивает данные, сверяет id. Постепенно половина reducer-ов превращается в самописный кэш с багами - тот самый кэш, который давно решён готовыми библиотеками. Корень в одной подмене: ответ сервера приняли за состояние приложения, хотя это снимок чужих данных с истекающим сроком годности.
- Linear и Vercel: серверные данные держит кэш-слой (Query/SWR), а Redux или Zustand остаётся только под локальный UI-стейт
- Любая админка с таблицами и фильтрами: строки таблицы это серверный кэш, а выбранные фильтры до отправки - клиентское состояние
- E-commerce: каталог, цены и остатки приходят с сервера и устаревают, корзина до оформления живёт на клиенте
- Команды массово выпиливают сотни строк Redux-слоя загрузки данных, оставляя стор под модалки, тему и черновики форм
- GitHub по докладам инженеров использует stale-while-revalidate, показывая кэш мгновенно и сверяя его в фоне
Предварительные знания
- Таксономия состояния: какие вообще бывают виды стейта в приложении
- Загрузка данных через fetch и обработка loading/error
- Базовое понимание кэша и срока его жизни (TTL)
Как server-state выделили в отдельную категорию
Долгое время стандартом было одно хранилище на всё: ответы API складывали в Redux рядом с флагами модалок и темой. Таннер Линсли в 2020 году с выходом React Query сформулировал то, что многие чувствовали, но не называли: серверные данные принципиально отличаются от состояния приложения. Они принадлежат не клиенту, а серверу. Они устаревают в любой момент. Их разделяют несколько компонентов. Их нужно периодически сверять с источником. Значит, это кэш со сроком годности, а не переменная. Это разделение к 2026 году стало базовым принципом во всех фреймворках - React, Vue, Svelte, Solid - и определило целый класс инструментов: Query, SWR, Apollo, urql.
Два вида состояния
Состояние в приложении удобно делить надвое по тому, кто им владеет. Клиентское состояние полностью принадлежит интерфейсу: открыта ли модалка, какая выбрана вкладка, что введено в поле до отправки, какая тема включена. Серверное состояние это данные, которые живут в базе на сервере, а на клиенте присутствуют лишь как копия: список заказов, профиль пользователя, остаток на складе, цена товара.
Разница не косметическая. Клиентское состояние по определению актуально - оно и есть истина, менять его некому, кроме самого интерфейса. Серверная копия устаревает сама собой: пока на экране висел список, другой человек добавил, удалил или переименовал запись на сервере. Клиент об этом не узнает, пока не переспросит. Поэтому серверному состоянию нужны понятия, которых у обычной переменной нет.
- Клиентское состояние — Принадлежит интерфейсу, всегда актуально, синхронное, владелец один. Примеры: открытая модалка, активная вкладка, черновик формы, тема. Инструмент - useState, Zustand, Redux.
- Серверное состояние — Принадлежит серверу, может устареть, асинхронное, разделяемое многими. Примеры: список заказов, профиль, цены, остатки. Инструмент - TanStack Query, SWR, Apollo.
| Свойство | Клиентское | Серверное |
|---|---|---|
| Владелец | Интерфейс | Сервер |
| Актуальность | Всегда истина | Снимок, устаревает |
| Доступ | Синхронный | Асинхронный |
| Кто меняет | Только этот клиент | Любой клиент или процесс |
| Что нужно | Присваивание | Свежесть, сверка, инвалидация |
Проверочный вопрос для любого куска данных: если бы кто-то изменил это прямо в базе, заметил бы интерфейс расхождение? Если да - это серверное состояние, ему нужен кэш со свежестью. Если данные нигде кроме браузера не существуют - это клиентское состояние.
Что из перечисленного - серверное состояние, а не клиентское?
Серверные данные это кэш с TTL
Раз серверная копия устаревает, относиться к ней нужно как к кэшу, а не как к собственному состоянию. У кэша есть срок свежести (TTL): сколько данные считаются актуальными, прежде чем их стоит пересверить с источником. Пока срок не истёк, кэш отдают мгновенно и в сеть не идут. После истечения данные помечаются устаревшими и при следующем обращении тихо перезапрашиваются в фоне. Это и есть модель stale-while-revalidate: показать кэш сразу, сверить с сервером следом.
Кэш-семантика даёт то, что у переменной в сторе приходится дописывать руками: дедупликацию одинаковых запросов, фоновую сверку по таймеру и по возврату фокуса, повторные попытки при сбое, единый источник для всех компонентов, читающих один ключ. Данные не присваивают - их запрашивают по ключу и доверяют слою кэша решать, отдать ли снимок или сходить в сеть.
- TTL (staleTime): срок, в течение которого снимок считается свежим и не перезапрашивается
- Background revalidation: после истечения срока кэш тихо сверяется с сервером в фоне
- Дедупликация: десять компонентов с одним ключом дают один сетевой запрос, а не десять
- Единый источник: все читатели ключа видят одни и те же данные, копий не возникает
Срок свежести подбирают под природу данных. Курс валют или лента новостей - секунды. Профиль пользователя или справочник стран - минуты или часы. Чем реже данные меняются на сервере, тем дольше снимок можно считать свежим.
Почему серверные данные правильнее вести как кэш с TTL, а не как переменную в сторе?
Антипаттерн номер один
Самая частая ошибка в работе с серверными данными: скопировать ответ API в глобальный стор (Redux или Zustand) и при этом параллельно пользоваться fetch-кэшем. Возникают две копии одних и тех же данных, и они расходятся. Кэш сходил в сеть и обновился, а копия в сторе осталась старой. Или наоборот - вручную обновили стор, а кэш отдаёт прежний снимок. Дальше команда дописывает ручную синхронизацию, и reducer-ы превращаются в самописный кэш-слой с багами.
Признаки, что в проект заехал этот антипаттерн: после каждой мутации диспатчится экшен ради синхронизации; в сторе лежат массивы с id записей, пришедших из API; reducer-ы наполнены логикой 'обнови элемент по id'. Всё это - ручная реализация кэша, который уже умеют делать готовые библиотеки.
Разделение простое и держится одним правилом. Серверное состояние ведёт кэш-библиотека: Query, SWR, Apollo или urql - она владеет снимком, его свежестью и сверкой. Глобальный стор (Redux, Zustand) остаётся под клиентское состояние: модалки, тема, выбранные фильтры до отправки, черновики. Две роли не пересекаются, и копий одних данных не возникает.
Команда грузит список из API в useEffect и кладёт его в Redux, а рядом тот же запрос идёт через кэш-библиотеку. В чём здесь главная проблема?
Связь с другими темами
Опорный урок блока про серверное состояние. Дальше тема раскрывается инструментами:
- TanStack Query — Главный инструмент, который ведёт серверный кэш со свежестью, фоновой сверкой и инвалидацией
- SWR — Лёгкая альтернатива от Vercel с той же моделью stale-while-revalidate
- GraphQL-кэш как состояние — В Apollo и urql нормализованный кэш сам и есть серверное состояние
Итог
- Состояние делится на два вида: клиентское принадлежит интерфейсу и всегда актуально, серверное это снимок данных с сервера, который устаревает
- Серверные данные - удалённый кэш с TTL, а не состояние, которым владеет клиент. У него есть срок свежести и стратегия обновления, а не простое присваивание
- Антипаттерн номер один: копировать ответ API в глобальный стор (Redux/Zustand) и параллельно держать fetch-кэш - получаются две копии, расходящиеся между собой
- Признак проблемы: ручная инвалидация после каждой мутации, диспатч экшенов ради синхронизации, reducer-ы, превратившиеся в самописный кэш с багами
- Правильный путь: серверное состояние ведёт кэш-библиотека (Query/SWR/Apollo), а глобальный стор остаётся только под чисто клиентский стейт
Связанные уроки
- sm-02-state-taxonomy — Деление состояния на виды из таксономии - фундамент, на котором стоит разделение server-state и client-state
- sm-31-tanstack-query — Поняв, что серверные данные это кэш, переходят к инструменту, который этот кэш ведёт - TanStack Query
- rc-36-tanstack-query — Тот же разрыв server-state и client-state, разобранный в контексте React-экосистемы