Strukturyzowanie warstwy danych TanStack Query, od queryOptions do funkcji Rollbacks
Buduj warstwę danych TanStack Query krok po kroku: wspólne queryOptions, generatorzy kluczy, selektory, paginacja, wcześniejsze pobieranie danych, centralne unieważnianie oraz bezpieczne aktualizacje optymistyczne.
Bazy kodu TanStack Query (wcześniej React Query) mają tendencję do gromadzenia się duplikowanych kluczy, zapomnianych procedur unieważniania danych oraz elementów w postaci wirujących ikonek wszędzie, nie z powodu samej biblioteki, lecz z powodu braku odpowiedniej struktury. Ten przewodnik buduje jedną funkcjonalność – ekran z kontaktemi – od pojedynczego zapytania aż po małą warstwę danych z wspólnymi opcjami, generatorami kluczy, automatycznym unieważnianiem danych oraz mechanizmem optymistycznych usuwań, wskazując przy tym na pułapki, które kryją się za każdym z tych podejść. Jeśli nadal zastanawiasz się, gdzie powinien znajdować się stan serwera, nasze porównanie React Query i Redux do zarządzania stanem serwera odpowiada na to pytanie jako pierwsze.
Najmniejsze użyteczne zapytanie
Zapytanie wymaga klucza oraz funkcji. Ten komponent pobiera kontakty i obsługuje stany oczekiwania oraz błędów.
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 nie jest etykietą. To identyfikator wpisu w pamięci cache: każdy komponent, który żąda ['contacts'], korzysta z tych samych danych, ma taką samą deduplikację żądań oraz dokonuje takiego samego tła ponownego pobierania danych. Prawie każdy z poniższych wzorów dotyczy właśnie prawidłowego zarządzania kluczami.
Dzielenie się definicjami zapytań
Otocz powtarzające się zapytania własnym hookiem
Gdy dwa komponenty potrzebują tych samych danych, własny hook przechowuje jedną definicję, pozostawiając komponenty wyłącznie do prezentacji.
// queries/contacts.ts
export function useContacts() {
return useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
}
// Component
function ContactsTable() {
const { data, isPending, isError } = useContacts();
// Clean, focused component logic
}
Niech preferowane będą obiekty queryOptions zamiast hooków
Hook może być wywoływany tylko wewnątrz komponentu. Zwykły obiekt opcji utworzony za pomocą queryOptions może być używany przez hooki, prefetchQuery, getQueryData oraz ładowacze tras.
import { queryOptions } from '@tanstack/react-query';
export const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
});
Ma dwie zalety. Po pierwsze, inferencja typów: tagi queryOptions przypisują kluczowi typ danych zapytania, dzięki czemu queryClient.getQueryData(contactsQueryOptions.queryKey) zwraca wartość już z określonym typem, bez konieczności ręcznego użycia generyków. Po drugie, możliwość kompozycji: można rozszerzyć obiekt i zmienić lub dodać pola w zależności od miejsca wywołania, tak jak robi to drugi komponent przy użyciu 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} />;
}
Efektywne odczytywanie danych
Selektory ograniczające ponowne renderowanie
select przekształca dane z pamięci cache przed ich dotarciem do komponentu, a jednocześnie służy do optymalizacji renderowania. Może również znajdować się w wspólnych opcjach:
const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
select: (data) => data.length,
});
Komponent jest renderowany ponownie na podstawie wybranego wyniku. Jeśli wybierze się tylko liczbę, a serwer zmieni nazwę jednego kontaktu, liczba pozostaje niezmieniona i komponent nie ulega aktualizacji. Na ekranach, gdzie wiele komponentów wyświetla tę samą dużą listę, zapobiega to licznym niepotrzebnym renderom. Jedna uwaga: strzałka do wyboru umieszczona w tekście jest nową funkcją przy każdym renderze, więc jest wykonywana za każdym razem; w przypadku kosztownych transformacji należy ją zdefiniować poza komponentem lub użyć mechanizmu memoizacji.
Zapytania parametryzowane: każdy wprowadzany element powinien znaleźć się w kluczu
Strona szczegółowa wymaga zapytania, które zależy od ID. Funkcja fabryczna zwracająca opcje pomaga utrzymać porządek.
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} />;
}
Zasada zapobiegająca jednemu z najczęstszych błędów w produkcji: każda zmienna używana przez funkcję zapytania musi pojawić się w kluczu. Jeśli pominąć contactId, cache traktuje wszystkie kontakty jako jedną pozycję, przez co użytkownik może chwilowo zobaczyć dane innej osoby. Przy użyciu routera należy również wziąć pod uwagę, że id może mieć wartość undefined; opcja enabled pozwala przechowywać zapytanie do chwili, aż ta wartość zostanie ustalona.
Paginacja to zapytanie parametryzowane plus stan
Paginacja nie wymaga niczego specjalnego: numer strony trafia do klucza, a zmiana go w stanie tworzy nowe zapytanie.
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)}
/>
</>
);
}
Nie jest konieczne ponowne pobieranie danych; nowy klucz oznacza po prostu nowe zapytanie. W praktyce istotne są dwa ulepszenia. Podczas pierwszej renderizacji wartość data jest undefined, więc data.items wymaga ochrony. Ponadto przy każdej zmianie strony nowy klucz jest pusty, przez co tabela na chwilę znika; ustawienie placeholderData: keepPreviousData pozwala utrzymać poprzednią stronę widoczną podczas ładowania następnej.
Pobieraj z góry stronę, o którą będą prosić użytkownicy
Połącz to z funkcją pobierania danych z góry, a następna strona jest zazwyczaj gotowa przed kliknięciem.
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 wypełnia pamięć cache bez podpięcia komponentu. Gdy użytkownik przechodzi na następną stronę, zapytanie znajduje świeże dane i natychmiast je renderuje. Działa również przy przejściu kursorem nad elementem lub przed załadowaniem danej trasy, ale należy to stosować celowo – każde pobranie z góry to rzeczywisty żądanie.
Nieskończone listy z wskaźnikami
Dla opcji „załadować więcej” lub nieskończonego przewijania, useInfiniteQuery przechowuje listę stron i śledzi dla ciebie wskaźnik. Opisujesz, jak znaleźć następny wskaźnik w funkcji getNextPageParam, a biblioteka zajmuje się resztą.
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>
</>
);
}
Należy pamiętać, że w TanStack Query v5 dedykowanym narzędziem do tego celu jest infiniteQueryOptions, które przypisuje polom nieskończonym odpowiednie typy; sprawdź aktualną dokumentację, czy queryOptions je odrzuca. Użyj również funkcji hasNextPage, aby ukryć przycisk, gdy getNextPageParam zwróci wartość undefined.
Zachowywanie spójności kluczy za pomocą fabryki
Ręcznie pisane klucze mogą się różnić: w jednym pliku zapisano ['contacts', 'list'], a w innym ['contact', 'lists'], co powoduje ciche pomijanie nieprawidłowości. Fabryka kluczy opisuje hierarchię tylko raz.
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
Ponieważ klucze pasują ze względu na przedrostek, hierarchia umożliwia nieważnienie danych w szerokim lub wąskim zakresie: contactKeys.all odświeża wszystko związane z kontaktem, natomiast contactKeys.lists() dotyka tylko zapytań listowych i pozostawia dane w pamięci podręcznej nietknięte.
Zmiana danych za pomocą mutacji
Bazowy hook mutacji
Zapisy przechodzą przez useMutation. Umieszczenie go w hooku pozwala utrzymać efekty uboczne, takie jak komunikaty, obok samej prośby.
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 i onSettled są wykonywane na odpowiednich etapach; isPending ułatwia wyłączenie przycisku, gdy prośba jest w trakcie realizacji.
Ogłaszanie tego, co nieważnią mutacje
Po zapisie dane dotyczące afekowanych zapytań muszą zostać ponownie pobraane. Zamiast wywoływać invalidateQueries w każdym hooku, mutacja może określić swoje cele w polu meta, a jeden globalny obsługiwacz może na nie zadziałać.
// 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 })
)
);
}
},
},
},
});
Idea jest dobra, ale sprawdź podłączenia zgodnie ze swoją wersją. W pokazanej sygnaturze funkcji zwrotnej czwarty argument to wartość zwrócona z onMutate, a nie sama mutacja, więc context?.meta nie znajdzie deklarowanych kluczy. Ponadto mutacja, która definiuje własną funkcję onSettled, zastępuje tę domyślną zamiast działać obok niej. Bardziej solidnym miejscem dla tego obsługiwacza jest MutationCache przekazywany do QueryClient: jego funkcje zwrotne otrzymują obiekt mutacji (wraz z mutation.meta) i zawsze są wykonywane dodatkowo do funkcji zwrotnych specyficznych dla danej mutacji. W TypeScript typowanie meta.invalidates wymaga zarejestrowania własnego typu Meta.
Globalne obsługiwanie błędów
Błędy o charakterze ogólnym, takie jak wygasła sesja, również powinny znajdować się w jednym miejscu.
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');
}
},
},
},
});
Każda mutacja reaguje w ten sam sposób na błąd 401 lub awarię sieci. Obowiązuje ta sama zasada dotycząca przejęcia kontroli: funkcja useDeleteContact zdefiniowana powyżej ma własną funkcję onError, która zastępuje tę domyślną, dlatego funkcja onError w MutationCache pozostaje najbardziej niezawodnym wyborem. Należy również zauważyć, że error.status oraz komunikat „Network Error” zależą od używanego klienta HTTP; ten drugi jest komunikatem generowanym przez Axios, natomiast fetch wywołuje błąd TypeError.
Aktualizacje optymistyczne
Interfejs optymistyczny pokazuje wynik operacji zapisu jeszcze przed potwierdzeniem jej przez serwer. Istnieją dwa poziomy realizacji tego podejścia.
Poziom interfejsu: ukrywanie elementów podczas oczekiwania na ich usunięcie
useMutationState umożliwia widzenie trwających mutacji w dowolnym miejscu drzewa. Filtrowanie według klucza i statusu pozwala zidentyfikować ID elementów aktualnie usuwanych, które lista może po prostu ukryć.
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} />;
}
W pamięci cache nic się nie zmienia, więc jeśli żądanie zawiedzie, element pojawi się ponownie sam. To dopasowanie zależy od tego, czy mutacja ma wartość mutationKey: ['deleteContact'], której wcześniejszy hook nie ustawiał; należy ją dodać, w przeciwnym razie filtr nic nie znajdzie.
Poziom cache: edytuj cache i cofnij zmiany w przypadku błędu
Bardziej kompleksowe podejście polega na bezpośredniej edycji listy przechowywanej w cache, dzięki czemu wszystkie komponenty ją czytające są aktualizowane jednocześnie.
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()
});
},
});
}
Kolejność ma znaczenie. Funkcja cancelQueries zapobiega temu, by aktualizacje trwające w tle nadpisały optymistyczne zmiany. Zdjęcie stanu jest zwracane z funkcji onMutate, aby funkcja onError mogła je przywrócić. Funkcja onSettled ponownie pobiera dane niezależnie od tego, czy żądanie zakończyło się sukcesem, czy niepowodzeniem, dzięki czemu pamięć cache kończy się zgodnie ze stanem serwera. Należy również zwrócić uwagę na klucze: getQueryData i setQueryData są identyczne, więc jeśli twoje listy są przechowywane w cache pod adresem contactKeys.list(filters), adresowanie się do contactKeys.lists() nic nie zmieni; funkcja setQueriesData aktualizuje każdą pozycję pod danym prefiksem. Należy również chronić zmienną old, ponieważ lista może jeszcze nie być w cache.
Suspense dla stanów ładowania
Funkcja useSuspenseQuery gwarantuje, że zmienna data jest zdefiniowana, i przekazuje kontrolę nad procesem oczekiwania do elementu Suspense w 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>
);
}
Jedna granica zastępuje rozproszone elementy sterujące pojedynczym szkieletem. Kompromis dotyczy szczegółowości: granica czeka na swój naj wolniejszy element potomny, więc należy umieszczać je tam, gdzie połączony stan ładowania ma rzeczywiście sens, oraz przedładowywać dane, aby uniknąć serii zapytań.
Kombinowane wzorce
Oto funkcjonalność kontaktów z połączonymi elementami: fabryka kluczy, opcje parametryzowane, mutacja usuwania, która deklaruje unieważnienie danych i aktualizuje je w sposób optymistyczny, oraz lista, która zawiesza działanie i przedładowuje dane.
// 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 }}
/>
);
}
Wynik jest typowany od początku do końca, szybki i przechowuje zasady cache w jednym module. Należy uważać na to: przy użyciu useSuspenseQuery zmiana page ponownie zawiesza działanie i pokazuje alternatywę przy każdej zmianie strony; otoczenie setPage funkcją startTransition zapewnia, że aktualna strona pozostaje na ekranie podczas ładowania następnej.
Główne wnioski
- Przypisz każdą wartość wejściową zapytania do odpowiedniego klucza; ta jedna zasada zapobiega większości błędów w pamięci cache.
- Zdefiniuj zapytania jako obiekty
queryOptions, aby hooki, pobieranie danych z wyprzedzeniem oraz odczyty z pamięci cache korzystały z tego samego typu źródła. - Jak najszybciej zastosuj mechanizm generowania kluczy; dopasowanie poprzez prefiks umożliwia precyzyjne unieważnianie danych.
- Zcentralizuj procesy unieważniania i obsługi błędów, najlepiej w callbackach
MutationCache, aby callbacki dla poszczególnych modyfikacji nie nadpisywały ich bez informowania o tym. - Dla prostych operacji ukrywania elementów wybierz podejście optymistyczne na poziomie interfejsu, natomiast gdy wiele komponentów odczytuje dane, zastosuj podejście optymistyczne na poziomie pamięci cache, wraz z funkcjami tworzenia kopii i cofania zmian.
Powiązane materiały
- React Query i Redux: Przemyślenie o stanie serwera w dużych aplikacjach — Dowiedz się, dlaczego aplikacja do czatowania w środowisku produkcyjnym używała TanStack Query zamiast Redux do zarządzania danymi serwera, oraz gdzie Redux nadal ma zastosowanie w nowoczesnej architekturze React.
- Wysyłanie danych z RTK Query: Praktyczny przewodnik po mutacjach — Naucz się, jak używać builder.mutation() w RTK Query do wysyłania zapytań POST, zarządzania stanami ładowania i błędów oraz tworzenia działającego komponentu formularza.