Паттерны инвалидации кеша: invalidate против write-through, оптимистичный rollback и live-синхронизация
После записи кеш лжёт, пока вы его не примирите. Инвалидация покупает правду сервера ценой запроса; setQueryData мгновенен, но дублирует серверную логику; оптимистичным обновлениям нужны cancel + snapshot + rollback. Плюс infinite queries и websocket-синхронизация.
Два тикета с разницей в две недели, одно семейство первопричин. Тикет первый: «Создал счёт, получил тост об успехе, попал в список счетов — а моего счёта там нет. Обновил страницу — появился. Ваше приложение теряет данные». Оно не теряет данные: у запроса списка ради производительности стоял staleTime: 5 минут, мутация никогда не инвалидировала ['invoices'], и кеш честно отдавал свою пятиминутную правду. Тикет второй пришёл со стороны инфраструктуры: p99-латентность API скачет каждый день после обеда. Трейс показал, что отдельные пользователи генерируют 40 запросов за секунду — «фикс» первого тикета добавил invalidateQueries() без ключа в onSuccess каждой мутации, и сохранение одного чекбокса перезапрашивало каждый активный запрос на дашборде с сорока виджетами. Шторм перезапросов, нанесённый себе самим. Это урок, где встречаются две знаменитые трудные вещи: после каждой записи ваш кеш неверен, пока вы его не примирите, — а примирение — это ручка между «перезапросить всё истинное» и «запатчить ровно то, что изменилось», где один конец стоит запросов, а другой — корректности.
invalidate против setQueryData: правда перезапросом или правда репликацией
invalidateQueries({ queryKey: ['invoices'] }) — консервативный ход: сматчить затронутые ключи по префиксу, пометить устаревшими, перезапросить активные. Вы получаете правду сервера — порядок сортировки, вычисленные суммы, строки, добавленные другими пользователями, — ценой одного лишнего круга и короткого окна, когда старые данные ещё на экране. setQueryData(['invoices'], updater) — это write-through: синхронно запатчить закешированное значение, ноль запросов, мгновенный UI. Его цена коварнее: ваш updater обязан реплицировать серверную логику. Вставить новый счёт — на какую позицию? Список отсортирован по номеру, который назначает сервер. Подходит ли он под текущий фильтр? Не проставил ли сервер dueDate, не нормализовал ли валюту, не зацепил ли побочным эффектом другую строку? Каждое расхождение — молчаливая ложь в кеше. Сеньорский дефолт: write-through ответом сервера (onSuccess: (created) => setQueryData(...), вписывающий созданный объект), либо write-through, а затем инвалидация — мгновенный UI сейчас, правда примиряется в фоне. Чистый setQueryData приберегите для кешей, чью форму вы контролируете полностью, и никогда не инвалидируйте без ключа: ограничивайте областью реально изменившегося семейства ресурсов, иначе дашборд с 40 виджетами превращает каждый чекбокс в шторм запросов.
Оптимистичные обновления: трёхшаговый контракт с откатом
Для взаимодействий, где даже один круг ощущается поломкой — лайки, переключатели, drag-перестановка, — обновляйте кеш до подтверждения сервера. У контракта три обязательных шага, все в onMutate:
const mutation = useMutation({
mutationFn: toggleTodo,
onMutate: async (todo) => {
// 1. Cancel: летящий refetch перезаписал бы нашу оптимистичную запись
await queryClient.cancelQueries({ queryKey: ["todos"] });
// 2. Snapshot: к чему откатываемся, если сервер скажет «нет»
const previous = queryClient.getQueryData(["todos"]);
// 3. Оптимистичный write-through
queryClient.setQueryData(["todos"], (old) =>
old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t))
);
return { previous }; // → context
},
onError: (_err, _todo, context) =>
queryClient.setQueryData(["todos"], context.previous), // откат
onSettled: () =>
queryClient.invalidateQueries({ queryKey: ["todos"] }), // примирение
});Каждый шаг существует из-за конкретного отказа. Пропустите cancel — и refetch, начавшийся до вашего клика, может приземлиться после оптимистичной записи и откатить её на лету: чекбокс мигает «выключено», потом снова «включено», когда мутация завершилась; пользователи описывают это как «приложение со мной боролось». Пропустите snapshot/rollback — и отказ сервера оставит UI навсегда утверждающим ложь: лайк, которого не было. Пропустите примирение в onSettled — и ваша оптимистичная догадка (даже верная) разъедется с полями, вычисляемыми сервером. Трейдофф честный: оптимистичный UI превращает p50-латентность в ноль воспринимаемой, но покупает путь ошибки, который надо спроектировать — откат плюс видимое «не сохранилось», — и плохо компонуется со многими одновременными мутациями одной сущности (снапшот мутации B может захватить неподтверждённую догадку мутации A; документация TanStack Query отправляет тяжёлые случаи в одиночные мутации или per-mutation variables).
Оптимистичный переключатель реализует snapshot и rollback, но пропускает cancelQueries в onMutate. QA сообщает: чекбокс иногда сам выключается через мгновение после клика, а затем снова включается. Что произошло?
За пределами одного списка: зависимости, страницы и приходящая правда
Освоив паттерны одного списка, спросите себя: где граф данных усложняется? Зависимые запросы, постраничность и real-time push каждый ломают одно из допущений выше — и каждый несёт свою цену отказа, которая не проявляется, пока не вырастет трафик или число страниц.
Зависимые запросы цепляются через enabled: useQuery({ queryKey: ['projects', user?.id], enabled: !!user }) держит второй запрос выключенным, пока не зарезолвился первый. Это осознанная версия водопада из прошлых уроков — нормально, когда зависимость реальна, но каждая цепочка enabled — это сериализованная латентность, которую вы выбрали сами, так что аудируйте цепочки, как аудируете последовательности await. Infinite queries делают инвалидацию дорогой неожиданным образом: инвалидированный useInfiniteQuery перезапрашивает каждую загруженную страницу последовательно, чтобы курсоры остались согласованными — пользователь на глубине 10 страниц запускает 10 серийных запросов на каждую инвалидацию. Смягчения: ограничить хранимые страницы через maxPages, предпочесть точечный setQueryData-патч страницы для правок одной строки и использовать placeholderData: keepPreviousData на обычных постраничных запросах, чтобы листание показывало старую страницу вместо спиннера. Синхронизация по websocket — эндшпиль многопользовательского устаревания, и сеньорский паттерн контринтуитивен: на push-событие не пишите payload в кеш — вызовите invalidateQueries с ключом сущности из события. Присланные payload приходят не по порядку, могут не содержать вычисляемых сервером полей и дублируют вашу write-through-логику; события как сигналы «что-то изменилось, спроси сервер» сохраняют один источник правды, а при высокой частоте инвалидации дебаунсятся по ключу — 50 событий в секунду схлопываются в считаные перезапросы, — ровно тот дроссель шторма, которого не хватало тикету два.
Realtime-доска получает websocket-события об изменениях карточек и пишет каждый payload прямо в кеш через setQueryData. Карточки то и дело показывают устаревшие заголовки и потерянный вычисляемый бейдж "blocked". Каков сеньорский фикс?
- 01Разложите ручку примирения после мутации: три стратегии, что каждая стоит, чем рискует и какова сигнатура продакшен-бага при ошибке в каждой.
- 02Как меняется цена инвалидации для infinite queries и почему «инвалидируй по событию», а не «запиши payload» — стандартный паттерн websocket-синхронизации кеша?
Мутация делает каждое закешированное чтение этих данных неверным, и этот урок — каталог стратегий примирения. Инвалидация — правда перезапросом: invalidateQueries матчит ограниченный ключ по префиксу, помечает записи устаревшими, перезапрашивает активные — её отказы это недоинвалидация (созданный счёт отсутствует в пятиминутно-свежем списке до F5) и переинвалидация (invalidate без ключа превращает один чекбокс в шторм из 40 запросов и послеобеденные скачки p99). Write-through setQueryData — правда репликацией: мгновенно и без запросов, но updater обязан зеркалить серверную логику — позицию сортировки, принадлежность фильтру, вычисляемые поля — и каждое расхождение лжёт молча, поэтому предпочитайте патч реальным ответом сервера или инвалидацию следом за записью. Оптимистичные обновления переносят запись до ответа сервера и являются трёхшаговым контрактом в onMutate: await cancelQueries, чтобы летящий refetch не растоптал оптимистичное значение (фирменное мигание), снапшот для отката в onError, чтобы отказ не оставил постоянную ложь в UI, и инвалидация в onSettled, чтобы даже верные догадки примирились с вычисленной сервером правдой. Зависимые запросы через enabled — выбранные вами водопады, аудируйте их как цепочки await. Infinite queries при инвалидации перезапрашивают все загруженные страницы последовательно — ограничивайте страницы и патчите строки на месте. Websocket-события — сигналы, а не данные: инвалидируйте ключ сущности (с дебаунсом под нагрузкой) вместо прямой записи payload, потому что присланное приходит неупорядоченным и неполным. Ручка идёт от «перезапросить всё» до «запатчить ровно это» — запросы на одном конце, риск корректности на другом. Теперь, когда пишешь onSuccess мутации, берёшь самый узкий ключ, покрывающий реально изменившееся, — а когда видишь мигание или устаревший список после сохранения, знаешь, какой из трёх шагов проверить первым.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.