Структурування шару даних TanStack Query: від queryOptions до відкатів
Створіть шар даних TanStack Query крок за кроком: спільні параметри запиту, генератори ключів, селектори, сторінкування, попереднє завантаження даних, централізоване скасування та безпечні оптимістичні оновлення.
У проєктах, які використовують 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,
});
Компонент переробляється знову на основі вибраного результату. Якщо він обирає лише кількість, а сервер змінює ім’я одного контакту, кількість залишається незмінною, і компонент не змінюється. На екранах, де багато компонентів відображають один і той самий великий список, це допомагає уникнути безлічі зайвих переробок. Однак є один недолік: вбудована стрілка select є новою функцією при кожній переробці, тому вона виконується заново щоразу; для дорогих операцій перетворень її слід визначити поза межами компонента або зберегти у форматі memoization.
Параметризовані запити: кожен вхідний параметр має бути у ключі
Сторінка деталей потребує запиту, який залежить від 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, керування станами завантаження та помилок, а також для створення функціонального компонента форми.