Серверная ошибка — это ситуация, при которой запрос успешно достигает сервера, но сервер не может корректно обработать операцию или возвращает неуспешный HTTP-статус. В контексте TanStack Query ошибки являются частью жизненного цикла запроса и обрабатываются встроенными механизмами библиотеки.
Наиболее распространённые категории серверных ошибок:
401 Unauthorized);403 Forbidden);404 Not Found);409 Conflict);422 Unprocessable Entity);500 Internal Server Error);503 Service Unavailable).TanStack Query рассматривает ошибку как отдельное состояние запроса, наряду с:
pending;success;error.Каждый query и mutation содержит собственный набор флагов и данных ошибок.
Простейшая обработка ошибок строится через свойства
error и isError.
import { useQuery } from '@tanstack/react-query'
async function fetchUsers() {
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error('Ошибка загрузки пользователей')
}
return response.json()
}
export function UsersPage() {
const {
data,
error,
isError,
isPending
} = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
})
if (isPending) {
return <div>Загрузка...</div>
}
if (isError) {
return <div>{error.message}</div>
}
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
API fetch() не считает HTTP-статусы 404,
500, 403 ошибками JavaScript. Ошибка
выбрасывается только при сетевых сбоях.
Неверный вариант:
async function fetchPosts() {
const response = await fetch('/api/posts')
return response.json()
}
Даже при 500 Internal Server Error код попадёт в
success.
Правильный подход:
async function fetchPosts() {
const response = await fetch('/api/posts')
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`)
}
return response.json()
}
Стандартный Error содержит слишком мало информации. На
практике сервер часто возвращает JSON с описанием проблемы.
Например:
{
"message": "Email already exists",
"code": "EMAIL_EXISTS"
}
Правильнее формировать расширенную ошибку:
async function registerUser(payload) {
const response = await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const errorData = await response.json()
const error = new Error(errorData.message)
error.code = errorData.code
error.status = response.status
throw error
}
return response.json()
}
Использование:
if (isError) {
console.log(error.status)
console.log(error.code)
}
Mutation используется для изменения данных на сервере:
Ошибки mutation возникают значительно чаще, чем ошибки query.
import { useMutation } from '@tanstack/react-query'
function CreatePost() {
const mutation = useMutation({
mutationFn: async (payload) => {
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message)
}
return response.json()
}
})
return (
<button
onCl ick={() => {
mutation.mutate({
title: 'New post'
})
}}
>
Создать
</button>
)
}
Mutation предоставляет отдельные флаги:
const {
mutate,
isPending,
isSuccess,
isError,
error
} = useMutation(...)
Пример:
if (isError) {
return <div>{error.message}</div>
}
Колбэк onError позволяет централизованно реагировать на
ошибки.
const mutation = useMutation({
mutationFn: saveUser,
onError: (error) => {
console.error(error)
showNotification(error.message)
}
})
onError полезен для:
Общая обработка ошибок позволяет не дублировать код.
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
onError: (error) => {
console.error('Query error:', error)
}
},
mutations: {
onError: (error) => {
console.error('Mutation error:', error)
}
}
}
})
TanStack Query автоматически повторяет запросы.
По умолчанию:
retry: 3
Это особенно полезно для:
Для некоторых ошибок повторные запросы бессмысленны.
Например:
401;403;404;useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
retry: false
})
Можно динамически определять необходимость повторного запроса.
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
retry: (failureCount, error) => {
if (error.status === 404) {
return false
}
return failureCount < 3
}
})
Настройка задержки между повторными попытками.
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
retryDelay: 2000
})
Динамическая задержка:
retryDelay: (attempt) => {
return Math.min(1000 * 2 ** attempt, 30000)
}
Такой подход реализует exponential backoff.
TanStack Query интегрируется с React Error Boundary.
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
throwOnError: true
})
Далее ошибка передаётся в boundary:
<ErrorBoundary fallback={<ErrorPage />}>
<PostsPage />
</ErrorBoundary>
Параметр throwOnError управляет тем, выбрасывается ли
ошибка в React.
throwOnError: true
Также поддерживается функция:
throwOnError: (error) => {
return error.status >= 500
}
Например:
500 уходят в Error Boundary;422 отображаются локально.Важно различать:
Пример неправильного подхода:
try {
mutate(data)
} catch (error) {
console.log(error)
}
mutate() не выбрасывает ошибки синхронно.
Правильный вариант:
mutate(data, {
onError: (error) => {
console.log(error)
}
})
Для async/await используется mutateAsync.
const mutation = useMutation({
mutationFn: loginUser
})
async function handleSubmit() {
try {
await mutation.mutateAsync({
email,
password
})
} catch (error) {
console.log(error.message)
}
}
Типичный сценарий:
async function fetchProfile() {
const response = await fetch('/api/profile')
if (response.status === 401) {
logout()
redirectToLogin()
}
if (!response.ok) {
throw new Error('Server error')
}
return response.json()
}
На практике ошибки удобнее обрабатывать в одном месте.
export async function api(url, options = {}) {
const response = await fetch(url, options)
let data = null
try {
data = await response.json()
} catch {}
if (!response.ok) {
const error = new Error(
data?.message || 'Unknown server error'
)
error.status = response.status
error.data = data
throw error
}
return data
}
Использование:
useQuery({
queryKey: ['posts'],
queryFn: () => api('/api/posts')
})
Сервер может вернуть ошибки полей.
Пример ответа:
{
"errors": {
"email": "Invalid email",
"password": "Too short"
}
}
Mutation:
const mutation = useMutation({
mutationFn: registerUser
})
Использование:
if (mutation.isError) {
console.log(mutation.error.data.errors.email)
}
При optimistic update данные изменяются до ответа сервера.
Если запрос завершится ошибкой, требуется rollback.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({
queryKey: ['todos']
})
const previousTodos =
queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], old => {
return [...old, newTodo]
})
return { previousTodos }
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['todos'],
context.previousTodos
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
})
Ошибка mutation не должна автоматически инвалидировать успешный кеш.
Неправильный вариант:
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['posts']
})
}
Даже при ошибке произойдёт refetch.
Лучше:
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts']
})
}
Иногда сервер вообще недоступен.
TypeError: Failed to fetch
Полезно разделять:
if (error instanceof TypeError) {
console.log('Network error')
}
Если данные устарели и refetch завершается ошибкой, TanStack Query может сохранить старые данные.
useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
staleTime: 60000
})
Поведение:
Это особенно важно для dashboard-интерфейсов.
При пагинации можно сохранять предыдущую страницу при ошибке загрузки новой.
useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
placeholderData: keepPreviousData
})
Если новая страница не загрузилась:
При использовании Suspense ошибки передаются в Error Boundary.
useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts
})
Пример:
<Suspense fallback={<Loader />}>
<ErrorBoundary fallback={<ErrorPage />}>
<Posts />
</ErrorBoundary>
</Suspense>
Часто используется интеграция с:
Пример:
onError: (error) => {
Sentry.captureException(error)
}
Крупные приложения часто используют собственные классы ошибок.
export class ApiError extends Error {
constructor(message, status, data) {
super(message)
this.status = status
this.data = data
}
}
Использование:
throw new ApiError(
data.message,
response.status,
data
)
Проверка:
if (error instanceof ApiError) {
console.log(error.status)
}
API может ограничивать частоту запросов.
retry: (count, error) => {
if (error.status === 429) {
return count < 5
}
return false
}
Иногда сервер возвращает Retry-After.
const retryAfter =
response.headers.get('Retry-After')
Для критических ошибок иногда отключают повторные попытки.
useQuery({
queryKey: ['payment'],
queryFn: processPayment,
retry: false
})
Особенно важно для:
Ошибка фонового refetch не всегда должна ломать интерфейс.
TanStack Query хранит:
Пример:
const query = useQuery({
queryKey: ['stats'],
queryFn: fetchStats,
refetchInterval: 5000
})
Даже если один из refetch завершится ошибкой:
data останется доступным;error обновится;Ошибка может возникнуть не только в queryFn, но и в
select.
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (data) => {
return data.users.map(user => ({
id: user.id,
name: user.profile.name
}))
}
})
Если profile отсутствует, возникнет runtime error.
Следует защищать преобразования:
select: (data) => {
return data.users.map(user => ({
id: user.id,
name: user.profile?.name ?? 'Unknown'
}))
}
По умолчанию ошибка имеет тип unknown.
if (error instanceof Error) {
console.log(error.message)
}
Для кастомных ошибок:
if (error instanceof ApiError) {
console.log(error.status)
}
Во время SSR ошибка запроса может привести к падению рендера.
Пример:
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts
})
Без try/catch серверный рендер может завершиться исключением.
Правильный вариант:
try {
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts
})
} catch (error) {
console.error(error)
}
Неправильная логика retry может создать DDoS собственного API.
Опасный вариант:
retry: true
Без ограничений запрос может выполняться бесконечно.
Безопасный подход:
retry: 3
или:
retry: (count) => count < 5
Крупные приложения обычно разделяют обработку ошибок на уровни:
Такое разделение уменьшает связность и упрощает поддержку приложения.