Серверное против клиентского состояния: кто владеет какой истиной
Серверное состояние — кеш истины сервера: асинхронной, общей, устаревшей по умолчанию; его жизненный цикл ведёт query-библиотека. Клиентское — истина UI. Копирование серверного в useState форкает истину; черновик формы — единственный намеренный форк с явным коммитом.
Саппорт прозвал это «призраком переименования», и у бага был собственный тег в тикет-системе: 31 обращение за квартал. Пользователь переименовывает проект на странице деталей, возвращается назад — а список показывает старое имя до жёсткого рефреша. Страница списка фетчила проекты в эффекте и складывала в useState; страница деталей делала то же со своим фетчем; мутация переименования обновляла сервер и локальную копию страницы деталей. Три компонента, три приватные копии одной серверной строки, ноль договорённостей о том, когда обновляться. Первая «починка» команды — глобальная шина событий, рассылающая «project-changed», — добавила 400 строк и два новых бага. Настоящей починкой стало признание категориальной ошибки: данные проекта никогда не были их собственностью. Они принадлежат серверу; клиент держит кеш. Чтения переехали в query-библиотеку с ключами ["projects"] и ["project", id], мутация переименования стала инвалидировать оба ключа, а каждый useState, изображавший из себя базу данных, удалили. Тег призрака закрыли вместе с 600 строками кода синхронизации.
К концу урока вы сможете назвать владельца любого куска состояния в своём приложении — и понять, почему ошибка с владением породила 31 тикет поддержки о призраке переименования.
Два вида состояния с противоположной физикой
Прежде чем писать следующий useEffect для фетча, спросите себя: кто реально владеет этой истиной? Инструментарий useState/useReducer/стор из предыдущих уроков управляет клиентским состоянием: истиной, рождающейся в UI — какая вкладка активна, открыта ли модалка, ширина сайдбара. Источник истины — вы; оно синхронно, всегда корректно и умирает вместе с сессией. Серверное состояние — другая субстанция, которая лишь протекает через те же хуки, если ей позволить: список проектов, профиль пользователя, цены. Этой истиной владеет сервер. Ваша копия — кеш чужих данных: асинхронный, общий с другими пользователями, которые их мутируют, и устаревший в момент прибытия. У кешей есть жизненный цикл, которого у клиентского состояния нет: фетч и рефетч, дедупликация параллельных запросов, инвалидация после мутаций, фоновая ревалидация, сборка мусора неиспользуемых записей. Query-библиотека (TanStack Query, SWR, RTK Query) — не «библиотека для фетчинга» (фетч — одна строка). Это менеджер кеша: записи по ключам запросов, отслеживание устаревания, дедуп, ревалидация по фокусу и интервалу, инвалидация от мутаций. Этот жизненный цикл и есть настоящая работа, и его ручная сборка покомпонентно — то, как рождается призрак переименования.
Класс багов: копирование серверных данных в useState
Анти-паттерн выглядит безобидно и прекрасно компилируется:
// АНТИ-ПАТТЕРН: форк серверной истины в локальное состояние
function ProjectList() {
const [projects, setProjects] = useState([]);
useEffect(() => {
fetchProjects().then(setProjects); // приватная копия, замороженная на момент фетча
}, []);
// ...
}
// Под управлением кеша: одна общая запись, инвалидируемая мутациями
function ProjectListFixed() {
const { data: projects } = useQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
});
// переименование где-то ещё: queryClient.invalidateQueries({ queryKey: ["projects"] })
}Механизм бага: в момент, когда серверные данные попадают в useState, они форкаются. Компонент владеет снапшотом без ключа, говорящего, копией чего он является, — поэтому никакая мутация не может его найти и инвалидировать; два компонента, фетчащие один ресурс, держат независимые форки, расходящиеся после любой записи; рефетч гоняется с мутацией, и более медленный ответ побеждает, воскрешая старые данные. Каждая «починка» — шины событий, прокинутые колбэки refresh, ручной refetchAll() — это самописная, баг-в-баг, реимплементация инвалидации кеша. Производный вариант коварнее и переживает ревью: useState(props.user.name) «для инициализации» — инициализатор выполняется один раз при маунте, поэтому когда запрос рефетчится и пропсы обновляются, состояние хранит протухший форк. Нужно преобразованное представление серверных данных — выводите его при рендере (const sorted = useMemo(...)): деривация пересчитывается от свежей истины, копия замораживает мёртвую.
Состояние формы: третий вид — намеренный форк
Формы выглядят противоречием: форма редактирования обязана копировать серверные данные в локальное состояние — пользователь печатает в черновик. Разрешение противоречия: черновик — форк нарочно, с явным коммитом. Расхождение с сервером — это фича (пользователь редактирует); submit — точка коммита, отправляющая черновик назад и инвалидирующая кеш. Дисциплина, делающая намеренные форки безопасными: черновик инициализируется из серверных данных один раз на сессию редактирования и никогда молча не пересинхронизируется посреди правки (представьте фоновый рефетч, переписывающий полунабранное поле), а компонент перемонтируется при смене субъекта — key={project.id} на форме, чтобы редактирование другого проекта получало свежий черновик, а не протухший. Библиотеки форм (react-hook-form, формы с actions) — эта дисциплина как продукт: dirty-трекинг, reset-on-success, валидация черновика.
▸Почему это работает
Почему вопрос — «это копия серверной истины?», а не «это фетчится?» Потому что сценарий отказа — путаница владения, а не сетевой код. Состояние URL (текущий фильтр в query-параметрах) принадлежит роутеру; положить его ещё и в useState — форкнуть точно так же. Сессия авторизации — серверная истина, кешируемая вашим auth-провайдером. Решение обобщается: для каждого куска состояния назовите владельца — сервер, роутер, черновик формы или UI, — храните его в инструменте этого владельца и всё остальное выводите. Состояние с двумя владельцами — баг, ещё не выбравший себе шаги воспроизведения.
Таблица решений
| Состояние… | Владелец | Живёт в | Никогда |
|---|---|---|---|
| Фетчится, общее, мутируется другими (списки, профили, цены) | Сервер | Кеш запросов, по ключу | Не копируется в useState |
| Истина UI (открытая модалка, активная вкладка, шаг визарда) | Клиент | useState / редьюсер / стор (уроки 1–3) | Не кладётся в кеш запросов |
| Правка серверных данных в процессе | Черновик формы | Состояние формы, с ключом по id записи, коммит на submit | Не пересинхронизируется молча посреди правки |
| Шарируемая конфигурация вида (фильтры, страница, сортировка) | URL | Параметры роутера | Не дублируется в useState |
Страница списка и страница деталей фетчат один проект каждая в свой useState. После мутации переименования на странице деталей список показывает старое имя до жёсткого рефреша. В чём корневая причина?
Форма редактирования копирует загруженную запись в локальный черновик. По правилам этого юнита — когда такая копия корректна и какие две дисциплины делают её безопасной?
Приложение показывает список проектов пользователя, загруженных из API. Мутация переименования срабатывает со страницы деталей. Какой подход держит все представления согласованными без ручной синхронизации?
- 01Объясните механически, почему копирование загруженных данных в useState порождает класс багов «протухло после мутации», и почему кеш запросов чинит это там, где шины событий и колбэки refresh — нет.
- 02Разложите полную таблицу решений, где живёт состояние, включая два вида помимо серверного/клиентского, и обоснуйте исключение для черновика формы из правила «не копировать».
Инструменты юнита — колокация, редьюсеры, внешние сторы — управляют клиентским состоянием: истиной, рождающейся в UI и принадлежащей вам. Серверное состояние лишь похоже: список проектов и профиль пользователя — истина сервера, а приложение держит кеш — асинхронный, общий с другими писателями, устаревший с момента прибытия. У кешей жизненный цикл: записи по ключам, дедупликация запросов, фоновая ревалидация и, главное, инвалидация после мутаций — этим query-библиотеки и являются: менеджеры кеша, а не обёртки над fetch. Класс багов рождается из игнорирования границы: копирование загруженного в useState форкает истину в снапшот без ключа, который мутациям не найти, и компоненты расходятся после записи — призрак переименования; затравочный вариант, useState из пропсов или данных запроса, замораживает форк, потому что инициализаторы выполняются один раз. Починки через рассылку событий — самописная инвалидация; структурная починка — записи кеша по ключам плюс мутации, инвалидирующие задетые ключи, а преобразованные представления — деривация при рендере. Формы — санкционированное исключение: черновик — намеренный форк, чьё расхождение и есть фича, безопасный благодаря инициализации раз на сессию, запрету пересинхронизации посреди правки, ключу по id записи и коммиту на submit. Общее правило, закрывающее юнит: назовите владельца — сервер, роутер, черновик формы или UI, — храните состояние в инструменте владельца и выводите остальное. Теперь, когда вы увидите useEffect, фетчащий данные в useState, вы сразу спросите: что произойдёт, когда мутация придёт из другого компонента — и кто инвалидирует эту копию?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.