Fallback UI — это пользовательский интерфейс, отображаемый в момент ожидания данных, возникновения ошибки или выполнения асинхронной операции. В экосистеме React и TanStack Query fallback-компоненты играют ключевую роль в формировании устойчивого и предсказуемого пользовательского опыта.
При работе с серверным состоянием невозможно гарантировать мгновенное получение данных. Запросы могут:
Без fallback UI интерфейс начинает «прыгать», отображать пустые блоки или зависать без обратной связи. TanStack Query предоставляет достаточно инструментов для построения гибких fallback-сценариев практически любой сложности.
Любой запрос в TanStack Query проходит через несколько состояний:
| Состояние | Описание |
|---|---|
| pending | Запрос выполняется впервые |
| success | Данные успешно получены |
| error | Произошла ошибка |
| fetching | Идёт фоновое обновление |
| paused | Запрос поставлен на паузу |
На практике fallback UI строится вокруг комбинации этих состояний.
Пример:
const {
data,
isPending,
isError,
error,
} = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
Самый распространённый fallback — индикатор загрузки.
if (isPending) {
return <div>Загрузка...</div>
}
После завершения запроса отображаются данные:
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
Такой подход считается минимально допустимым, однако он плохо масштабируется при большом количестве запросов.
Наивная реализация быстро приводит к проблемам:
Одинаковый код:
if (isPending)
if (isError)
появляется во множестве компонентов.
if (isPending) {
return ...
}
if (isError) {
return ...
}
if (!data.length) {
return ...
}
Интерфейс становится трудно поддерживать.
При background refetch контент может исчезать и появляться заново.
Во время загрузки структура страницы ломается:
Наиболее распространённый подход — создание обёртки.
type QueryBoundaryProps<T> = {
query: UseQueryResult<T>
children: (data: T) => React.ReactNode
}
function QueryBoundary<T>({
query,
children,
}: QueryBoundaryProps<T>) {
if (query.isPending) {
return <Spinner />
}
if (query.isError) {
return <ErrorMessage error={query.error} />
}
return children(query.data)
}
Использование:
<QueryBoundary query={usersQuery}>
{users => (
<UsersTable users={users} />
)}
</QueryBoundary>
Преимущества:
Skeleton-интерфейсы визуально повторяют структуру будущего контента.
Вместо:
<div>Загрузка...</div>
используется:
<UserCardSkeleton />
Пример:
function UserCardSkeleton() {
return (
<div className="card skeleton">
<div className="avatar" />
<div className="line" />
<div className="line short" />
</div>
)
}
Skeleton UI считается более современным подходом, потому что:
TanStack Query поддерживает placeholderData.
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
placeholderData: [],
})
Преимущества:
undefined;Однако placeholderData не кэшируется как полноценный результат.
initialData отличается от
placeholderData.
useQuery({
queryKey: ['settings'],
queryFn: fetchSettings,
initialData: defaultSettings,
})
Особенности:
| initialData | placeholderData |
|---|---|
| попадает в кэш | не попадает |
| считается реальными данными | временные данные |
| влияет на stale state | не влияет |
Очень важный сценарий.
Во время background refetch нельзя полностью скрывать интерфейс.
Неправильно:
if (isFetching) {
return <Spinner />
}
Это приведёт к мерцанию.
Правильный подход:
<>
{isFetching && <SmallLoader />}
<UsersTable users={data} />
</>
Основной контент сохраняется, а пользователь видит индикатор обновления.
TanStack Query предоставляет разные флаги:
| Флаг | Назначение |
|---|---|
| isPending | первый запрос |
| isFetching | любой fetch |
| isRefetching | повторный fetch |
| isLoadingError | ошибка первой загрузки |
| isRefetchError | ошибка refetch |
Пример:
if (query.isPending) {
return <PageSkeleton />
}
return (
<>
{query.isRefetching && (
<TopBarProgress />
)}
<Content data={query.data} />
</>
)
Ошибки требуют отдельной UX-стратегии.
Примитивный вариант:
if (isError) {
return <div>Ошибка</div>
}
Недостатки:
type ErrorViewProps = {
error: Error
onRetry: () => void
}
function ErrorView({
error,
onRetry,
}: ErrorViewProps) {
return (
<div>
<h2>Не удалось загрузить данные</h2>
<pre>{error.message}</pre>
<button onCl ick={onRetry}>
Повторить
</button>
</div>
)
}
Использование:
if (query.isError) {
return (
<ErrorView
error={query.error}
onRe try={() => query.refetch()}
/>
)
}
TanStack Query поддерживает интеграцию с React Error Boundary.
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
throwOnError: true,
})
Ошибка будет выброшена в Error Boundary.
Пример:
<ErrorBoundary fallback={<PageError />}>
<ProfilePage />
</ErrorBoundary>
Для корректного сброса ошибок используется специальный boundary.
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onRe set={reset}
fallbackRender={({ resetErrorBoundary }) => (
<ErrorFallback
onRe try={resetErrorBoundary}
/>
)}
>
<Page />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
Это особенно важно при retry-сценариях.
TanStack Query поддерживает React Suspense.
const query = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
Компонент автоматически «приостанавливается».
Fallback задаётся через Suspense:
<Suspense fallback={<PostsSkeleton />}>
<PostsPage />
</Suspense>
Suspense устраняет:
if (isPending);Компонент получает уже готовые данные:
const { data } = useSuspenseQuery(...)
Несмотря на удобство, существуют ограничения.
Иногда нужен отдельный UI для:
Suspense скрывает часть контроля.
Suspense почти всегда требует:
Архитектура усложняется.
Большие приложения используют вложенные fallback-границы.
Пример:
<Suspense fallback={<PageSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<Table />
</Suspense>
</Dashboard>
</Suspense>
Преимущества:
Fallback UI позволяет строить progressive rendering.
Например:
Это создаёт ощущение быстрого интерфейса даже при медленном API.
Fallback — это не только loading и error.
Пустые данные тоже требуют отдельного интерфейса.
Плохой вариант:
return <ul></ul>
Правильный вариант:
if (data.length === 0) {
return (
<EmptyState
title="Нет пользователей"
/>
)
}
Нет проектов
Ничего не найдено
Недостаточно прав
Создайте первый документ
Одна из ключевых задач fallback UI — сохранение структуры страницы.
Неправильно:
if (isPending) {
return <Spinner />
}
После загрузки интерфейс резко меняется.
Правильнее:
<div className="page">
<Sidebar />
<main>
{isPending
? <ContentSkeleton />
: <Content />}
</main>
</div>
Очень полезная возможность для пагинации.
useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
placeholderData: keepPreviousData,
})
Во время переключения страниц:
Optimistic update — это тоже разновидность fallback UX.
Пользователь видит результат ещё до ответа сервера.
onMutate: async newTodo => {
await queryClient.cancelQueries({
queryKey: ['todos'],
})
const previous =
queryClient.getQueryData(['todos'])
queryClient.setQueryData(
['todos'],
old => [...old, newTodo]
)
return { previous }
}
TanStack Query умеет работать в offline-сценариях.
Fallback может отображать:
<OfflineBanner />
или:
<RetryLaterMessage />
Иногда требуется отображать состояние загрузки всего приложения.
Используется useIsFetching.
const fetching = useIsFetching()
Пример:
{fetching > 0 && <GlobalLoader />}
const mutating = useIsMutating()
Пример:
{mutating > 0 && (
<SavingIndicator />
)}
Мгновенное отображение spinner может ухудшать UX.
При быстрых запросах появляется неприятное мерцание.
Используется задержка:
const [visible, setVisible] =
useState(false)
useEffect(() => {
const timer = setTimeout(() => {
setVisible(true)
}, 300)
return () => clearTimeout(timer)
}, [])
Если запрос завершился быстро — loader вообще не появится.
Современные приложения обычно используют комбинацию:
| Сценарий | Решение |
|---|---|
| Initial loading | Skeleton |
| Background refetch | subtle loader |
| Empty state | отдельный экран |
| Error | retry UI |
| Pagination | keepPreviousData |
| Suspense | nested fallback |
| Offline | offline banner |
| Mutation | optimistic UI |
Крупные проекты обычно выделяют:
components/
fallback/
ErrorView/
Skeletons/
EmptyState/
OfflineBanner/
LoadingOverlay/
Это обеспечивает:
Иногда создаётся универсальный компонент:
<Resource
query={usersQuery}
loading={<UsersSkeleton />}
error={(err) => (
<ErrorView error={err} />
)}
empty={<EmptyUsers />}
>
{(users) => (
<UsersTable users={users} />
)}
</Resource>
Такой подход существенно уменьшает количество условного рендера.
Плохой fallback приводит к:
Даже быстрый API может восприниматься как медленный при неправильном fallback UX.
Основной контент должен оставаться доступным.
Skeleton лучше сохраняет layout.
Это разные состояния.
Пустой результат — это не ошибка.
Повторяющийся код должен быть вынесен.
Особенно в Suspense-архитектуре.
Полная блокировка интерфейса раздражает пользователей.
Высота и структура страницы не должны резко меняться.
Это снижает perceived latency.
Пользователь должен иметь возможность повторить запрос.