Очереди мутаций

Очереди мутаций в TanStack Query формируются не как отдельная встроенная структура с явным API уровня “queue”, а как комбинация механизмов mutationCache, статуса мутаций, ключей (mutationKey) и пользовательских паттернов сериализации. На практике это слой управления конкурентным выполнением побочных эффектов, где важны порядок, идемпотентность и контроль состояния между запросами на изменение данных.


Мутация в TanStack Query представляет собой асинхронную операцию изменения серверного состояния: создание, обновление или удаление данных. В отличие от запросов (queries), мутации не кэшируются как результат, но их жизненный цикл отслеживается через mutationCache.

Каждая мутация проходит стадии:

  • idle — не запущена
  • pending — выполняется
  • success — успешно завершена
  • error — завершилась с ошибкой

Внутри библиотеки каждая мутация регистрируется в глобальном MutationCache, что позволяет отслеживать их состояние, подписываться на изменения и реализовывать сложные сценарии управления порядком выполнения.


Причины появления очередей мутаций

Очередь мутаций требуется там, где параллельное выполнение приводит к неконсистентности:

  • последовательное изменение одного ресурса
  • зависимость операций друг от друга
  • офлайн-режим и последующая синхронизация
  • оптимистические обновления с гарантией порядка
  • защита от race conditions при быстрых пользовательских действиях

Типичный пример — редактирование списка задач, где пользователь быстро меняет порядок элементов или статус нескольких сущностей подряд.


Конкурентность и проблема гонок

TanStack Query по умолчанию не сериализует мутации. Несколько вызовов mutate выполняются параллельно:

const mutation = useMutation({
  mutationFn: updateTodo,
})
mutation.mutate({ id: 1, title: 'A' })
mutation.mutate({ id: 1, title: 'B' })

Результат зависит от скорости сети и сервера: второй запрос может завершиться раньше первого, что приводит к перетиранию данных.

Эта проблема и является основной причиной построения очередей.


Базовая сериализация через mutateAsync

Самый простой механизм очереди — цепочка await mutateAsync.

const mutation = useMutation({
  mutationFn: updateTodo,
})

async function runQueue() {
  await mutation.mutateAsync({ id: 1, title: 'A' })
  await mutation.mutateAsync({ id: 1, title: 'B' })
  await mutation.mutateAsync({ id: 1, title: 'C' })
}

Это линейная очередь, где каждая операция ожидает завершения предыдущей.

Недостаток — отсутствие централизованного управления и невозможность динамического добавления задач в очередь без внешнего контейнера.


Очередь на уровне mutationKey

mutationKey позволяет группировать мутации по логическому признаку. Это основа для построения очередей на уровне сущностей.

useMutation({
  mutationKey: ['todo', 'upd ate', todoId],
  mutationFn: updateTodo,
})

Сам по себе ключ не создаёт очередь, но даёт возможность:

  • группировать мутации по сущности
  • отслеживать параллельность через MutationCache
  • реализовывать кастомную сериализацию

Через mutationCache можно получать активные мутации:

queryClient.getMutationCache().findAll({
  mutationKey: ['todo', 'update', todoId],
})

Реализация очереди через локальный менеджер

Практический подход — создание очереди поверх mutateAsync.

class MutationQueue {
  constructor() {
    this.queue = []
    this.running = false
  }

  enqueue(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject })
      this.process()
    })
  }

  async process() {
    if (this.running) return
    this.running = true

    while (this.queue.length) {
      const { task, resolve, reject } = this.queue.shift()
      try {
        const result = await task()
        resolve(result)
      } catch (e) {
        reject(e)
      }
    }

    this.running = false
  }
}

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

const queue = new MutationQueue()

const mutation = useMutation({
  mutationFn: updateTodo,
})

function updateTodoQueued(data) {
  return queue.enqueue(() => mutation.mutateAsync(data))
}

Такая очередь гарантирует последовательное выполнение мутаций.


Очереди на уровне сущностей (per-resource queue)

Наиболее распространённый сценарий — очередь на конкретный ресурс (например, один todoId), при этом другие ресурсы могут изменяться параллельно.

const queues = new Map()

function getQueue(id) {
  if (!queues.has(id)) {
    queues.se t(id, new MutationQueue())
  }
  return queues.get(id)
}

function updateTodoQueued(id, data, mutation) {
  const queue = getQueue(id)

  return queue.enqueue(() =>
    mutation.mutateAsync({ id, ...data })
  )
}

Такой подход устраняет гонки внутри одного объекта, но сохраняет параллельность между разными сущностями.


Интеграция с optimistic updates

Очереди мутаций часто сочетаются с оптимистическими обновлениями через onMutate.

useMutation({
  mutationFn: updateTodo,
  onMutate: async (newData) => {
    await queryClient.cancelQueries(['todo', newData.id])

    const previous = queryClient.getQueryData(['todo', newData.id])

    queryClient.setQueryData(['todo', newData.id], old => ({
      ...old,
      ...newData,
    }))

    return { previous }
  },
  onError: (err, newData, context) => {
    queryClient.setQueryData(
      ['todo', newData.id],
      context.previous
    )
  },
})

При использовании очереди важно, чтобы rollback не нарушал порядок выполнения. Поэтому откаты должны применяться строго к конкретной мутации, а не ко всей очереди.


Очереди и retry-механизм

TanStack Query автоматически поддерживает retry для мутаций. В контексте очередей это создаёт дополнительный уровень сложности: повторная попытка может блокировать очередь.

useMutation({
  mutationFn: updateTodo,
  retry: 3,
  retryDelay: 1000,
})

Если очередь последовательная, retry фактически “удлиняет” выполнение всей цепочки. Это может быть желаемым поведением (строгая консистентность), либо проблемой (заморозка очереди).

Для управления этим используют:

  • отключение retry для очередных мутаций
  • разделение критичных и некритичных операций
  • перенос retry-логики внутрь очереди

Офлайн-очереди и отложенная синхронизация

В связке с networkMode: 'offlineFirst' мутации могут откладываться до восстановления сети. Это формирует естественную очередь синхронизации.

useMutation({
  mutationFn: updateTodo,
  networkMode: 'offlineFirst',
})

При офлайн-режиме TanStack Query сохраняет мутации и выполняет их при восстановлении соединения. В таких сценариях порядок выполнения становится критичным, особенно при зависимых изменениях данных.


Persisted mutation queue

При использовании persistence (persistQueryClient) можно сохранять не только queries, но и состояние мутаций. Это позволяет восстанавливать очередь после перезапуска приложения.

Основная сложность:

  • сериализация функций невозможна напрямую
  • требуется хранить только данные мутации
  • восстановление через фабрику mutationFn

Пример концепта:

persistQueryClient({
  queryClient,
  persister,
})

Восстановление очереди строится через:

  • сохранённые payload’ы мутаций
  • повторное оборачивание в mutateAsync
  • повторную постановку в queue manager

Глобальная очередь мутаций

Для сложных приложений применяется централизованная очередь.

class GlobalMutationQueue {
  constructor(queryClient) {
    this.queryClient = queryClient
    this.queue = []
    this.running = false
  }

  add(mutationFn, variables) {
    return new Promise((resolve, reject) => {
      this.queue.push({ mutationFn, variables, resolve, reject })
      this.run()
    })
  }

  async run() {
    if (this.running) return
    this.running = true

    while (this.queue.length) {
      const item = this.queue.shift()

      try {
        const result = await item.mutationFn(item.variables)
        item.resolve(result)
      } catch (e) {
        item.reject(e)
      }
    }

    this.running = false
  }
}

Интеграция:

const queue = new GlobalMutationQueue(queryClient)

const mutation = useMutation({
  mutationFn: updateTodo,
})

function update(data) {
  return queue.add(mutation.mutateAsync, data)
}

Конфликты состояния и инвалидация

При очередях мутаций критически важна стратегия инвалидирования кэша:

  • немедленная invalidation после каждой мутации приводит к перегрузке сети
  • отложенная invalidation может показывать устаревшие данные

Типичный паттерн:

  • оптимистическое обновление локально
  • инвалидирование только после завершения очереди
  • батчинг invalidation через queryClient.invalidateQueries

Отличие очередей от батчинга

Очередь и батчинг часто путаются, но имеют разные цели:

Очередь:

  • последовательное выполнение
  • контроль порядка
  • устранение гонок

Батчинг:

  • объединение нескольких операций в одну
  • минимизация запросов
  • оптимизация сети

Очередь может содержать батчи, но батч не гарантирует порядок между элементами.


Типовые архитектурные паттерны

Используются три основных подхода:

  1. Пер-ресурсные очереди Изоляция по entityId

  2. Глобальная очередь Полная сериализация мутаций

  3. Гибридная модель Очереди по ключу + глобальный лимитер параллелизма

Гибрид часто реализуется как:

  • максимум 1 мутация на ключ
  • максимум N параллельных мутаций глобально

Контроль параллелизма через MutationCache

MutationCache позволяет отслеживать активные мутации:

queryClient.getMutationCache().getAll()

Это используется для:

  • блокировки UI при активных мутациях
  • предотвращения дублирующих операций
  • построения адаптивных очередей

Практическая модель поведения очереди

В реальных приложениях очередь мутаций обычно включает:

  • сериализацию по ключу
  • управление retry
  • интеграцию с offline режимом
  • оптимистические обновления
  • отложенную инвалидацию
  • контроль конкурентности

Такой слой становится промежуточным “mutation orchestration layer” поверх TanStack Query, который превращает библиотеку из простого клиента серверного состояния в управляемую систему выполнения операций изменения данных.