Структураванне шара дадзеных TanStack Query, ад параметраў запитоў да можлівасцяў анулявання.
Па кроках створыце шар дадзеных 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) вяртаеся з правым типам без неабходнасці ручнага выкарыстоўвання гэнерыкі. Другая — можлівасць складання: вы можете распрастарыць об’ект і зменіць або дадаць поля па кожнам месца вызову, як гэта робіць другi компонент з 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 у формате inline ў кожны раз працюе як новая функцыя, таму ёй трэба запускатыся занова ў кожны момент; для дорогіх перетвароўкаў яе трэба задаць за межамі компаненты або зберагчы ў памяці.
Параметрызаваныя запиты: кожны параметр павінен быць частью ключа
Старонка з деталямі патрабуе запита, які залежыць ад 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} />;
}
Правіла, які запобiegаюць адзінам з найбольш частых багоў у працэйным сістэмам: кожная зменна, яку викорыстоўвае функцыя запиту, павінна быць прынятая ў ключы. Якшчо не включыць 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 дапамагае лёгка адключыць кнопку, калі запыт яшчэ выкананы.
Адзначэнне таго, што анулюе мутацыя
Пасля збіркі паведамленняяў неабходна перзапрацаваць афектаваныя запиты. У змене можна задаць ў 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-кліента; паведамленне 'Network Error' генеруецца праграмай 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, адколі список можа ўжо не быць кэшаваным.
Адліквідацыя стану загрузкі
Функцыя useSuspenseQuery гарантуе, што значэнне data ўжо визначана, і перадае адпаведную обработку на межу React Suspense.
// 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, каб калбэкі для кожнай мутацыі не перакрылі іх без паведамлення. - Для простых сцэнарыяў скрытнага адображэння выбірайце оптымізм на рывень UI, а калі многі компоненты чытаюць дадзеныя — оптымізм на рывень кэша, з функціямі стварэння копій і вярнення да пачатковага стану.
Супакойлена літэратура
- React Query і Redux: Наватарэнне падходу да керавання станам сервера ў вялікіх дапрацоўках — Дазвольце вам дазнацца, чымі кераваўся продакшн-чатовы дапрацоўкі, выбрав TanStack Query заместа Redux для керавання дадзеннямі сэрвера, а таксама дзе ў сучасной архітектуры React все ж такі застаецца Redux.
- Адправка дадзенняў за дапамою RTK Query: Практычны падход да мутацый — Дазвольце вам дазнацца, як выкарыстоўваць builder.mutation() у RTK Query для адправкі запытоў типу POST, керавання станамі завантажэння і адзёркі, а таксама для стварэння функцыональнага компонента формы.