В приложениях, использующих TanStack Query, ошибки возникают
постоянно: сетевые сбои, недоступность API, ошибки авторизации,
превышение лимитов запросов, некорректные ответы сервера, таймауты и
проблемы сериализации данных. Если каждая ошибка обрабатывается локально
внутри отдельных useQuery и useMutation, код
быстро становится перегруженным, дублируется и начинает противоречить
сам себе.
Глобальная обработка ошибок решает несколько задач одновременно:
Без глобальной обработки ошибок код часто выглядит так:
const query = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
onError: (error) => {
toast.error('Ошибка загрузки пользователей');
}
});
Проблема в том, что аналогичный onError начинает
копироваться десятки или сотни раз.
Наиболее распространённый тип — ошибки внутри
queryFn.
const fetchUsers = async () => {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Ошибка загрузки');
}
return response.json();
};
Если функция выбрасывает исключение, TanStack Query переводит запрос
в состояние error.
Ошибки могут происходить внутри mutationFn.
const createUser = async (payload) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error('Ошибка создания');
}
return response.json();
};
Иногда ошибка возникает уже после получения данных.
select: (data) => data.items.map(...)
Если items отсутствует, приложение может получить
исключение уже на этапе обработки результата.
Сервер может вернуть:
401 Unauthorized403 ForbiddenТакие ошибки обычно требуют глобальной реакции:
Обычно система состоит из нескольких уровней:
| Уровень | Назначение |
|---|---|
| Query Cache | Глобальные ошибки запросов |
| Mutation Cache | Глобальные ошибки мутаций |
| API Layer | Нормализация ошибок |
| UI Layer | Toast, модальные окна |
| Monitoring Layer | Sentry, LogRocket |
| Auth Layer | Logout и refresh token |
TanStack Query позволяет перехватывать ошибки на уровне
QueryCache.
import {
QueryClient,
QueryCache
} from '@tanstack/react-query';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
console.error(error);
console.log(query.queryKey);
}
})
});
Теперь любой запрос, завершившийся ошибкой, попадёт в этот обработчик.
Объект ошибки.
onError: (error) => {
console.log(error.message);
}
Полный объект запроса.
onError: (error, query) => {
console.log(query.queryKey);
}
Это особенно полезно для:
Для мутаций используется отдельный обработчик.
import {
QueryClient,
MutationCache
} from '@tanstack/react-query';
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: (error, variables, context, mutation) => {
console.log(error);
}
})
});
На практике ошибки запросов и мутаций обрабатываются по-разному.
Обычно:
Чаще:
Очень распространённый сценарий.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error(error.message);
}
})
});
Однако без фильтрации интерфейс может оказаться завален уведомлениями.
Один запрос может:
В результате пользователь увидит множество одинаковых toast.
onError: (error, query) => {
if (query.state.fetchFailureCount === 1) {
toast.error(error.message);
}
}
onError: (error, query) => {
if (query.state.data !== undefined) {
return;
}
toast.error(error.message);
}
Если данные уже были получены ранее, ошибка background refetch может не требовать уведомления.
Серверы редко возвращают ошибки в одинаковом формате.
Например:
{
"message": "Validation failed"
}
или:
{
"error": {
"text": "Access denied"
}
}
или:
{
"errors": [
"Invalid email"
]
}
Без нормализации UI-код начинает усложняться.
export class ApiError extends Error {
constructor(
message,
status,
data
) {
super(message);
this.status = status;
this.data = data;
}
}
export const apiClient = async (
url,
options = {}
) => {
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok) {
throw new ApiError(
data.message || 'API Error',
response.status,
data
);
}
return data;
};
Теперь все ошибки имеют единый формат.
if (error.status === 401) {
logout();
}
if (error.status === 403) {
navigate('/forbidden');
}
if (error.status >= 500) {
toast.error('Ошибка сервера');
}
Один из наиболее важных сценариев.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (error.status === 401) {
authStore.logout();
}
}
})
});
После разлогинивания необходимо удалить приватные данные.
queryClient.clear();
Либо:
queryClient.removeQueries();
TanStack Query умеет автоматически повторять запросы.
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
retry: 3
});
Некоторые ошибки повторять нельзя.
Например:
401403404retry: (failureCount, error) => {
if (error.status === 401) {
return false;
}
return failureCount < 3;
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (count, error) => {
if (error.status >= 400 &&
error.status < 500) {
return false;
}
return count < 3;
}
}
}
});
retryDelay: (attempt) =>
Math.min(1000 * 2 ** attempt, 30000)
Такой подход реализует exponential backoff.
TanStack Query интегрируется с React Error Boundary.
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
useErrorBoundary: true
});
Если запрос завершится ошибкой, исключение попадёт в React boundary.
Подходит для:
Не подходит для:
<ErrorBoundary fallback={<PageError />}>
<App />
</ErrorBoundary>
Частая ошибка — показывать и boundary, и toast одновременно.
Это создаёт дублирование UI.
Обычно используют правило:
| Тип ошибки | Поведение |
|---|---|
| Критическая | Error Boundary |
| Некритическая | Toast |
| Form validation | Inline errors |
Глобальный обработчик не запрещает локальный.
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
onError: (error) => {
console.log('Локальная ошибка');
}
});
Оба обработчика будут вызваны.
Обычно архитектура выглядит так:
Особенно важная тема.
Сценарий:
Если полностью скрыть UI, это ухудшит UX.
TanStack Query сохраняет предыдущие данные даже после ошибки refetch.
if (query.isError && query.data) {
return (
<>
<Warning />
<Table data={query.data} />
</>
);
}
if (query.isPending) {
return <Loader />;
}
if (query.isError && !query.data) {
return <ErrorPage />;
}
Практически все production-приложения отправляют ошибки во внешние сервисы.
Например:
onError: (error, query) => {
Sentry.captureException(error, {
tags: {
queryKey: JSON.stringify(
query.queryKey
)
}
});
}
Не все ошибки должны логироваться.
Например:
onError: (error) => {
if (error.name === 'AbortError') {
return;
}
captureException(error);
}
if (!navigator.onLine) {
return;
}
Часто используется вместе с optimistic updates.
useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
const previous =
queryClient.getQueryData(['user']);
queryClient.setQueryData(
['user'],
newUser
);
return { previous };
},
onError: (
error,
variables,
context
) => {
queryClient.setQueryData(
['user'],
context.previous
);
}
});
Иногда важно понимать:
const logError = ({
error,
queryKey
}) => {
console.log({
message: error.message,
queryKey,
date: Date.now()
});
};
const handleApiError = (
error,
options = {}
) => {
if (error.status === 401) {
authStore.logout();
return;
}
if (error.status >= 500) {
toast.error('Ошибка сервера');
}
if (options.log !== false) {
captureException(error);
}
};
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: handleApiError
}),
mutationCache: new MutationCache({
onError: handleApiError
})
});
Ошибки валидации редко должны быть глобальными.
Например:
{
"errors": {
"email": "Invalid email"
}
}
Подобные ошибки обрабатываются локально внутри формы.
Полезная практика — создавать категории:
class ValidationError extends Error {}
class AuthError extends Error {}
class NetworkError extends Error {}
class ServerError extends Error {}
if (error instanceof ValidationError) {
return;
}
{
"code": "EMAIL_ALREADY_EXISTS"
}
const errorMessages = {
EMAIL_ALREADY_EXISTS:
'Email уже используется',
INVALID_PASSWORD:
'Неверный пароль'
};
При использовании SSR ошибки могут происходить:
Это требует отдельной стратегии.
try {
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
} catch (error) {
console.error(error);
}
invalidateQueries сам по себе редко выбрасывает ошибки,
но refetch после invalidation — может.
TanStack Query Devtools позволяют:
Создаёт перегрузку интерфейса.
Можно скрыть реальные проблемы сети.
Ошибки формы не должны попадать в глобальный handler.
Бессмысленно и создаёт лишнюю нагрузку.
Пользователь теряет уже загруженные данные.
Типичная production-архитектура выглядит так:
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: globalQueryErrorHandler
}),
mutationCache: new MutationCache({
onError: globalMutationErrorHandler
}),
defaultOptions: {
queries: {
retry,
retryDelay
}
}
});
Главная задача состоит не в том, чтобы показать сообщение об ошибке, а в том, чтобы сделать поведение системы: