Границы ошибок с useQuery

useQuery предоставляет встроенные механизмы обработки ошибок через свойства error, isError, status, failureCount, retry и callbacks. Однако при росте приложения возникает проблема дублирования логики:

const usersQuery = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
})

if (usersQuery.isError) {
    return <ErrorMessage />
}

Через некоторое время аналогичные проверки появляются во множестве компонентов:

if (query.isError) {
    return <ErrorPage />
}

или:

if (query.error) {
    toast.error(query.error.message)
}

Подобный подход создаёт несколько проблем:

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

Для решения этих задач в TanStack Query существует механизм Error Boundaries.


Что такое Error Boundary

Error Boundary — специальный React-компонент, перехватывающий ошибки внутри дерева компонентов.

Пример базовой границы ошибок:

class ErrorBoundary extends React.Component {
    state = {
        hasError: false,
    }

    static getDerivedStateFromError() {
        return {
            hasError: true,
        }
    }

    render() {
        if (this.state.hasError) {
            return <h1>Ошибка приложения</h1>
        }

        return this.props.children
    }
}

React Error Boundary умеет перехватывать:

  • ошибки рендера;
  • ошибки lifecycle-методов;
  • ошибки хуков;
  • ошибки дочерних компонентов.

Но существует важное ограничение.


Почему обычный Error Boundary не работает с useQuery

Ошибка запроса возникает асинхронно:

const query = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
})

fetchUsers() выполняется вне процесса рендера компонента.

Следовательно:

  • React Error Boundary не увидит ошибку автоматически;
  • запрос просто перейдёт в состояние isError;
  • компонент продолжит рендериться.

Именно поэтому TanStack Query предоставляет механизм проброса ошибок в React Error Boundary.


Параметр throwOnError

Для интеграции с Error Boundary используется параметр:

throwOnError

Пример:

const usersQuery = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    throwOnError: true,
})

Теперь при ошибке TanStack Query:

  1. сохранит ошибку внутри query;
  2. во время следующего рендера выбросит исключение;
  3. React Error Boundary перехватит ошибку.

Базовая архитектура Error Boundary

Обычно структура выглядит так:

<ErrorBoundary>
    <UsersPage />
</ErrorBoundary>

Компонент:

function UsersPage() {
    const query = useQuery({
        queryKey: ['users'],
        queryFn: fetchUsers,
        throwOnError: true,
    })

    return (
        <UsersList users={query.data} />
    )
}

Если запрос завершится ошибкой:

  • UsersPage не завершит рендер;
  • React перейдёт к fallback UI;
  • пользователь увидит экран ошибки.

Использование react-error-boundary

На практике чаще используется библиотека:

npm install react-error-boundary

Пример:

import { ErrorBoundary } from 'react-error-boundary'

function ErrorFallback({ error }) {
    return (
        <div>
            <h2>Ошибка загрузки</h2>
            <pre>{error.message}</pre>
        </div>
    )
}

function App() {
    return (
        <ErrorBoundary
            FallbackComponent={ErrorFallback}
        >
            <UsersPage />
        </ErrorBoundary>
    )
}

Компонент запроса:

function UsersPage() {
    const query = useQuery({
        queryKey: ['users'],
        queryFn: fetchUsers,
        throwOnError: true,
    })

    return (
        <UsersList users={query.data} />
    )
}

Что происходит внутри TanStack Query

Алгоритм работы выглядит примерно так:

1. Выполнение query

queryFn()

2. Ошибка запроса

throw new Error('Network Error')

3. Query сохраняет ошибку

query.state.error

4. При следующем рендере TanStack Query делает:

throw error

5. React Error Boundary перехватывает исключение

<FallbackComponent />

Отличие isError от throwOnError

Подход через isError

if (query.isError) {
    return <ErrorMessage />
}

Особенности:

  • локальная обработка;
  • полный контроль внутри компонента;
  • подходит для мелких UI ошибок.

Подход через Error Boundary

throwOnError: true

Особенности:

  • централизованная обработка;
  • единый fallback UI;
  • интеграция с monitoring;
  • меньше дублирования.

Когда использовать Error Boundary

Границы ошибок особенно полезны для:

  • страниц;
  • layout-компонентов;
  • dashboard;
  • критических API;
  • SSR-приложений;
  • сложных SPA.

Пример удачного сценария:

<UserPage />

Если страница пользователя не загрузилась, проще показать целиком fallback-экран.


Когда isError лучше Error Boundary

Не каждая ошибка должна ломать UI.

Например:

const notificationsQuery = useQuery({
    queryKey: ['notifications'],
    queryFn: fetchNotifications,
})

Если уведомления не загрузились:

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

Пример:

if (query.isError) {
    return null
}

Гибридная стратегия

Очень часто используется комбинация:

  • критические ошибки → Error Boundary;
  • второстепенные ошибки → локальная обработка.

Пример:

const profileQuery = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    throwOnError: true,
})

const recommendationsQuery = useQuery({
    queryKey: ['recommendations'],
    queryFn: fetchRecommendations,
})

Профиль критичен для страницы.

Рекомендации — нет.


Условный throwOnError

Параметр принимает функцию.

Пример:

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

Теперь:

  • 500 → Error Boundary;
  • 400 → локальная обработка.

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

Очень распространённый паттерн:

throwOnError: (error) => {
    if (error.status === 401) {
        return false
    }

    return true
}

Здесь:

  • 401 обрабатывается локально;
  • остальные ошибки пробрасываются вверх.

Пример обработки 401

const query = useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    throwOnError: (error) => {
        return error.status >= 500
    },
})

if (query.error?.status === 401) {
    return <LoginRequired />
}

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

  • авторизации;
  • валидации;
  • бизнес-ошибок;
  • feature restrictions.

Работа с retry

По умолчанию TanStack Query повторяет запрос:

retry: 3

Важно понимать:

Error Boundary НЕ сработает до завершения всех retry.

Алгоритм:

1. Ошибка
2. Retry
3. Ошибка
4. Retry
5. Ошибка
6. Error Boundary

Отключение retry

Иногда ошибки должны отображаться сразу:

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    retry: false,
    throwOnError: true,
})

Особенно полезно для:

  • административных панелей;
  • критических действий;
  • серверных ошибок;
  • защищённых API.

Retry и UX

Агрессивный retry может ухудшать пользовательский опыт.

Например:

retry: 10

Проблемы:

  • долгий переход к fallback UI;
  • подвисание интерфейса;
  • бесконечное ожидание.

Чаще используются:

retry: 1

или:

retry: false

Сброс Error Boundary

После ошибки boundary остаётся в fallback состоянии.

Для повторной попытки нужен reset.

TanStack Query предоставляет специальный механизм:

QueryErrorResetBoundary

QueryErrorResetBoundary

Пример:

import {
    QueryErrorResetBoundary,
} from '@tanstack/react-query'

import { ErrorBoundary } from 'react-error-boundary'

function App() {
    return (
        <QueryErrorResetBoundary>
            {({ reset }) => (
                <ErrorBoundary
                    onRe set={reset}
                    fallbackRender={({ resetErrorBoundary }) => (
                        <div>
                            <h2>Ошибка</h2>

                            <button
                                onCl ick={() => {
                                    resetErrorBoundary()
                                }}
                            >
                                Повторить
                            </button>
                        </div>
                    )}
                >
                    <UsersPage />
                </ErrorBoundary>
            )}
        </QueryErrorResetBoundary>
    )
}

Зачем нужен QueryErrorResetBoundary

Без reset запрос останется в ошибочном состоянии:

status === 'error'

Даже после повторного рендера компонент снова выбросит ошибку.

reset() очищает error-state query.


Что делает reset

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

reset()

TanStack Query:

  • очищает query error;
  • разрешает новый запрос;
  • снимает блокировку boundary.

Повторный запрос после reset

После reset обычно происходит новый mount компонента:

<UsersPage />

И запрос запускается заново.


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

Error Boundary особенно хорошо сочетается с Suspense.

Пример:

<Suspense fallback={<Loader />}>
    <ErrorBoundary fallback={<ErrorScreen />}>
        <UsersPage />
    </ErrorBoundary>
</Suspense>

Архитектура Suspense + Error Boundary

В такой схеме:

  • loading → Suspense;
  • error → Error Boundary;
  • success → UI.

Компонент становится значительно чище:

function UsersPage() {
    const query = useSuspenseQuery({
        queryKey: ['users'],
        queryFn: fetchUsers,
    })

    return (
        <UsersList users={query.data} />
    )
}

Ошибки в useSuspenseQuery

useSuspenseQuery автоматически выбрасывает ошибки.

Поэтому:

throwOnError: true

обычно не требуется.


Локальные Error Boundary

Boundary можно размещать точечно.

Пример:

<Dashboard>
    <Sidebar />

    <ErrorBoundary fallback={<WidgetError />}>
        <AnalyticsWidget />
    </ErrorBoundary>
</Dashboard>

Ошибка внутри analytics не сломает весь dashboard.


Вложенные границы ошибок

React поддерживает nested boundaries.

Пример:

<GlobalBoundary>
    <PageBoundary>
        <WidgetBoundary>
            <Widget />
        </WidgetBoundary>
    </PageBoundary>
</GlobalBoundary>

TanStack Query корректно работает с такой архитектурой.


Централизованный fallback UI

Обычно создаётся единый компонент:

function DefaultErrorFallback({ error }) {
    return (
        <div className="error-screen">
            <h1>Что-то пошло не так</h1>

            <p>{error.message}</p>
        </div>
    )
}

Преимущества:

  • единый UX;
  • единый стиль;
  • одинаковое логирование;
  • централизованный мониторинг.

Интеграция с monitoring системами

Error Boundary удобно использовать вместе с:

  • Sentry
  • Datadog
  • New Relic

Пример:

function ErrorFallback({ error }) {
    useEffect(() => {
        captureException(error)
    }, [error])

    return (
        <ErrorScreen />
    )
}

Проблема повторных toast уведомлений

Без Error Boundary можно случайно получить множественные уведомления:

if (query.isError) {
    toast.error('Ошибка')
}

Каждый рендер будет вызывать toast повторно.


Правильная работа с toast

Лучше использовать:

useEffect(() => {
    if (query.isError) {
        toast.error(query.error.message)
    }
}, [query.isError])

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


Error Boundary как инфраструктурный слой

Крупные приложения часто используют архитектуру:

Component
↓
useQuery
↓
Error Boundary
↓
Monitoring
↓
Logging
↓
Analytics

Так достигается:

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

SSR и Error Boundaries

При SSR ошибки могут возникать:

  • на сервере;
  • на клиенте;
  • во время hydration.

TanStack Query позволяет унифицировать обработку через Error Boundary.


Важное ограничение Error Boundary

Error Boundary не ловит:

  • ошибки event handlers;
  • ошибки setTimeout;
  • ошибки вне React tree;
  • ошибки async callbacks.

Пример:

<button
    onCl ick={async () => {
        await dangerousAction()
    }}
>
    Save
</button>

Такие ошибки нужно обрабатывать отдельно через try/catch.


Антипаттерн: глобальный boundary на всё приложение

Плохой пример:

<ErrorBoundary>
    <EntireApplication />
</ErrorBoundary>

Проблема:

  • любая ошибка уничтожит всё приложение;
  • ухудшается UX;
  • теряется изоляция компонентов.

Рекомендуемая стратегия размещения

Обычно boundaries размещают:

  • на уровне страниц;
  • на уровне крупных widgets;
  • на уровне feature modules;
  • вокруг критических частей UI.

Антипаттерн: проброс всех ошибок

Плохой пример:

throwOnError: true

для абсолютно всех запросов.

Например:

  • небольшой sidebar widget;
  • уведомления;
  • второстепенные counters.

Это приводит к чрезмерному количеству fallback UI.


Практический подход

Чаще всего используется следующая стратегия:

Локальная обработка

isError

для:

  • второстепенных компонентов;
  • мелких widgets;
  • некритичных API.

Error Boundary

throwOnError

для:

  • страниц;
  • критичных данных;
  • authentication;
  • SSR;
  • Suspense архитектуры.

Архитектура production-приложений

В крупных приложениях часто применяется структура:

App Boundary
    ├── Route Boundary
    │       ├── Feature Boundary
    │       │       ├── Widget Boundary

Это позволяет:

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