Кеш в TanStack Query хранит данные между запросами, повторными рендерами, переходами по страницам и даже между перезапусками приложения при использовании персистентного хранилища. Со временем структура данных меняется: появляются новые поля, удаляются старые, меняется формат API, обновляется логика сериализации. Без механизма управления версиями старые данные начинают конфликтовать с новым кодом.
Типичные проблемы отсутствия версионирования:
Управление версиями кеша позволяет:
Предположим, API возвращал следующую структуру:
{
"id": 1,
"name": "Laptop"
}
Позже backend изменился:
{
"id": 1,
"title": "Laptop",
"price": 1200
}
Если persisted cache содержит старую версию, а UI ожидает новую, возникнут проблемы:
const { data } = useQuery({
queryKey: ['product', id],
queryFn: fetchProduct,
})
console.log(data.title)
title отсутствует в старом кеше.
Особенно опасны подобные изменения при использовании:
Самый простой способ управления версиями — включение версии в query key.
useQuery({
queryKey: ['v2', 'products'],
queryFn: fetchProducts,
})
После изменения структуры данных:
useQuery({
queryKey: ['v3', 'products'],
queryFn: fetchProducts,
})
Старый кеш перестаёт использоваться автоматически.
Версию можно применять точечно:
queryKey: ['product-v2', productId]
или:
queryKey: ['product', productId, { version: 2 }]
Второй вариант лучше масштабируется.
Распространённый подход — хранение версии в отдельной константе.
export const CACHE_VERSION = 3
Использование:
useQuery({
queryKey: ['products', CACHE_VERSION],
queryFn: fetchProducts,
})
Преимущества:
В больших приложениях query keys обычно централизуются.
export const productKeys = {
all: ['products', 3],
lists: () => [...productKeys.all, 'list'],
list: (filters) => [
...productKeys.lists(),
filters,
],
details: () => [...productKeys.all, 'detail'],
detail: (id) => [
...productKeys.details(),
id,
],
}
Использование:
useQuery({
queryKey: productKeys.detail(productId),
queryFn: () => fetchProduct(productId),
})
Преимущества:
TanStack Query поддерживает сохранение кеша между перезапусками приложения через persistQueryClient.
Пример:
persistQueryClient({
queryClient,
persister,
})
Без версионирования старый persisted cache может стать несовместимым после деплоя.
Для решения этой проблемы используется buster.
persistQueryClient({
queryClient,
persister,
buster: 'v3',
})
При изменении значения:
buster: 'v4'
старый persisted cache автоматически игнорируется.
Persisted state содержит специальную метку:
{
"buster": "v3",
"timestamp": 1710000000,
"clientState": {}
}
При несовпадении версий:
На практике обычно используются оба механизма.
const CACHE_VERSION = 5
persistQueryClient({
queryClient,
persister,
buster: `cache-${CACHE_VERSION}`,
})
И:
queryKey: ['products', CACHE_VERSION]
Такой подход защищает:
Иногда сбрасывать кеш нежелательно:
В этом случае применяется миграция данных.
Persisted state можно преобразовать вручную перед восстановлением.
const persisted = await persister.restoreClient()
if (persisted?.buster === 'v1') {
persisted.clientState.queries.forEach((query) => {
if (query.queryKey[0] === 'product') {
query.state.data.title =
query.state.data.name
delete query.state.data.name
}
})
persisted.buster = 'v2'
}
После миграции:
hydrate(queryClient, persisted.clientState)
Миграция кеша значительно сложнее простой инвалидации.
Основные риски:
По этой причине большинство проектов предпочитают invalidate вместо migrate.
Infinite queries особенно чувствительны к изменениям структуры.
{
"pages": [],
"pageParams": []
}
Изменение формата страниц может полностью сломать пагинацию.
Старая схема:
{
"items": [],
"nextPage": 2
}
Новая схема:
{
"data": [],
"cursor": "abc123"
}
Старый кеш infinite query становится несовместимым.
Решение:
queryKey: ['feed', 'v2']
При SSR сервер сериализует кеш:
dehydrate(queryClient)
На клиенте происходит hydration:
hydrate(queryClient, dehydratedState)
Если версии клиента и сервера отличаются, возможны:
Распространённый подход:
const dehydratedState = dehydrate(queryClient)
return {
props: {
dehydratedState,
cacheVersion: 3,
},
}
На клиенте:
if (cacheVersion !== CURRENT_VERSION) {
queryClient.clear()
}
TanStack Query позволяет выборочно инвалидировать кеш.
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey.includes('v1')
},
})
Иногда требуется полностью удалить старые записи.
queryClient.removeQueries({
predicate: (query) => {
return query.queryKey.includes('v1')
},
})
Разница:
| Метод | Поведение |
|---|---|
| invalidateQueries | помечает stale |
| removeQueries | полностью удаляет |
['v3', 'products']
Преимущества:
Недостатки:
['products', 'v2']
['users', 'v5']
Преимущества:
Недостатки:
['product', id, 'v4']
Наиболее гибкий вариант.
Используется в:
Иногда используются semver-подобные версии.
['products', '2.1.0']
или:
const CACHE_SCHEMA = {
major: 2,
minor: 1,
}
Практический смысл обычно имеет только major version.
Минорные изменения редко требуют сброса кеша.
Версию следует менять при:
Не требуют обновления версии:
select может скрывать несовместимость данных.
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
select: (data) => {
return data.items
},
})
Если backend изменил:
{
"products": []
}
старый select ломается.
Решение:
queryKey: ['products', 'v2']
Оптимистические обновления особенно чувствительны к схемам данных.
queryClient.setQueryData(
['product', id],
(old) => ({
...old,
likes: old.likes + 1,
})
)
Если likes больше не существует:
Иногда требуется сброс mutation cache после обновления приложения.
queryClient.getMutationCache().clear()
Особенно важно для offline mutations.
В offline-first приложениях версия кеша становится критически важной.
Проблемы:
Пример структуры:
{
"version": 4,
"mutation": {
"type": "UPDATE_PRODUCT"
}
}
При несовместимости mutation можно:
Крупные приложения часто выделяют отдельную систему версий.
export const CACHE_SCHEMA = {
products: 4,
users: 2,
cart: 8,
notifications: 3,
}
Использование:
queryKey: [
'products',
CACHE_SCHEMA.products,
]
function versionedKey(name, ...parts) {
return [
name,
CACHE_SCHEMA[name],
...parts,
]
}
Использование:
queryKey: versionedKey(
'products',
productId
)
Нельзя напрямую связывать API version и cache version.
Например:
Cache version отражает совместимость frontend cache schema, а не API version.
Наиболее распространённая схема:
const CACHE_VERSION = 12
Использование:
persistQueryClient({
queryClient,
persister,
buster: `v${CACHE_VERSION}`,
})
И:
queryKey: [
'products',
CACHE_VERSION,
]
Дополнительно:
Самая частая ошибка.
После деплоя приложение начинает работать с несовместимыми данными.
queryKey: ['products', Date.now()]
Полностью отключает повторное использование кеша.
queryKey: ['products', 'new']
Непредсказуемо и плохо масштабируется.
Плохо:
['products']
при разных форматах данных.
Миграция кеша без проверки часто приводит к повреждению persisted state.
Проверяются:
Пример проверки:
expect(restoredCache.version)
.toBe(CURRENT_VERSION)
Проверка инвалидации:
expect(queryClient.getQueryData(
['products', 'v1']
)).toBeUndefined()
Частая смена версий приводит к:
Редкая смена версий приводит к:
Баланс между стабильностью и безопасностью определяется архитектурой приложения и скоростью изменения API.