Обработка ошибок в мутациях

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

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

Из-за этого обработка ошибок в мутациях требует более точного контроля. Необходимо учитывать:

  • отображение ошибок интерфейсу;
  • повторные попытки;
  • откат optimistic update;
  • логирование;
  • различие между серверными и сетевыми ошибками;
  • синхронизацию состояния интерфейса.

Базовая обработка ошибок через isError

Наиболее простой способ обработки — использование состояния isError.

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

function CreatePost() {
  const mutation = useMutation({
    mutationFn: async (post) => {
      const response = await axios.post('/api/posts', post)

      return response.data
    }
  })

  const handleCreate = () => {
    mutation.mutate({
      title: 'Новая статья'
    })
  }

  return (
    <div>
      <button onCl ick={handleCreate}>
        Создать
      </button>

      {mutation.isPending && <p>Сохранение...</p>}

      {mutation.isError && (
        <p>Ошибка при создании записи</p>
      )}

      {mutation.isSuccess && (
        <p>Запись создана</p>
      )}
    </div>
  )
}

Состояние isError становится true, если mutationFn выбрасывает исключение.


Поле error

Для получения текста ошибки используется поле error.

if (mutation.isError) {
  console.log(mutation.error)
}

Пример:

{mutation.isError && (
  <p>{mutation.error.message}</p>
)}

Ошибки в Axios

Axios автоматически выбрасывает исключение при статусах 4xx и 5xx.

const mutation = useMutation({
  mutationFn: async (data) => {
    const response = await axios.post('/api/users', data)

    return response.data
  }
})

Если сервер вернёт:

{
  "message": "Email уже существует"
}

то ошибка будет доступна через:

mutation.error.response.data.message

Пример:

{mutation.isError && (
  <p>
    {mutation.error.response.data.message}
  </p>
)}

Обработка ошибок через onError

TanStack Query позволяет централизованно обрабатывать ошибки через callback onError.

const mutation = useMutation({
  mutationFn: createUser,

  onError: (error) => {
    console.error(error)
  }
})

onError вызывается:

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

Параметры onError

Callback получает несколько аргументов.

const mutation = useMutation({
  mutationFn: updatePost,

  onError: (error, variables, context) => {
    console.log(error)
    console.log(variables)
    console.log(context)
  }
})

error

Объект ошибки.

variables

Аргументы, переданные в mutate.

mutation.mutate({
  id: 10,
  title: 'Новый текст'
})

Тогда:

variables.id
variables.title

context

Контекст, возвращённый из onMutate.

Обычно используется для rollback optimistic update.


Серверные и сетевые ошибки

Важно различать типы ошибок.

Серверная ошибка

Сервер ответил кодом:

  • 400
  • 401
  • 403
  • 404
  • 422
  • 500

Пример:

error.response

Сетевая ошибка

Запрос вообще не дошёл до сервера:

  • отсутствует интернет;
  • таймаут;
  • DNS;
  • CORS;
  • обрыв соединения.

Пример:

error.request

Разделение ошибок по типу

onError: (error) => {
  if (error.response) {
    console.log('Ошибка сервера')
  } else if (error.request) {
    console.log('Сетевая ошибка')
  } else {
    console.log('Неизвестная ошибка')
  }
}

Обработка ошибок в mutate

mutate поддерживает локальные обработчики.

mutation.mutate(data, {
  onError: (error) => {
    console.log(error)
  }
})

Это позволяет переопределять логику для конкретного вызова.


Глобальный onError и локальный onError

Оба обработчика могут существовать одновременно.

const mutation = useMutation({
  mutationFn: saveUser,

  onError: () => {
    console.log('Глобальная ошибка')
  }
})

mutation.mutate(data, {
  onError: () => {
    console.log('Локальная ошибка')
  }
})

Будут вызваны оба обработчика.


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

mutateAsync превращает мутацию в Promise.

const mutation = useMutation({
  mutationFn: login
})

Пример:

try {
  const result = await mutation.mutateAsync({
    email,
    password
  })

  console.log(result)
} catch (error) {
  console.log(error)
}

Такой подход особенно полезен:

  • в формах;
  • в async/await логике;
  • при последовательных запросах;
  • в многошаговых операциях.

Почему try/catch не работает с mutate

mutate не возвращает Promise.

Неправильно:

try {
  mutation.mutate(data)
} catch (error) {
  console.log(error)
}

Правильно:

await mutation.mutateAsync(data)

Обработка ошибок в формах

Очень распространённый сценарий.

const mutation = useMutation({
  mutationFn: registerUser
})

Пример:

const handleSubmit = async (values) => {
  try {
    await mutation.mutateAsync(values)

    resetForm()
  } catch (error) {
    setFormError(
      error.response.data.message
    )
  }
}

Валидационные ошибки

Backend часто возвращает ошибки валидации.

Пример ответа сервера:

{
  "errors": {
    "email": ["Некорректный email"],
    "password": ["Минимум 8 символов"]
  }
}

Обработка:

catch (error) {
  const errors =
    error.response.data.errors

  setErrors(errors)
}

Retry в мутациях

По умолчанию мутации не повторяются автоматически.

Это важное отличие от useQuery.

Причина:

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

Включение retry

const mutation = useMutation({
  mutationFn: saveData,
  retry: 3
})

Теперь TanStack Query выполнит:

  • первоначальный запрос;
  • ещё 3 повторные попытки.

Функция retry

Можно гибко управлять повтором.

retry: (failureCount, error) => {
  if (error.response?.status === 404) {
    return false
  }

  return failureCount < 2
}

Retry delay

retryDelay: 1000

или:

retryDelay: (attempt) => {
  return attempt * 1000
}

Exponential backoff

retryDelay: (attempt) => {
  return Math.min(
    1000 * 2 ** attempt,
    30000
  )
}

Поведение:

Попытка Задержка
1 2000 ms
2 4000 ms
3 8000 ms

Ошибки optimistic update

Optimistic update изменяет интерфейс до ответа сервера.

Если запрос завершится ошибкой — данные нужно откатить.


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

const mutation = useMutation({
  mutationFn: updateTodo,

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

    const previousTodos =
      queryClient.getQueryData(['todos'])

    queryClient.setQueryData(
      ['todos'],
      (old) => {
        return old.map((todo) =>
          todo.id === newTodo.id
            ? newTodo
            : todo
        )
      }
    )

    return { previousTodos }
  },

  onError: (
    error,
    variables,
    context
  ) => {
    queryClient.setQueryData(
      ['todos'],
      context.previousTodos
    )
  }
})

Почему rollback обязателен

Без rollback интерфейс может показать:

  • несуществующие изменения;
  • удалённые, но реально существующие записи;
  • неверные данные;
  • рассинхронизацию клиента и сервера.

Обработка ошибок удаления

Удаление — одна из самых рискованных мутаций.

const deleteMutation = useMutation({
  mutationFn: deletePost,

  onError: () => {
    toast.error(
      'Не удалось удалить запись'
    )
  }
})

Throwing errors вручную

Иногда API не выбрасывает ошибку автоматически.

const mutation = useMutation({
  mutationFn: async (data) => {
    const response = await fetch('/api')

    const result = await response.json()

    if (!response.ok) {
      throw new Error(result.message)
    }

    return result
  }
})

Ошибки fetch

fetch не выбрасывает исключения при 404 и 500.

Это одна из самых распространённых ошибок начинающих разработчиков.

Неправильно:

const response = await fetch('/api')

return response.json()

Правильно:

if (!response.ok) {
  throw new Error('Ошибка запроса')
}

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

TanStack Query поддерживает интеграцию с Error Boundary.

const mutation = useMutation({
  mutationFn: saveUser,
  throwOnError: true
})

Теперь ошибка будет выброшена в React Error Boundary.


throwOnError

Возможны разные варианты:

throwOnError: true

или:

throwOnError: (error) => {
  return error.response?.status >= 500
}

Пример:

  • ошибки 400 обрабатываются локально;
  • ошибки 500 отправляются в Error Boundary.

Сброс ошибок

Для повторной попытки может понадобиться сброс состояния.

mutation.reset()

После вызова:

  • isError станет false;
  • error очистится;
  • status вернётся к idle.

Пример reset

<button
  onCl ick={() => mutation.reset()}
>
  Скрыть ошибку
</button>

Централизованная обработка ошибок

Можно создать универсальную функцию.

function handleApiError(error) {
  if (error.response?.status === 401) {
    logout()
    return
  }

  if (error.response?.status === 403) {
    toast.error('Нет доступа')
    return
  }

  toast.error(
    error.response?.data?.message ||
    'Ошибка сервера'
  )
}

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

const mutation = useMutation({
  mutationFn: updateProfile,

  onError: handleApiError
})

Логирование ошибок

Часто используется интеграция:

  • Sentry;
  • LogRocket;
  • Datadog;
  • Bugsnag.

Пример:

onError: (error) => {
  Sentry.captureException(error)
}

Ошибки авторизации

При 401 обычно:

  • удаляется токен;
  • очищается кеш;
  • выполняется logout;
  • пользователь перенаправляется на login.

Пример:

onError: (error) => {
  if (error.response?.status === 401) {
    localStorage.removeItem('token')

    queryClient.clear()

    navigate('/login')
  }
}

Race conditions и ошибки

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

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

Проверка активной мутации

const isUpdating =
  useIsMutating({
    mutationKey: ['update-post']
  }) > 0

Mutation status

Мутация имеет несколько состояний.

Статус Описание
idle Мутация не запускалась
pending Выполняется
success Завершилась успешно
error Завершилась ошибкой

Полный пример обработки ошибок

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

function UpdateProfile() {
  const mutation = useMutation({
    mutationFn: async (data) => {
      const response = await axios.put(
        '/api/profile',
        data
      )

      return response.data
    },

    retry: (count, error) => {
      if (
        error.response?.status === 400
      ) {
        return false
      }

      return count < 2
    },

    onError: (error) => {
      if (error.response) {
        console.log(
          'Ошибка сервера'
        )
      } else {
        console.log(
          'Ошибка сети'
        )
      }
    }
  })

  const handleSave = async () => {
    try {
      await mutation.mutateAsync({
        name: 'Alex'
      })

      console.log('Сохранено')
    } catch (error) {
      console.log(error.message)
    }
  }

  return (
    <div>
      <button
        onCl ick={handleSave}
        disabled={mutation.isPending}
      >
        Сохранить
      </button>

      {mutation.isPending && (
        <p>Сохранение...</p>
      )}

      {mutation.isError && (
        <p>
          {mutation.error.message}
        </p>
      )}
    </div>
  )
}