Структурирование слоя данных TanStack Query: от queryOptions до возможностей отката
Построим слой данных TanStack Query пошагово: общие параметры запроса queryOptions, генераторы ключей, селекторы, пагинация, предзагрузка данных, централизованное аннулирование и безопасные оптимистичные обновления.
В проектах, использующих TanStack Query (ранее React Query), часто накапливаются дублирующиеся ключи, забытые операции аннулирования и индикаторы загрузки во всех уголках кода. Это происходит не из-за самой библиотеки, а из-за отсутствия четкой структуры. В этом руководстве создается одна функциональность — экран с контактами — начиная с одного запроса и заканчивая небольшим слоем данных с общими настройками, генераторами ключей, автоматическим аннулированием и оптимистичными операциями удаления, при этом указываются подводные камни, скрытые каждым из этих подходов. Если вы все еще не решили, где именно должен храниться серверный состояние, наша статья «React Query и Redux для управления серверным состоянием» сначала отвечает на этот вопрос.
Самый простой полезный запрос
Для запроса требуются ключ и функция. Этот компонент загружает контакты и обрабатывает состояния ожидания и ошибок.
import { useQuery } from '@tanstack/react-query';
import { getContacts } from './api/client';
function ContactsTable() {
const { data, isPending, isError, refetch } = useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
if (isPending) return <LoadingSpinner />;
if (isError) return <ErrorAlert onRetry={refetch} />;
return <Table data={data} />;
}
queryKey — это не метка. Это идентификатор записи в кэше: все компоненты, запрашивающие ['contacts'], получают одни и те же данные, проходят одну и ту же обработку дублирования запросов и имеют одинаковую автоматическую перезагрузку данных на фоне. Практически все приведённые ниже подходы сводятся к правильному управлению ключами.
Общее использование определений запросов
Заключение повторяющихся запросов в пользовательский хук
Когда двум компонентам нужны одни и те же данные, пользовательский хук хранит единственное определение запроса, оставляя компоненты только для отображения.
// queries/contacts.ts
export function useContacts() {
return useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
}
// Component
function ContactsTable() {
const { data, isPending, isError } = useContacts();
// Clean, focused component logic
}
В первую очередь используйте объекты queryOptions вместо хуков
Хук можно вызвать только внутри компонента. Обычный объект параметров, созданный с помощью queryOptions, может использоваться хуками, функцией prefetchQuery, функцией getQueryData и загрузчиками маршрутов.
import { queryOptions } from '@tanstack/react-query';
export const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
});
У этого подхода есть два преимущества. Во-первых, инференция типов: теги queryOptions присваивают ключу тип данных запроса, поэтому вызов queryClient.getQueryData(contactsQueryOptions.queryKey) возвращает значение с указанным типом без необходимости использования генериков вручную. Во-вторых, возможность комбинирования: можно распространить объект и переопределить или добавить поля в зависимости от места вызова, как это делает второй компонент с помощью метода select.
// Use directly
function ContactsList() {
const { data } = useQuery(contactsQueryOptions);
return <List items={data} />;
}
// Extend with custom selectors
function ContactsCount() {
const { data } = useQuery({
...contactsQueryOptions,
select: (contacts) => contacts.length,
});
return <Badge count={data} />;
}
Эффективное чтение данных
Селекторы, ограничивающие повторную отрисовку
select преобразует данные из кэша ещё до того, как они попадут в компонент, что также способствует оптимизации процесса отрисовки. Кроме того, он может находиться в общих параметрах:
const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
select: (data) => data.length,
});
Компонент перерисовывается в зависимости от отобранного результата. Если выбирается только количество, а сервер меняет имя одного контакта, количество остается прежним, и компонент не меняется. На экранах, где множество компонентов отображают один и тот же большой список, это позволяет избежать множества ненужных перерисовок. Однако есть один недостаток: встроенная стрелка выбора создает новую функцию при каждой перерисовке, поэтому она выполняется заново каждый раз; для ресурсоемких преобразований её следует определить вне компонента или сделать мемоизированной.
Параметризованные запросы: каждый параметр должен находиться в ключе
Страница с деталями требует запроса, зависящего от ID. Функция-фабрика, возвращающая параметры, помогает сохранять порядок.
export const contactQueryOptions = (contactId: string) =>
queryOptions({
queryKey: ['contacts', contactId],
queryFn: () => getContact(contactId),
});
// Usage
function ContactPage() {
const { id } = useParams();
const { data } = useQuery(contactQueryOptions(id));
return <ContactDetails contact={data} />;
}
Правило, предотвращающее одну из наиболее распространенных ошибок в производственной среде: каждая переменная, используемая функцией запроса, должна присутствовать в ключе. Если исключить contactId, кэш будет рассматривать все контакты как одну запись, в результате чего пользователь может временно увидеть данные другого человека. При использовании маршрутизатора также следует учитывать, что значение id может быть undefined; опция enabled позволяет сохранять запрос до тех пор, пока это значение не появится.
Пагинация — это параметризованный запрос с учетом состояния
Для реализации пагинации не требуется ничего особенного: номер страницы включается в ключ, а изменение его в состоянии приводит к созданию нового запроса.
export const paginatedContactsOptions = (page: number, pageSize: number) =>
queryOptions({
queryKey: ['contacts', 'paginated', page, pageSize],
queryFn: () => getContacts({ page, pageSize }),
});
function ContactsTable() {
const [page, setPage] = useState(1);
const { data } = useQuery(paginatedContactsOptions(page, 10));
return (
<>
<Table data={data.items} />
<Pagination
currentPage={page}
onNext={() => setPage(p => p + 1)}
/>
</>
);
}
Для повторного загруза никаких действий не требуется; новый ключ означает лишь новый запрос. На практике важны два улучшения. При первой отрисовке значение data равно undefined, поэтому для data.items необходима защита от ошибок. Кроме того, при каждой смене страницы новый ключ сначала пуст, из-за чего таблица мгновенно обновляется; установка параметра placeholderData: keepPreviousData позволяет сохранять предыдущую страницу видимой во время загрузки следующей.
Загружайте заранее страницу, которую пользователи попросят в следующий раз
Сочетая это с функцией предзагрузки, следующая страница обычно готова ещё до нажатия на неё.
function ContactsTable() {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data } = useQuery(paginatedContactsOptions(page, 10));
useEffect(() => {
// Silently load the next page in the background
queryClient.prefetchQuery(
paginatedContactsOptions(page + 1, 10)
);
}, [page, queryClient]);
return <Table data={data} />;
}
prefetchQuery заполняет кэш без подключения компонента. Когда пользователь переходит на следующую страницу, запрос получает свежие данные и сразу же их отображает. Этот метод также работает при наведении курсора или до загрузки маршрута, но следует использовать его целенаправленно: каждая предзагрузка является реальным запросом.
Бесконечные списки с курсорами
Для функции «загрузить ещё» или бесконечного прокручивания метод useInfiniteQuery хранит список страниц и отслеживает курсор за вас. Вы описываете, как найти следующий курсор в функции getNextPageParam, а библиотека занимается всеми расчётами.
export const infiniteContactsOptions = queryOptions({
queryKey: ['contacts', 'infinite'],
queryFn: ({ pageParam }) => getContacts({ cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
function InfiniteContactsList() {
const {
data,
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery(infiniteContactsOptions);
return (
<>
{data.pages.map(page =>
page.items.map(contact => (
<ContactCard key={contact.id} {...contact} />
))
)}
<button onClick={() => fetchNextPage()}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</>
);
}
Обратите внимание, что в TanStack Query v5 специальный помощник для этой задачи — это infiniteQueryOptions, который присваивает полям бесконечного прокручивания правильные типы; проверьте текущую документацию, если queryOptions отклоняет их. Также используйте метод hasNextPage, чтобы скрыть кнопку, как только getNextPageParam вернёт значение undefined.
Сохранение согласованности ключей с помощью фабрики
Вручную создаваемые ключи могут отклоняться: в одном файле записано ['contacts', 'list'], в другом — ['contact', 'lists'], и механизм обновления может это проигнорировать. Фабрика ключей описывает иерархию один раз.
export const contactKeys = {
all: ['contacts'] as const,
lists: () => [...contactKeys.all, 'list'] as const,
list: (filters: ContactFilters) =>
[...contactKeys.lists(), filters] as const,
details: () => [...contactKeys.all, 'detail'] as const,
detail: (id: string) =>
[...contactKeys.details(), id] as const,
};
// Usage in queries
export const contactQueryOptions = (id: string) =>
queryOptions({
queryKey: contactKeys.detail(id),
queryFn: () => getContact(id),
});
// Surgical cache invalidation
queryClient.invalidateQueries({
queryKey: contactKeys.all
}); // Invalidates everything
queryClient.invalidateQueries({
queryKey: contactKeys.lists()
}); // Only list queries
Поскольку ключи совпадают по префиксу, иерархия позволяет аннулировать действия как в широком, так и в узком объеме: contactKeys.all обновляет всю информацию о контактах, в то время как contactKeys.lists() затрагивает только запросы к спискам и оставляет данные из кэша нетронутыми.
Изменение данных с помощью мутаций
Базовый хук для мутации
Записи проходят через функцию useMutation. Размещение её в хуке позволяет сохранять побочные эффекты, такие как уведомления, рядом с самим запросом.
export function useDeleteContact() {
return useMutation({
mutationFn: (contactId: string) => deleteContact(contactId),
onSuccess: () => {
toast.success('Contact deleted successfully');
},
onError: () => {
toast.error('Failed to delete contact');
},
});
}
// Usage in components
function ContactCard({ contact }) {
const { mutate, isPending } = useDeleteContact();
return (
<Card>
<h3>{contact.name}</h3>
<button
onClick={() => mutate(contact.id)}
disabled={isPending}
>
{isPending ? 'Deleting...' : 'Delete'}
</button>
</Card>
);
}
onSuccess, onError и onSettled выполняются на соответствующих этапах; функция isPending упрощает отключение кнопки во время обработки запроса.
Определение того, что аннуливается мутацией
После записи необходимо повторно загрузить затронутые запросы. Вместо того чтобы вызывать invalidateQueries в каждом хуке, мутация может указать свои цели в поле meta, и один глобальный обработчик сможет воздействовать на них.
// In your mutation
export function useDeleteContact() {
return useMutation({
mutationFn: (contactId: string) => deleteContact(contactId),
meta: {
invalidates: [contactKeys.all],
},
});
}
// Global setup (one time, in main.tsx)
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onSettled: async (data, error, variables, context) => {
const meta = context?.meta;
if (meta?.invalidates) {
await Promise.all(
meta.invalidates.map((queryKey) =>
queryClient.invalidateQueries({ queryKey })
)
);
}
},
},
},
});
Идея хороша, но необходимо проверить подключение проводов с учётом вашей версии. В показанной подписи обратного вызова четвёртым аргументом является значение, возвращаемое из onMutate, а не сама мутация, поэтому context?.meta не сможет найти объявленные ключи. Кроме того, мутация, определяющая собственный метод onSettled, заменяет этот стандартный метод вместо того, чтобы работать параллельно с ним. Более надёжным местом для обработчика является MutationCache, передаваемый в QueryClient: его обратные вызовы получают объект мутации (с mutation.meta) и всегда выполняются в дополнение к обратным вызовам, связанным с конкретной мутацией. В TypeScript для указания типа meta.invalidates требуется зарегистрировать пользовательский тип Meta.
Глобальная обработка ошибок
Проблемы, возникающие в разных частях приложения, такие как истёкший срок действия сессии, также должны находиться в одном месте.
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Handle authentication globally
if (error.status === 401) {
logout();
navigate('/login');
}
// Handle network errors
if (error.message === 'Network Error') {
toast.error('Check your connection');
}
},
},
},
});
Каждая мутация реагирует одинаково на код ошибки 401 или сбой сети. Действует та же оговорка относительно переопределения: функция useDeleteContact, описанная выше, определяет собственный метод onError, который заменяет этот стандартный вариант, поэтому метод onError из MutationCache снова остается надежным выбором. Также обратите внимание, что значение error.status и сообщение «Network Error» зависят от используемого HTTP-клиента; последнее — это сообщение, генерируемое Axios, в то время как функция fetch выбрасывает исключение TypeError.
Оптимистичные обновления
В режиме оптимистичных обновлений интерфейс отображает результат записи еще до подтверждения сервером. Существует два уровня реализации.
Уровень интерфейса: скрытие элементов во время ожидания их удаления
useMutationState позволяет отслеживать в процессе выполнения мутации в любой части структуры. Фильтрация по ключу и статусу позволяет выявить ID элементов, которые в настоящее время удаляются, и список может просто скрыть их.
function useContactsBeingDeleted() {
return useMutationState({
filters: {
mutationKey: ['deleteContact'],
status: 'pending'
},
select: (mutation) => mutation.state.variables,
});
}
function ContactsList() {
const { data: contacts } = useContacts();
const deletingIds = useContactsBeingDeleted();
// Filter out contacts being deleted
const visibleContacts = contacts.filter(
c => !deletingIds.includes(c.id)
);
return <List items={visibleContacts} />;
}
В кэше ничего не меняется, поэтому при сбое запроса элемент снова появляется сам по себе. Это сопоставление основано на том, что мутация содержит mutationKey: ['deleteContact'], который ранее не был установлен хуком; его необходимо добавить, иначе фильтр ничего не найдет.
На уровне кэша: редактирование кэша и откат при сбое
Более тщательный подход заключается в прямом редактировании кэшируемого списка, так что все компоненты, которые его читают, обновляются одновременно.
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteContact,
onMutate: async (contactId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: contactKeys.lists()
});
// Snapshot current value
const previousContacts = queryClient.getQueryData(
contactKeys.lists()
);
// Optimistically update
queryClient.setQueryData(
contactKeys.lists(),
(old) => old.filter(c => c.id !== contactId)
);
// Return rollback data
return { previousContacts };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousContacts) {
queryClient.setQueryData(
contactKeys.lists(),
context.previousContacts
);
}
},
onSettled: () => {
// Always refetch for consistency
queryClient.invalidateQueries({
queryKey: contactKeys.lists()
});
},
});
}
Порядок действий имеет значение. Метод cancelQueries прерывает любые текущие запросы на обновление, чтобы они не перезаписали результат оптимистичных изменений. Снимок состояния возвращается из метода onMutate, чтобы метод onError мог его восстановить. Метод onSettled снова запрашивает данные независимо от того, успешен запрос или нет, так что кэш в итоге совпадает с данными на сервере. Обратите также внимание на ключи: методы getQueryData и setQueryData совпадают полностью, поэтому если ваши списки хранятся в кэше под ключом contactKeys.list(filters), обращение к contactKeys.lists() ничего не изменит; метод setQueriesData обновляет все записи, соответствующие данному префиксу. Также необходимо защищать переменную old, поскольку список еще может не находиться в кэше.
Использование Suspense для состояний загрузки
Метод useSuspenseQuery гарантирует, что переменная data определена, и передает управление процессом ожидания в механизм Suspense библиотеки React.
// Change from useQuery to useSuspenseQuery
function ContactsList() {
const { data } = useSuspenseQuery(contactsQueryOptions);
// No isPending check needed!
return <Table data={data} />;
}
function ContactDetails({ id }) {
const { data } = useSuspenseQuery(contactQueryOptions(id));
return <Details contact={data} />;
}
// Centralized loading UI
function App() {
return (
<Suspense fallback={<AppSkeleton />}>
<ContactsList />
<ContactDetails id="123" />
</Suspense>
);
}
Один границы заменяет несколько элементов-индикаторов загрузки на один общий структурный элемент. Компромисс заключается в уровне детализации: такая граница ждёт самого медленного элемента, поэтому её следует размещать там, где совокупное состояние загрузки действительно имеет смысл, а также предзагружать данные для избежания серии запросов.
Сочетание шаблонов
Вот функция управления контактами с объединёнными компонентами: фабрика ключей, параметризованные опции, операция удаления, объявляющая об аннулировании данных и выполняющая оптимистичную обновлённую операцию, а также список, который приостанавливает работу и предзагружает данные.
// queries/contacts.ts
export const contactKeys = {
all: ['contacts'] as const,
lists: () => [...contactKeys.all, 'list'] as const,
list: (filters: Filters) => [...contactKeys.lists(), filters] as const,
detail: (id: string) => [...contactKeys.all, id] as const,
};
export const contactsQueryOptions = (filters: Filters) =>
queryOptions({
queryKey: contactKeys.list(filters),
queryFn: () => getContacts(filters),
});
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteContact,
mutationKey: ['deleteContact'],
meta: { invalidates: [contactKeys.all] },
onMutate: async (contactId) => {
await queryClient.cancelQueries({
queryKey: contactKeys.lists()
});
const previous = queryClient.getQueryData(
contactKeys.lists()
);
queryClient.setQueryData(
contactKeys.lists(),
(old) => old?.filter(c => c.id !== contactId)
);
return { previous };
},
onError: (err, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
contactKeys.lists(),
context.previous
);
}
},
});
}
// components/ContactsList.tsx
function ContactsList() {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data } = useSuspenseQuery(
contactsQueryOptions({ page, pageSize: 20 })
);
const { mutate: deleteContact } = useDeleteContact();
// Prefetch next page
useEffect(() => {
queryClient.prefetchQuery(
contactsQueryOptions({ page: page + 1, pageSize: 20 })
);
}, [page, queryClient]);
return (
<Table
data={data.items}
onDelete={deleteContact}
pagination={{ page, onChange: setPage }}
/>
);
}
Результат получается типизированным от начала до конца, быстрым, причём правила кэширования хранятся в одном модуле. Следует обратить внимание на то, что при использовании useSuspenseQuery изменение значения page снова приводит к приостановке работы, и фоновый вариант отображается при каждой смене страницы; обёртка функции setPage в startTransition позволяет сохранять текущую страницу на экране во время загрузки следующей.
Основные выводы
- Помещайте каждый входной параметр запроса в соответствующий ключ; это единственное правило предотвращает большинство ошибок кэша.
- Определяйте запросы как объекты
queryOptions, чтобы хуки, предзагрузка и чтение из кэша использовали один и тот же типизированный источник. - Заранее внедрите механизм генерации ключей; сопоставление по префиксу позволяет точно выполнять аннулирование данных.
- Централизуйте процессы аннулирования данных и обработки ошибок, желательно в обратных вызовах
MutationCache, чтобы обратные вызовы для отдельных изменений не могли тайно их переопределить. - Для простого скрытия элементов используйте оптимизм на уровне интерфейса, а когда множество компонентов читает данные — оптимизм на уровне кэша с функциями создания снимков и возврата к предыдущему состоянию.
Связанные материалы
- React Query и Redux: переосмысление состояния сервера в крупных приложениях — Узнайте, почему производственное чат-приложение использовало TanStack Query вместо Redux для управления серверными данными, и где Redux всё ещё находит своё место в современной архитектуре React.
- Отправка данных с RTK Query: практическое руководство по мутациям — Узнайте, как использовать метод builder.mutation() в RTK Query для отправки POST-запросов, управления состояниями загрузки и ошибок, а также для создания рабочего компонента формы.