Управление версиями кеша

Кеш в TanStack Query хранит данные между запросами, повторными рендерами, переходами по страницам и даже между перезапусками приложения при использовании персистентного хранилища. Со временем структура данных меняется: появляются новые поля, удаляются старые, меняется формат API, обновляется логика сериализации. Без механизма управления версиями старые данные начинают конфликтовать с новым кодом.

Типичные проблемы отсутствия версионирования:

  • hydration получает несовместимую структуру;
  • persisted cache содержит устаревшие поля;
  • infinite queries ломаются после изменения pagination;
  • optimistic updates используют старую схему;
  • селекторы получают данные неожиданного формата;
  • приложение показывает неконсистентные данные после деплоя.

Управление версиями кеша позволяет:

  • инвалидировать старые записи;
  • мигрировать данные;
  • разделять кеш разных версий приложения;
  • безопасно изменять query keys;
  • контролировать совместимость persisted state.

Изменение структуры данных и проблема совместимости

Предположим, 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 отсутствует в старом кеше.

Особенно опасны подобные изменения при использовании:

  • localStorage persistence;
  • IndexedDB persistence;
  • SSR hydration;
  • offline-first архитектуры;
  • mobile hybrid-приложений.

Версионирование через query keys

Самый простой способ управления версиями — включение версии в 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 и контроль версий

В больших приложениях query keys обычно централизуются.

Пример key factory

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),
})

Преимущества:

  • единая версия для всей доменной области;
  • отсутствие ручного дублирования;
  • простое обновление схемы;
  • уменьшение количества коллизий.

Версионирование persisted cache

PersistQueryClient

TanStack Query поддерживает сохранение кеша между перезапусками приложения через persistQueryClient.

Пример:

persistQueryClient({
  queryClient,
  persister,
})

Без версионирования старый persisted cache может стать несовместимым после деплоя.


Использование buster

Для решения этой проблемы используется buster.

persistQueryClient({
  queryClient,
  persister,
  buster: 'v3',
})

При изменении значения:

buster: 'v4'

старый persisted cache автоматически игнорируется.


Как работает buster

Persisted state содержит специальную метку:

{
  "buster": "v3",
  "timestamp": 1710000000,
  "clientState": {}
}

При несовпадении версий:

  • кеш не восстанавливается;
  • выполняются новые запросы;
  • старое состояние отбрасывается.

Комбинирование buster и query key versioning

На практике обычно используются оба механизма.

Пример

const CACHE_VERSION = 5

persistQueryClient({
  queryClient,
  persister,
  buster: `cache-${CACHE_VERSION}`,
})

И:

queryKey: ['products', CACHE_VERSION]

Такой подход защищает:

  • runtime cache;
  • persisted cache;
  • hydration state;
  • offline snapshots.

Миграция кеша вместо сброса

Иногда сбрасывать кеш нежелательно:

  • слишком большие данные;
  • offline-first приложение;
  • дорогие запросы;
  • медленные сети;
  • ограниченные API rate limits.

В этом случае применяется миграция данных.


Миграция persisted state

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)

Опасности миграции кеша

Миграция кеша значительно сложнее простой инвалидации.

Основные риски:

  • повреждение структуры;
  • несовместимость infinite queries;
  • рассинхронизация metadata;
  • ошибки сериализации;
  • частично мигрированные данные;
  • проблемы конкурентного доступа.

По этой причине большинство проектов предпочитают invalidate вместо migrate.


Версионирование infinite queries

Infinite queries особенно чувствительны к изменениям структуры.

Типичная структура

{
  "pages": [],
  "pageParams": []
}

Изменение формата страниц может полностью сломать пагинацию.


Изменение cursor pagination

Старая схема:

{
  "items": [],
  "nextPage": 2
}

Новая схема:

{
  "data": [],
  "cursor": "abc123"
}

Старый кеш infinite query становится несовместимым.

Решение:

queryKey: ['feed', 'v2']

Версионирование SSR hydration

При SSR сервер сериализует кеш:

dehydrate(queryClient)

На клиенте происходит hydration:

hydrate(queryClient, dehydratedState)

Если версии клиента и сервера отличаются, возможны:

  • hydration mismatch;
  • повторные запросы;
  • ошибки рендера;
  • неконсистентный UI.

Добавление версии в dehydration payload

Распространённый подход:

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']

Преимущества:

  • точечные обновления;
  • меньше повторных запросов.

Недостатки:

  • сложнее поддержка;
  • больше контроля вручную.

Версия по endpoint

['product', id, 'v4']

Наиболее гибкий вариант.

Используется в:

  • enterprise-системах;
  • offline-first приложениях;
  • сложных API gateway;
  • multi-tenant архитектуре.

Semantic versioning кеша

Иногда используются semver-подобные версии.

['products', '2.1.0']

или:

const CACHE_SCHEMA = {
  major: 2,
  minor: 1,
}

Практический смысл обычно имеет только major version.

Минорные изменения редко требуют сброса кеша.


Когда необходимо увеличивать версию

Версию следует менять при:

  • изменении структуры ответа;
  • переименовании полей;
  • изменении pagination;
  • изменении select-transform;
  • изменении сериализации;
  • изменении optimistic updates;
  • изменении normalization;
  • изменении hydration logic;
  • несовместимых изменениях API.

Когда версия не требуется

Не требуют обновления версии:

  • изменение UI;
  • изменение CSS;
  • изменение staleTime;
  • изменение retry;
  • изменение refetchOnWindowFocus;
  • изменение loading state;
  • изменение placeholders.

Связь версионирования и select

select может скрывать несовместимость данных.

Пример

useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,

  select: (data) => {
    return data.items
  },
})

Если backend изменил:

{
  "products": []
}

старый select ломается.

Решение:

queryKey: ['products', 'v2']

Версионирование optimistic updates

Оптимистические обновления особенно чувствительны к схемам данных.

Старый optimistic patch

queryClient.setQueryData(
  ['product', id],
  (old) => ({
    ...old,
    likes: old.likes + 1,
  })
)

Если likes больше не существует:

  • optimistic update повреждает кеш;
  • rollback работает некорректно;
  • UI показывает неверные данные.

Версионирование mutation cache

Иногда требуется сброс mutation cache после обновления приложения.

queryClient.getMutationCache().clear()

Особенно важно для offline mutations.


Offline-first и версии кеша

В offline-first приложениях версия кеша становится критически важной.

Проблемы:

  • старые офлайн-запросы;
  • несовместимые optimistic patches;
  • устаревшие snapshots;
  • конфликтующие rollback state;
  • старые replay mutations.

Версионирование offline mutations

Пример структуры:

{
  "version": 4,
  "mutation": {
    "type": "UPDATE_PRODUCT"
  }
}

При несовместимости mutation можно:

  • отбросить;
  • преобразовать;
  • повторно синхронизировать;
  • отправить в reconciliation pipeline.

Архитектура cache schema version

Крупные приложения часто выделяют отдельную систему версий.

Пример

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
)

Связь версий кеша и backend API version

Нельзя напрямую связывать API version и cache version.

Например:

  • API v2 может иметь совместимую структуру;
  • API v1 может отдавать разные схемы;
  • gateway может трансформировать ответы;
  • frontend select может менять структуру.

Cache version отражает совместимость frontend cache schema, а не API version.


Практический production-подход

Наиболее распространённая схема:

const CACHE_VERSION = 12

Использование:

persistQueryClient({
  queryClient,
  persister,
  buster: `v${CACHE_VERSION}`,
})

И:

queryKey: [
  'products',
  CACHE_VERSION,
]

Дополнительно:

  • key factories;
  • selective invalidation;
  • hydration guards;
  • cache cleanup jobs;
  • migration utilities для редких случаев.

Антипаттерны

Отсутствие версий при persisted cache

Самая частая ошибка.

После деплоя приложение начинает работать с несовместимыми данными.


Использование timestamp как версии

queryKey: ['products', Date.now()]

Полностью отключает повторное использование кеша.


Случайные строки версий

queryKey: ['products', 'new']

Непредсказуемо и плохо масштабируется.


Смешивание разных схем под одним ключом

Плохо:

['products']

при разных форматах данных.


Ручная миграция без тестирования

Миграция кеша без проверки часто приводит к повреждению persisted state.


Тестирование версий кеша

Проверяются:

  • восстановление persisted cache;
  • hydration после деплоя;
  • invalidation старых схем;
  • совместимость infinite queries;
  • rollback optimistic updates;
  • offline mutation replay.

Интеграционные тесты версий

Пример проверки:

expect(restoredCache.version)
  .toBe(CURRENT_VERSION)

Проверка инвалидации:

expect(queryClient.getQueryData(
  ['products', 'v1']
)).toBeUndefined()

Производительность и версии

Частая смена версий приводит к:

  • увеличению сетевых запросов;
  • потере warm cache;
  • повторной hydration;
  • росту времени загрузки.

Редкая смена версий приводит к:

  • накоплению несовместимых данных;
  • ошибкам hydration;
  • повреждению persisted cache.

Баланс между стабильностью и безопасностью определяется архитектурой приложения и скоростью изменения API.