State Management
TanStack Query: мутации и optimistic
В Linear пользователь меняет статус задачи на 'Done', и галочка переключается мгновенно - до того, как сервер успел ответить. Это не иллюзия скорости сети, а оптимистичное обновление: кэш правят локально сразу, а запрос летит в фоне. Если сервер ответит ошибкой, изменение откатывается на прежнее значение, будто его и не было. Под капотом за это отвечают useMutation для записи и invalidateQueries для сверки кэша после. Без этой связки кэш чтения остаётся старым после изменения, и на экране висят данные, которых на сервере уже нет.
- Linear, Todoist, Notion: переключение статуса, лайк, чекбокс срабатывают мгновенно за счёт оптимистичного обновления
- Корзина в e-commerce: добавление товара отражается сразу, а откат при ошибке возвращает прежнее количество
- Соцсети: лайк и подписка ставятся оптимистично, потому что успех почти гарантирован, а отзывчивость важнее
- Админки: создание и удаление записи через useMutation, после которого invalidateQueries обновляет таблицу
- Формы редактирования профиля: сохранение через мутацию с инвалидацией кэша профиля по успеху
Предварительные знания
- Основы TanStack Query: useQuery, queryKey, кэш и staleTime
- Понимание стратегии stale-while-revalidate и фонового refetch
- Промисы и обработка ошибок async/await
useMutation и инвалидация
Чтение делает useQuery, а запись - useMutation. Мутация не кэшируется по ключу: она выполняет разовое действие (создать, обновить, удалить) и отдаёт статусы isPending, isError, isSuccess и колбэки onSuccess, onError, onSettled. Само по себе изменение на сервере не трогает кэш чтения - связь между записью и чтением приходится задать явно.
Запускают мутацию через mutate с аргументом, который попадёт в mutationFn. Пока запрос идёт, isPending равен true - на этом удобно блокировать кнопку, чтобы не отправить дважды. Результат и ошибку обрабатывают в колбэках, а не в then у вызова: так логика обновления кэша живёт рядом с самой мутацией, а не разъезжается по компонентам.
| Колбэк | Когда вызывается | Типичное применение |
|---|---|---|
| onMutate | До отправки запроса | Оптимистично правят кэш, сохраняют снимок для отката |
| onSuccess | Сервер ответил успехом | Инвалидация связанного кэша, уведомление |
| onError | Сервер ответил ошибкой | Откат оптимистичного изменения, показ ошибки |
| onSettled | В любом исходе после завершения | Финальная инвалидация для сверки с сервером |
Мутация не GET и не POST по сути. useMutation можно применить и к GET-действию: разница не в HTTP-методе, а в семантике. Чтение кэшируется и дедуплицируется по ключу, запись это разовое действие со своими колбэками, результат которого в кэш надо завести отдельно.
Чем роль useMutation отличается от роли useQuery?
invalidateQueries после мутации
После того как мутация изменила данные на сервере, кэш чтения остаётся старым: useQuery всё ещё держит снимок до изменения. Чтобы их сверить, связанный кэш помечают устаревшим вызовом invalidateQueries с нужным queryKey. Это не удаляет данные - оно помечает их stale и запускает фоновый refetch активных запросов с этим ключом. На экране данные на миг остаются прежними, затем тихо подменяются свежими с сервера.
invalidateQueries сопоставляет ключи по префиксу. Вызов с ['tasks'] инвалидирует и ['tasks'], и ['tasks', { project: 'web' }], и ['tasks', { status: 'open' }] - все запросы, чей ключ начинается с 'tasks'. Это удобно: одна инвалидация после создания задачи обновит и общий список, и все его отфильтрованные варианты разом.
Инвалидация по префиксу ключа - повод проектировать ключи иерархично. Если все запросы задач начинаются с 'tasks', а внутри идут фильтры объектом, одна инвалидация ['tasks'] покрывает любой их вариант без перечисления.
Что делает invalidateQueries({ queryKey: ['tasks'] }) после успешной мутации?
Оптимистичное обновление с откатом
Инвалидация по успеху отзывчива, но всё же ждёт ответа сервера. Оптимистичное обновление идёт дальше: изменение применяют в кэш сразу, до ответа. В onMutate отменяют идущие запросы по ключу (чтобы они не затёрли локальную правку), сохраняют текущий снимок кэша и правят его локально. Пользователь видит результат мгновенно, а запрос только отправляется в фон.
Если сервер ответил ошибкой, onError возвращает сохранённый снимок - это и есть откат (rollback). Прежнее значение приходит из context, который вернул onMutate. В onSettled, в любом исходе, запускается инвалидация: даже после удачного оптимистичного обновления финальная сверка с сервером гарантирует, что кэш точно совпал с источником, а не остался на догадке клиента.
- onMutate: отменить идущие запросы по ключу, сохранить снимок кэша, применить изменение локально
- Запрос летит на сервер в фоне, интерфейс уже показывает новое значение
- onError при сбое: вернуть сохранённый снимок из context - откат к прежнему состоянию
- onSettled в любом исходе: invalidateQueries для финальной сверки кэша с сервером
Оптимизм уместен там, где успех почти гарантирован и важна мгновенная реакция: лайки, чекбоксы, добавление в список. Для платежей и других критичных операций честнее дождаться ответа сервера, а не показывать результат, который придётся откатывать у пользователя на глазах.
В оптимистичном обновлении сервер ответил ошибкой. Что обеспечивает корректный откат?
Связь с другими темами
Урок про изменение данных. Дальше тема раскрывается так:
- TanStack Query: основы — Мутации меняют кэш, который ведёт useQuery: чтение и запись это две стороны одного кэша
- Server-state: паттерны — Оптимистичные обновления переносятся на пагинацию, бесконечные списки и офлайн-очереди
- GraphQL-кеш как состояние — В Apollo и urql кэш после мутации обновляют похоже: либо рефетч, либо прямая запись в кэш
Итог
- useMutation выполняет изменение на сервере (создать, обновить, удалить) и отдаёт статусы isPending, isError и колбэки onSuccess, onError, onSettled. По ключу мутация не кэшируется
- Само изменение на сервере не обновляет кэш чтения. После успешной мутации связанный кэш помечают устаревшим вызовом invalidateQueries, и Query запускает фоновый refetch
- Оптимистичное обновление в onMutate правит кэш локально до ответа сервера и сохраняет прежнее значение для отката
- При ошибке onError возвращает сохранённый снимок (rollback), а onSettled запускает инвалидацию, чтобы кэш в любом исходе сошёлся с сервером
- Оптимизм уместен там, где успех почти гарантирован и важна отзывчивость (лайки, чекбоксы). Для платежей честнее дождаться ответа сервера
Связанные уроки
- sm-31-tanstack-query — Мутации меняют тот самый кэш, который ведёт useQuery, поэтому сначала нужны основы Query
- sm-35-server-state-patterns — Поверх мутаций строятся продвинутые паттерны: оптимистичная пагинация, офлайн-очереди