Fallback UI

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,
})

Базовый Loading Fallback

Самый распространённый fallback — индикатор загрузки.

if (isPending) {
  return <div>Загрузка...</div>
}

После завершения запроса отображаются данные:

return (
  <ul>
    {data.map(user => (
      <li key={user.id}>{user.name}</li>
    ))}
  </ul>
)

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


Проблемы примитивных fallback-сценариев

Наивная реализация быстро приводит к проблемам:

Дублирование

Одинаковый код:

if (isPending)
if (isError)

появляется во множестве компонентов.


Глубокая вложенность

if (isPending) {
  return ...
}

if (isError) {
  return ...
}

if (!data.length) {
  return ...
}

Интерфейс становится трудно поддерживать.


Мерцание интерфейса

При background refetch контент может исчезать и появляться заново.


Потеря layout

Во время загрузки структура страницы ломается:

  • исчезают таблицы;
  • скачет высота блоков;
  • меняется сетка;
  • контент сдвигается.

Централизация fallback UI

Наиболее распространённый подход — создание обёртки.

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 Fallback UI

Skeleton-интерфейсы визуально повторяют структуру будущего контента.

Вместо:

<div>Загрузка...</div>

используется:

<UserCardSkeleton />

Пример:

function UserCardSkeleton() {
  return (
    <div className="card skeleton">
      <div className="avatar" />
      <div className="line" />
      <div className="line short" />
    </div>
  )
}

Skeleton UI считается более современным подходом, потому что:

  • сохраняет layout;
  • уменьшает визуальные скачки;
  • создаёт ощущение скорости;
  • снижает perceived latency.

Placeholder Data как fallback

TanStack Query поддерживает placeholderData.

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  placeholderData: [],
})

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

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

Однако placeholderData не кэшируется как полноценный результат.


Initial Data

initialData отличается от placeholderData.

useQuery({
  queryKey: ['settings'],
  queryFn: fetchSettings,
  initialData: defaultSettings,
})

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

initialData placeholderData
попадает в кэш не попадает
считается реальными данными временные данные
влияет на stale state не влияет

Fallback при Background Refetch

Очень важный сценарий.

Во время background refetch нельзя полностью скрывать интерфейс.

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

if (isFetching) {
  return <Spinner />
}

Это приведёт к мерцанию.

Правильный подход:

<>
  {isFetching && <SmallLoader />}
  
  <UsersTable users={data} />
</>

Основной контент сохраняется, а пользователь видит индикатор обновления.


Разделение Initial Loading и Refetch

TanStack Query предоставляет разные флаги:

Флаг Назначение
isPending первый запрос
isFetching любой fetch
isRefetching повторный fetch
isLoadingError ошибка первой загрузки
isRefetchError ошибка refetch

Пример:

if (query.isPending) {
  return <PageSkeleton />
}

return (
  <>
    {query.isRefetching && (
      <TopBarProgress />
    )}

    <Content data={query.data} />
  </>
)

Error Fallback UI

Ошибки требуют отдельной UX-стратегии.

Примитивный вариант:

if (isError) {
  return <div>Ошибка</div>
}

Недостатки:

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

Полноценный Error Component

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()}
    />
  )
}

Error Boundary и TanStack Query

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

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

Ошибка будет выброшена в Error Boundary.

Пример:

<ErrorBoundary fallback={<PageError />}>
  <ProfilePage />
</ErrorBoundary>

QueryErrorResetBoundary

Для корректного сброса ошибок используется специальный boundary.

<QueryErrorResetBoundary>
  {({ reset }) => (
    <ErrorBoundary
      onRe set={reset}
      fallbackRender={({ resetErrorBoundary }) => (
        <ErrorFallback
          onRe try={resetErrorBoundary}
        />
      )}
    >
      <Page />
    </ErrorBoundary>
  )}
</QueryErrorResetBoundary>

Это особенно важно при retry-сценариях.


Suspense Fallback UI

TanStack Query поддерживает React Suspense.

const query = useSuspenseQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
})

Компонент автоматически «приостанавливается».

Fallback задаётся через Suspense:

<Suspense fallback={<PostsSkeleton />}>
  <PostsPage />
</Suspense>

Преимущества Suspense Fallback

Suspense устраняет:

  • ручные if (isPending);
  • множественные проверки;
  • разрастание JSX;
  • дублирование loading UI.

Компонент получает уже готовые данные:

const { data } = useSuspenseQuery(...)

Недостатки Suspense

Несмотря на удобство, существуют ограничения.

Сложнее контролировать granular loading

Иногда нужен отдельный UI для:

  • initial loading;
  • refetch;
  • partial loading.

Suspense скрывает часть контроля.


Необходимость Error Boundary

Suspense почти всегда требует:

  • Suspense boundary;
  • Error boundary;
  • reset boundary.

Архитектура усложняется.


Nested Fallback UI

Большие приложения используют вложенные fallback-границы.

Пример:

<Suspense fallback={<PageSkeleton />}>
  <Dashboard>
    <Suspense fallback={<ChartSkeleton />}>
      <Chart />
    </Suspense>

    <Suspense fallback={<TableSkeleton />}>
      <Table />
    </Suspense>
  </Dashboard>
</Suspense>

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

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

Progressive Rendering

Fallback UI позволяет строить progressive rendering.

Например:

  1. сначала отображается layout;
  2. затем sidebar;
  3. затем таблица;
  4. затем графики;
  5. затем второстепенные данные.

Это создаёт ощущение быстрого интерфейса даже при медленном API.


Empty State как часть Fallback UI

Fallback — это не только loading и error.

Пустые данные тоже требуют отдельного интерфейса.

Плохой вариант:

return <ul></ul>

Правильный вариант:

if (data.length === 0) {
  return (
    <EmptyState
      title="Нет пользователей"
    />
  )
}

Типы Empty State

Полностью пустая система

Нет проектов

Результаты поиска отсутствуют

Ничего не найдено

Нет доступа

Недостаточно прав

Данные ещё не созданы

Создайте первый документ

Layout Preservation

Одна из ключевых задач fallback UI — сохранение структуры страницы.

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

if (isPending) {
  return <Spinner />
}

После загрузки интерфейс резко меняется.

Правильнее:

<div className="page">
  <Sidebar />

  <main>
    {isPending
      ? <ContentSkeleton />
      : <Content />}
  </main>
</div>

keepPreviousData как fallback-механизм

Очень полезная возможность для пагинации.

useQuery({
  queryKey: ['posts', page],
  queryFn: () => fetchPosts(page),
  placeholderData: keepPreviousData,
})

Во время переключения страниц:

  • старые данные остаются;
  • интерфейс не очищается;
  • таблица не мигает.

Optimistic UI как расширение Fallback Strategy

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 }
}

Offline Fallback

TanStack Query умеет работать в offline-сценариях.

Fallback может отображать:

<OfflineBanner />

или:

<RetryLaterMessage />

Глобальные fallback-индикаторы

Иногда требуется отображать состояние загрузки всего приложения.

Используется useIsFetching.

const fetching = useIsFetching()

Пример:

{fetching > 0 && <GlobalLoader />}

Глобальные mutation fallback

const mutating = useIsMutating()

Пример:

{mutating > 0 && (
  <SavingIndicator />
)}

Delayed Fallback UI

Мгновенное отображение spinner может ухудшать UX.

При быстрых запросах появляется неприятное мерцание.

Используется задержка:

const [visible, setVisible] =
  useState(false)

useEffect(() => {
  const timer = setTimeout(() => {
    setVisible(true)
  }, 300)

  return () => clearTimeout(timer)
}, [])

Если запрос завершился быстро — loader вообще не появится.


Комбинированные fallback-стратегии

Современные приложения обычно используют комбинацию:

Сценарий Решение
Initial loading Skeleton
Background refetch subtle loader
Empty state отдельный экран
Error retry UI
Pagination keepPreviousData
Suspense nested fallback
Offline offline banner
Mutation optimistic UI

Архитектура fallback-компонентов

Крупные проекты обычно выделяют:

components/
  fallback/
    ErrorView/
    Skeletons/
    EmptyState/
    OfflineBanner/
    LoadingOverlay/

Это обеспечивает:

  • переиспользование;
  • единый UX;
  • консистентный дизайн;
  • упрощение поддержки.

Паттерн Resource Wrapper

Иногда создаётся универсальный компонент:

<Resource
  query={usersQuery}
  loading={<UsersSkeleton />}
  error={(err) => (
    <ErrorView error={err} />
  )}
  empty={<EmptyUsers />}
>
  {(users) => (
    <UsersTable users={users} />
  )}
</Resource>

Такой подход существенно уменьшает количество условного рендера.


UX-проблемы неправильного fallback UI

Плохой fallback приводит к:

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

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


Best Practices

Не скрывать весь интерфейс при refetch

Основной контент должен оставаться доступным.


Использовать skeleton вместо spinner

Skeleton лучше сохраняет layout.


Разделять loading и refreshing

Это разные состояния.


Обрабатывать empty state отдельно

Пустой результат — это не ошибка.


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

Повторяющийся код должен быть вынесен.


Использовать Error Boundary

Особенно в Suspense-архитектуре.


Не злоупотреблять fullscreen loader

Полная блокировка интерфейса раздражает пользователей.


Сохранять layout стабильным

Высота и структура страницы не должны резко меняться.


Использовать optimistic updates

Это снижает perceived latency.


Показывать retry actions

Пользователь должен иметь возможность повторить запрос.