Accessibility considerations

Доступность интерфейсов напрямую связана с тем, как приложение загружает, обновляет и отображает данные. Библиотека TanStack Query управляет асинхронными запросами, кешированием, повторными запросами и состояниями загрузки, а значит влияет на поведение экранных дикторов, клавиатурную навигацию, восприятие изменений контента и стабильность DOM-структуры.

Неправильная интеграция может приводить к следующим проблемам:

  • экранный диктор не сообщает о загрузке данных;
  • пользователь теряет фокус после обновления интерфейса;
  • контент внезапно «прыгает» при refetch;
  • ошибки не озвучиваются;
  • skeleton-компоненты скрывают реальный контент;
  • автоматические обновления создают хаотичное поведение;
  • бесконечная прокрутка становится недоступной для клавиатуры.

Состояния запросов и доступность

Каждый query в TanStack Query проходит через набор состояний:

  • pending
  • success
  • error
  • fetching
  • stale
  • paused

Эти состояния должны корректно отображаться в DOM и быть понятными assistive-технологиям.

Простейший пример:

const {
  data,
  isPending,
  isError,
  error,
} = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
})

Доступные индикаторы загрузки

Одна из самых распространённых ошибок — отображение только визуального spinner без текстового описания.

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

if (isPending) {
  return <Spinner />
}

Экранный диктор не понимает, что происходит.

Корректный вариант:

if (isPending) {
  return (
    <div role="status" aria-live="polite">
      Загрузка данных...
    </div>
  )
}

Использование role="status"

Атрибут role="status" сообщает assistive-технологиям, что содержимое блока может обновляться динамически.

Использование aria-live

Режимы:

  • polite — дождаться паузы перед озвучиванием;
  • assertive — озвучить немедленно.

Для загрузки обычно используется polite.


Отличие isPending и isFetching

В контексте accessibility различие особенно важно.

isPending

Начальная загрузка.

Контент ещё отсутствует.

if (isPending) {
  return <Loader />
}

isFetching

Фоновое обновление уже существующих данных.

{isFetching && (
  <div aria-live="polite">
    Данные обновляются
  </div>
)}

При isFetching нельзя полностью скрывать текущий контент, иначе:

  • пользователь потеряет контекст;
  • screen reader воспримет это как удаление DOM;
  • клавиатурный фокус может исчезнуть.

Стабильность DOM

Доступность тесно связана со стабильностью структуры документа.

Опасный подход:

if (isFetching) {
  return <Loader />
}

return <Table />

Во время refetch таблица удаляется из DOM, а затем создаётся заново.

Проблемы:

  • потеря focus;
  • потеря scroll position;
  • повторное чтение страницы screen reader;
  • визуальные скачки.

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

<>
  {isFetching && (
    <div aria-live="polite">
      Обновление данных...
    </div>
  )}

  <Table />
</>

Skeleton UI и доступность

Skeleton-интерфейсы стали популярны благодаря perceived performance, однако они часто ломают accessibility.

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

<div className="skeleton-card" />

Screen reader получает пустой бессмысленный DOM.

Корректный вариант:

<div
  aria-hidden="true"
  className="skeleton-card"
/>

И дополнительно:

<div role="status" aria-live="polite">
  Загрузка списка пользователей
</div>

Почему aria-hidden важен

Skeleton не является реальным контентом.

Assistive-технологии не должны:

  • читать placeholder;
  • воспринимать skeleton как настоящий интерфейс;
  • перемещать по нему фокус.

Error Boundary и доступность

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

Пример:

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

В сочетании с React Error Boundary:

<ErrorBoundary fallback={<ErrorFallback />}>
  <PostsPage />
</ErrorBoundary>

Однако fallback-компонент обязан быть доступным.

Корректный пример:

function ErrorFallback() {
  return (
    <div role="alert">
      Не удалось загрузить данные
    </div>
  )
}

role="alert" для ошибок

Ошибки должны озвучиваться автоматически.

<div role="alert">
  Произошла ошибка соединения
</div>

role="alert" эквивалентен:

aria-live="assertive"

Используется только для критичных сообщений.


Retry-механизмы и UX

TanStack Query автоматически повторяет запросы.

По умолчанию:

retry: 3

Это может создавать проблемы:

  • screen reader будет многократно озвучивать ошибки;
  • интерфейс станет «мерцающим»;
  • пользователь не поймёт текущее состояние.

Для accessibility-sensitive интерфейсов retry часто настраивается вручную:

retry: (failureCount, error) => {
  if (error.status === 404) {
    return false
  }

  return failureCount < 2
}

Доступные кнопки повторной загрузки

Пример:

<button onCl ick={() => refetch()}>
  Повторить запрос
</button>

Недостаточно.

Лучше:

<button
  onCl ick={() => refetch()}
  aria-label="Повторно загрузить список статей"
>
  Обновить
</button>

Автоматический refetch и screen reader

TanStack Query умеет автоматически обновлять данные:

refetchInterval: 5000

Проблема заключается в том, что контент может внезапно меняться каждые несколько секунд.

Последствия:

  • screen reader постоянно теряет контекст;
  • focus может перескакивать;
  • пользователь не успевает взаимодействовать с элементами.

Безопасный polling

Нужно минимизировать агрессивные обновления.

Пример:

refetchInterval: (query) => {
  if (document.hidden) {
    return false
  }

  return 30000
}

keepPreviousData

Одна из важнейших accessibility-возможностей.

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

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

  • DOM остаётся стабильным;
  • фокус не сбрасывается;
  • таблица не исчезает;
  • screen reader не начинает чтение заново.

Пагинация и доступность

Плохой UX:

if (isPending) {
  return <Spinner />
}

При смене страницы список исчезает полностью.

Лучше:

<ul aria-busy={isFetching}>
  {data.items.map(renderItem)}
</ul>

aria-busy

Сообщает assistive-технологиям, что содержимое обновляется.


Infinite Query и клавиатурная навигация

useInfiniteQuery часто используется неправильно.

Пример:

const {
  data,
  fetchNextPage,
} = useInfiniteQuery(...)

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

Пользователь клавиатуры может:

  • не понять, что появились новые элементы;
  • не иметь возможности инициировать загрузку;
  • потерять текущую позицию.

Кнопка загрузки следующей страницы

Даже при автоматическом infinite scroll желательно оставлять доступную кнопку.

<button onCl ick={() => fetchNextPage()}>
  Загрузить ещё
</button>

Объявление новых данных

После добавления новой страницы:

<div aria-live="polite">
  Загружено ещё 20 элементов
</div>

Focus management после обновлений

После mutation интерфейс часто меняется динамически.

Пример:

const mutation = useMutation({
  mutationFn: createPost,
})

После успешного создания записи важно корректно управлять фокусом.

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

mutation.mutate(data)
navigate('/posts')

Фокус может оказаться в непредсказуемом месте.

Лучше:

useEffect(() => {
  if (mutation.isSuccess) {
    headingRef.current?.focus()
  }
}, [mutation.isSuccess])

Фокусируемые заголовки

<h1 tabIndex={-1} ref={headingRef}>
  Статья создана
</h1>

Это помогает screen reader сразу перейти к новому контенту.


Optimistic Updates и доступность

Optimistic updates мгновенно меняют UI ещё до ответа сервера.

onMutate: async (newTodo) => {
  queryClient.setQueryData(...)
}

Проблема:

  • screen reader может озвучить контент, который позже исчезнет;
  • пользователь не поймёт откат состояния.

Доступный optimistic UI

Нужно сообщать о временном состоянии.

<div aria-live="polite">
  Элемент сохраняется...
</div>

После rollback:

<div role="alert">
  Не удалось сохранить изменения
</div>

Suspense и accessibility

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

useSuspenseQuery(...)

Но Suspense может быть опасен для accessibility.

Причина:

  • fallback полностью заменяет subtree;
  • DOM уничтожается;
  • screen reader теряет контекст.

Ограничение области Suspense

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

<Suspense fallback={<PageLoader />}>
  <EntirePage />
</Suspense>

Лучше:

<PageLayout>
  <Sidebar />

  <Suspense fallback={<PostsLoader />}>
    <Posts />
  </Suspense>
</PageLayout>

Prefetch и perceived accessibility

Prefetching улучшает не только производительность, но и восприятие интерфейса.

queryClient.prefetchQuery({
  queryKey: ['post', id],
  queryFn: fetchPost,
})

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

  • меньше резких загрузок;
  • меньше spinner;
  • стабильнее DOM;
  • меньше неожиданных изменений.

Offline-состояния

TanStack Query умеет работать с offline-режимом.

networkMode: 'offlineFirst'

Важно уведомлять пользователя:

<div role="status">
  Нет подключения к сети
</div>

Доступные toast-уведомления

Mutation часто сопровождаются toast-сообщениями.

Плохой toast:

toast.success('Сохранено')

Многие библиотеки toast по умолчанию не поддерживают accessibility.

Нужно проверять:

  • наличие aria-live;
  • доступность клавиатурой;
  • возможность закрытия;
  • корректный focus management.

React Query Devtools и accessibility

Devtools не должны попадать в production.

Причины:

  • лишние focusable-элементы;
  • загрязнение accessibility tree;
  • возможные конфликты tab order.

Стабильные query keys

Нестабильные query key вызывают лишние перерисовки.

queryKey: ['posts', filters]

Если filters создаётся заново на каждом render:

const filters = {
  sort: 'date'
}

возможны:

  • повторные refetch;
  • DOM updates;
  • лишние aria announcements.

Решение:

const filters = useMemo(() => ({
  sort: 'date'
}), [])

Accessibility и серверные ошибки

Ошибка API должна быть понятной.

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

Ошибка: 500

Лучше:

<div role="alert">
  Сервер временно недоступен
</div>

Доступность форм с mutation

Пример:

const mutation = useMutation({
  mutationFn: saveProfile,
})

Во время отправки:

<button disabled={mutation.isPending}>
  Сохранить
</button>

Дополнительно:

<form aria-busy={mutation.isPending}>

Озвучивание успешных операций

После успешного mutation:

<div aria-live="polite">
  Профиль успешно сохранён
</div>

Accessibility и cache invalidation

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

queryClient.invalidateQueries({
  queryKey: ['posts'],
})

Если обновление затрагивает большие области DOM:

  • screen reader может потерять позицию;
  • focus может исчезнуть;
  • текущий элемент станет недоступным.

Частичная инвалидация

Лучше обновлять только нужные данные.

queryClient.setQueryData(...)

вместо полного refetch.


Доступность таблиц

При обновлении таблиц важно:

  • не уничтожать <table>;
  • не менять порядок строк без необходимости;
  • использовать стабильные key.

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

key={Math.random()}

Это приводит к полной пересборке DOM.


Доступность при сортировке

Если query зависит от сортировки:

queryKey: ['users', sort]

нужно уведомлять пользователя:

<div aria-live="polite">
  Таблица отсортирована по имени
</div>

Accessibility и debounce

Поиск с query:

useQuery({
  queryKey: ['search', term],
})

без debounce создаёт:

  • постоянные refetch;
  • частые aria announcements;
  • хаотичные обновления.

Решение:

const debouncedTerm = useDebounce(term, 500)

Доступность пустых состояний

Пустое состояние должно быть информативным.

Плохо:

<div>Нет данных</div>

Лучше:

<div role="status">
  По запросу ничего не найдено
</div>

Accessibility и cache hydration

При SSR и hydration важно избегать несоответствия DOM.

Иначе screen reader может:

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

Для этого применяются:

  • dehydrate;
  • HydrationBoundary;
  • стабильная серверная разметка.

Основные accessibility-принципы при работе с TanStack Query

Не удалять контент во время refetch

Предпочтительнее:

  • background fetching;
  • keepPreviousData;
  • placeholderData.

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

Частые обновления нарушают UX assistive-технологий.

Управлять focus после mutation

Особенно после:

  • navigation;
  • optimistic updates;
  • modal close;
  • form submit.

Использовать aria-live осознанно

  • polite — для обычных обновлений;
  • assertive — только для критических ошибок.

Сохранять стабильность DOM

Минимизировать:

  • remount;
  • смену key;
  • уничтожение subtree.

Проверять infinite scroll

Infinite scroll обязан иметь:

  • keyboard fallback;
  • announcements;
  • доступную кнопку загрузки.