Отмена мутаций

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

Отмена мутаций — это механизм управления жизненным циклом мутации, позволяющий прерывать выполнение операции до её завершения или игнорировать её результат, если он больше не актуален.


Модель выполнения мутаций и точки отмены

Мутация в TanStack Query проходит несколько этапов:

  1. Инициация (mutate)
  2. Выполнение mutationFn
  3. Успешное завершение или ошибка
  4. Обновление кэша и триггеры инвалидции

Отмена может происходить на разных уровнях:

  • до вызова mutationFn
  • во время выполнения mutationFn (если поддерживается AbortController)
  • после завершения запроса (логическая отмена через игнорирование результата)

AbortController как основной механизм отмены

TanStack Query использует стандартный веб API AbortController для отмены асинхронных операций.

Базовая интеграция

import { useMutation } from '@tanstack/react-query'

const mutation = useMutation({
  mutationFn: async ({ id, signal }) => {
    const res = await fetch(`/api/items/${id}`, {
      method: 'DELETE',
      signal,
    })

    if (!res.ok) {
      throw new Error('Ошибка удаления')
    }

    return res.json()
  },
})

В этом примере signal автоматически передаётся TanStack Query в mutationFn. Если мутация отменяется, signal.abort() прерывает fetch.


Ручная отмена мутации

TanStack Query предоставляет методы управления активной мутацией через объект мутации.

const mutation = useMutation({
  mutationFn: async (data) => {
    const controller = new AbortController()

    const res = await fetch('/api/items', {
      method: 'POST',
      body: JSON.stringify(data),
      signal: controller.signal,
    })

    return res.json()
  },
})

mutation.reset()

Однако reset() не отменяет сетевой запрос. Для реальной отмены требуется хранить AbortController самостоятельно.


Управление AbortController внутри мутации

Наиболее гибкий подход — явное управление контроллером:

let controller

const mutation = useMutation({
  mutationFn: async (data) => {
    controller = new AbortController()

    const res = await fetch('/api/items', {
      method: 'POST',
      body: JSON.stringify(data),
      signal: controller.signal,
    })

    return res.json()
  },
})

const cancelMutation = () => {
  controller?.abort()
}

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


Отмена через onMutate и контекст

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

const mutation = useMutation({
  mutationFn: async (newItem) => {
    return fetch('/api/items', {
      method: 'POST',
      body: JSON.stringify(newItem),
    }).then(res => res.json())
  },

  onMutate: async (newItem) => {
    await queryClient.cancelQueries({ queryKey: ['items'] })

    const previous = queryClient.getQueryData(['items'])

    queryClient.setQueryData(['items'], old => [
      ...old,
      { ...newItem, temp: true },
    ])

    return { previous }
  },

  onError: (err, newItem, context) => {
    queryClient.setQueryData(['items'], context.previous)
  },
})

Хотя это не «отмена» сетевого запроса напрямую, такой механизм позволяет отменить эффект мутации на уровне состояния.


Логическая отмена результата

Даже если запрос не был физически прерван, его результат можно игнорировать.

let isCancelled = false

const mutation = useMutation({
  mutationFn: async (data) => {
    const res = await fetch('/api/items', {
      method: 'POST',
      body: JSON.stringify(data),
    })

    const result = await res.json()

    if (isCancelled) {
      return
    }

    return result
  },
})

const cancel = () => {
  isCancelled = true
}

Этот подход полезен, когда API или окружение не поддерживает AbortController.


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

В React-окружении частая задача — предотвращение обновления состояния после размонтирования компонента.

TanStack Query частично решает это автоматически, но при ручных мутациях требуется учитывать жизненный цикл:

useEffect(() => {
  return () => {
    controller?.abort()
  }
}, [])

Это предотвращает попытки обновления состояния после ухода компонента из дерева.


Конкуренция мутаций и устаревшие запросы

При быстрых последовательных вызовах мутаций может возникать ситуация гонки:

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

Решения:

1. Сериализация мутаций

const mutation = useMutation({
  mutationFn: async (data) => {
    await queue.add(() => api.save(data))
  },
})

2. Игнорирование устаревших результатов

let lastCallId = 0

const mutation = useMutation({
  mutationFn: async (data) => {
    const callId = ++lastCallId

    const result = await api.save(data)

    if (callId !== lastCallId) return

    return result
  },
})

Взаимодействие отмены с инвалидацией кэша

Мутации часто сопровождаются invalidateQueries. Отмена влияет на это поведение косвенно.

Если мутация отменена:

  • onSuccess не вызывается
  • invalidateQueries не выполняется
  • кэш остаётся в прежнем состоянии
const mutation = useMutation({
  mutationFn: api.updateItem,

  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['items'] })
  },
})

При отмене выполнения onSuccess логически не достигается.


Отмена с использованием signal в кастомных API

При использовании Axios или других библиотек требуется адаптация.

Axios с AbortController

import axios from 'axios'

const mutation = useMutation({
  mutationFn: async ({ id, signal }) => {
    const res = await axios.delete(`/api/items/${id}`, {
      signal,
    })

    return res.data
  },
})

Axios начиная с современных версий поддерживает AbortController, что упрощает интеграцию.


Типичные ошибки при отмене мутаций

1. Потеря ссылки на AbortController

Без хранения контроллера невозможно прервать запрос после запуска.

2. Попытка отменить уже завершённую мутацию

AbortController не влияет на уже завершённый промис, поэтому результат может прийти позже и быть неактуальным.

3. Игнорирование гонок состояния

Отмена запроса не отменяет уже выполненные side effects, если они не защищены проверками.


Паттерн безопасной мутации с отменой

const mutation = useMutation({
  mutationFn: async (data) => {
    const controller = new AbortController()

    const promise = fetch('/api/items', {
      method: 'POST',
      body: JSON.stringify(data),
      signal: controller.signal,
    })

    promise.abortController = controller

    const res = await promise
    return res.json()
  },
})

const cancel = () => {
  mutation?.mutationFn?.abortController?.abort()
}

Хотя TanStack Query не требует такого паттерна, он полезен при сложных сценариях.


Итоговая модель поведения отмены

Отмена мутаций в TanStack Query — это не единый механизм, а комбинация нескольких уровней:

  • сетевой уровень через AbortController
  • логический уровень через игнорирование результата
  • уровень состояния через onMutate и откат
  • уровень конкуренции через контроль идентификаторов

Правильная архитектура всегда опирается на сочетание этих подходов, а не на один инструмент.