open atlas
↑ К треку
React с нуля до senior RCT · 06 · 04

Серверное против клиентского состояния: кто владеет какой истиной

Серверное состояние — кеш истины сервера: асинхронной, общей, устаревшей по умолчанию; его жизненный цикл ведёт query-библиотека. Клиентское — истина UI. Копирование серверного в useState форкает истину; черновик формы — единственный намеренный форк с явным коммитом.

RCT Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Саппорт прозвал это «призраком переименования», и у бага был собственный тег в тикет-системе: 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. Мутация переименования срабатывает со страницы деталей. Какой подход держит все представления согласованными без ручной синхронизации?

Вспомните перед уходом
  1. 01
    Объясните механически, почему копирование загруженных данных в useState порождает класс багов «протухло после мутации», и почему кеш запросов чинит это там, где шины событий и колбэки refresh — нет.
  2. 02
    Разложите полную таблицу решений, где живёт состояние, включая два вида помимо серверного/клиентского, и обоснуйте исключение для черновика формы из правила «не копировать».
Итог

Инструменты юнита — колокация, редьюсеры, внешние сторы — управляют клиентским состоянием: истиной, рождающейся в UI и принадлежащей вам. Серверное состояние лишь похоже: список проектов и профиль пользователя — истина сервера, а приложение держит кеш — асинхронный, общий с другими писателями, устаревший с момента прибытия. У кешей жизненный цикл: записи по ключам, дедупликация запросов, фоновая ревалидация и, главное, инвалидация после мутаций — этим query-библиотеки и являются: менеджеры кеша, а не обёртки над fetch. Класс багов рождается из игнорирования границы: копирование загруженного в useState форкает истину в снапшот без ключа, который мутациям не найти, и компоненты расходятся после записи — призрак переименования; затравочный вариант, useState из пропсов или данных запроса, замораживает форк, потому что инициализаторы выполняются один раз. Починки через рассылку событий — самописная инвалидация; структурная починка — записи кеша по ключам плюс мутации, инвалидирующие задетые ключи, а преобразованные представления — деривация при рендере. Формы — санкционированное исключение: черновик — намеренный форк, чьё расхождение и есть фича, безопасный благодаря инициализации раз на сессию, запрету пересинхронизации посреди правки, ключу по id записи и коммиту на submit. Общее правило, закрывающее юнит: назовите владельца — сервер, роутер, черновик формы или UI, — храните состояние в инструменте владельца и выводите остальное. Теперь, когда вы увидите useEffect, фетчащий данные в useState, вы сразу спросите: что произойдёт, когда мутация придёт из другого компонента — и кто инвалидирует эту копию?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.