Доступность интерфейсов напрямую связана с тем, как приложение загружает, обновляет и отображает данные. Библиотека TanStack Query управляет асинхронными запросами, кешированием, повторными запросами и состояниями загрузки, а значит влияет на поведение экранных дикторов, клавиатурную навигацию, восприятие изменений контента и стабильность DOM-структуры.
Неправильная интеграция может приводить к следующим проблемам:
Каждый query в TanStack Query проходит через набор состояний:
Эти состояния должны корректно отображаться в 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.
isPending и
isFetchingВ контексте accessibility различие особенно важно.
isPendingНачальная загрузка.
Контент ещё отсутствует.
if (isPending) {
return <Loader />
}
isFetchingФоновое обновление уже существующих данных.
{isFetching && (
<div aria-live="polite">
Данные обновляются
</div>
)}
При isFetching нельзя полностью скрывать текущий
контент, иначе:
Доступность тесно связана со стабильностью структуры документа.
Опасный подход:
if (isFetching) {
return <Loader />
}
return <Table />
Во время refetch таблица удаляется из DOM, а затем создаётся заново.
Проблемы:
Правильный подход:
<>
{isFetching && (
<div aria-live="polite">
Обновление данных...
</div>
)}
<Table />
</>
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-технологии не должны:
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"
Используется только для критичных сообщений.
TanStack Query автоматически повторяет запросы.
По умолчанию:
retry: 3
Это может создавать проблемы:
Для 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>
TanStack Query умеет автоматически обновлять данные:
refetchInterval: 5000
Проблема заключается в том, что контент может внезапно меняться каждые несколько секунд.
Последствия:
Нужно минимизировать агрессивные обновления.
Пример:
refetchInterval: (query) => {
if (document.hidden) {
return false
}
return 30000
}
keepPreviousDataОдна из важнейших accessibility-возможностей.
const query = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
placeholderData: keepPreviousData,
})
Преимущества:
Плохой UX:
if (isPending) {
return <Spinner />
}
При смене страницы список исчезает полностью.
Лучше:
<ul aria-busy={isFetching}>
{data.items.map(renderItem)}
</ul>
aria-busyСообщает assistive-технологиям, что содержимое обновляется.
useInfiniteQuery часто используется неправильно.
Пример:
const {
data,
fetchNextPage,
} = useInfiniteQuery(...)
Типичная ошибка — автоматическая подгрузка через IntersectionObserver без альтернативы.
Пользователь клавиатуры может:
Даже при автоматическом infinite scroll желательно оставлять доступную кнопку.
<button onCl ick={() => fetchNextPage()}>
Загрузить ещё
</button>
После добавления новой страницы:
<div aria-live="polite">
Загружено ещё 20 элементов
</div>
После 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 мгновенно меняют UI ещё до ответа сервера.
onMutate: async (newTodo) => {
queryClient.setQueryData(...)
}
Проблема:
Нужно сообщать о временном состоянии.
<div aria-live="polite">
Элемент сохраняется...
</div>
После rollback:
<div role="alert">
Не удалось сохранить изменения
</div>
TanStack Query поддерживает Suspense.
useSuspenseQuery(...)
Но Suspense может быть опасен для accessibility.
Причина:
Плохой пример:
<Suspense fallback={<PageLoader />}>
<EntirePage />
</Suspense>
Лучше:
<PageLayout>
<Sidebar />
<Suspense fallback={<PostsLoader />}>
<Posts />
</Suspense>
</PageLayout>
Prefetching улучшает не только производительность, но и восприятие интерфейса.
queryClient.prefetchQuery({
queryKey: ['post', id],
queryFn: fetchPost,
})
Преимущества:
TanStack Query умеет работать с offline-режимом.
networkMode: 'offlineFirst'
Важно уведомлять пользователя:
<div role="status">
Нет подключения к сети
</div>
Mutation часто сопровождаются toast-сообщениями.
Плохой toast:
toast.success('Сохранено')
Многие библиотеки toast по умолчанию не поддерживают accessibility.
Нужно проверять:
aria-live;Devtools не должны попадать в production.
Причины:
Нестабильные query key вызывают лишние перерисовки.
queryKey: ['posts', filters]
Если filters создаётся заново на каждом render:
const filters = {
sort: 'date'
}
возможны:
Решение:
const filters = useMemo(() => ({
sort: 'date'
}), [])
Ошибка API должна быть понятной.
Плохой пример:
Ошибка: 500
Лучше:
<div role="alert">
Сервер временно недоступен
</div>
Пример:
const mutation = useMutation({
mutationFn: saveProfile,
})
Во время отправки:
<button disabled={mutation.isPending}>
Сохранить
</button>
Дополнительно:
<form aria-busy={mutation.isPending}>
После успешного mutation:
<div aria-live="polite">
Профиль успешно сохранён
</div>
Инвалидация кеша может внезапно обновлять интерфейс.
queryClient.invalidateQueries({
queryKey: ['posts'],
})
Если обновление затрагивает большие области DOM:
Лучше обновлять только нужные данные.
queryClient.setQueryData(...)
вместо полного refetch.
При обновлении таблиц важно:
<table>;Плохой пример:
key={Math.random()}
Это приводит к полной пересборке DOM.
Если query зависит от сортировки:
queryKey: ['users', sort]
нужно уведомлять пользователя:
<div aria-live="polite">
Таблица отсортирована по имени
</div>
Поиск с query:
useQuery({
queryKey: ['search', term],
})
без debounce создаёт:
Решение:
const debouncedTerm = useDebounce(term, 500)
Пустое состояние должно быть информативным.
Плохо:
<div>Нет данных</div>
Лучше:
<div role="status">
По запросу ничего не найдено
</div>
При SSR и hydration важно избегать несоответствия DOM.
Иначе screen reader может:
Для этого применяются:
dehydrate;HydrationBoundary;Предпочтительнее:
Частые обновления нарушают UX assistive-технологий.
Особенно после:
Минимизировать:
Infinite scroll обязан иметь: