Strukturierung einer TanStack Query-Datenlage – von queryOptions bis zu Rollbacks
Erstellen Sie schrittweise eine Datenlage mit TanStack Query: gemeinsame queryOptions, Schlüsselgeneratoren, Selektoren, Paginierung, Vorausladeung, zentrale Invalidation sowie sichere optimistische Updates.
In den Codebasen von TanStack Query (früher React Query) häufen sich oft doppelte Schlüssel, vergessene Invalidationen sowie Spinner überall – nicht aufgrund der Bibliothek selbst, sondern wegen fehlender Struktur. Diese Anleitung entwickelt zunächst eine Funktion – ein Kontaktscreen – von einer einzigen Abfrage aus bis hin zu einer kleinen Datenschicht mit gemeinsamen Optionen, Schlüsselgeneratoren, automatischer Invalidation sowie optimistischen Löschvorgängen und weist auf die Fallstricke hin, die jedes dieser Muster verbirgt. Falls Sie sich noch nicht entschieden haben, wo der Serverzustand überhaupt gespeichert werden soll, beantwortet unser Vergleich von React Query und Redux für Serverzustand diese Frage zuerst.
Die kleinste nützliche Abfrage
Eine Abfrage benötigt einen Schlüssel und eine Funktion. Dieser Komponente lädt Kontakte herunter und kümmert sich um den Wartungs- sowie Fehlerzustand.
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 ist keine Beschriftung. Es handelt sich dabei um die Identität des Cache-Eintrags: Jeder Komponente, der ['contacts'] anfordert, werden dieselben Daten sowie derselbe Anfrageduplizierungsmechanismus und dieselbe Hintergrundabruffunktion zur Verfügung gestellt. Fast jedes unten beschriebene Muster dreht sich tatsächlich um eine ordnungsgemäße Verwaltung der Schlüssel.
Teilen von Abfragedefinitionen
Wiederholte Abfragen in einem benutzerdefinierten Hook verpacken
Falls zwei Komponenten dieselben Daten benötigen, speichert ein benutzerdefinierter Hook eine einzige Definition und überlässt die Komponenten, die nur zur Darstellung dienen, weiterhin unverändert.
// queries/contacts.ts
export function useContacts() {
return useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
}
// Component
function ContactsTable() {
const { data, isPending, isError } = useContacts();
// Clean, focused component logic
}
Objekte von queryOptions statt Hooks bevorzugen
Ein Hook kann nur innerhalb einer Komponente aufgerufen werden. Ein einfaches Optionsobjekt, das mit queryOptions erstellt wird, kann hingegen von Hooks, prefetchQuery, getQueryData sowie von Route-Loadern verwendet werden.
import { queryOptions } from '@tanstack/react-query';
export const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
});
Dies hat zwei Vorteile. Erstens die Typinferenz: Die queryOptions kennzeichnen den Schlüssel mit dem Datentyp der Abfrage, sodass queryClient.getQueryData(contactsQueryOptions.queryKey) ohne manuelle Generics bereits mit dem richtigen Typ zurückgegeben wird. Zweitens die Kompositionsfähigkeit: Man kann das Objekt ausbreiten und je nach Aufrufstelle Felder überschreiben oder hinzufügen, genau wie es der zweite Komponententeil mit select tut.
// 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} />;
}
Effizientes Lesen von Daten
Selektoren, die erneute Darstellungen einschränken
select wandelt gespeicherte Daten um, bevor sie den Komponententeil erreichen, und dient gleichzeitig zur Optimierung der Darstellung. Es kann auch in den gemeinsamen Optionen verwendet werden:
const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
select: (data) => data.length,
});
Die Komponente wird auf der Grundlage des ausgewählten Ergebnisses neu gerendert. Wenn nur die Anzahl ausgewählt wird und der Server den Namen eines Kontakts ändert, bleibt die Anzahl unverändert und die Komponente bleibt gleich. Auf Bildschirmen, bei denen viele Komponenten dieselbe große Liste lesen, wird so viel unnötiges Renderen vermieden. Eine Einschränkung: Ein inlines select-Pfeil ist bei jeder Renderung eine neue Funktion, wodurch er jedes Mal neu ausgeführt wird; für aufwändige Transformationen sollte er außerhalb der Komponente definiert oder gememorisert werden.
Parametrisierte Abfragen: Jeder Eingabewert gehört in den Schlüssel
Eine Detailseite benötigt eine Abfrage, die von einer ID abhängt. Eine Factory-Funktion, die Optionen zurückgibt, hält dies ordentlich strukturiert.
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} />;
}
Die Regel, die einen der häufigsten Fehler in der Produktion verhindert: jede Variable, die von der Abfrufunktion verwendet wird, muss im Schlüssel enthalten sein. Wenn contactId weggelassen wird, behandelt der Cache alle Kontakte als eine einzige Einträge, sodass ein Benutzer kurzzeitig die Details einer anderen Person sehen kann. Bei einem Router sollte man außerdem berücksichtigen, dass id auf undefined setzen kann; die Option enabled ermöglicht es, die Abfrage aufzubewahren, bis dieser Wert vorhanden ist.
Paginierung ist eine parametrisierte Abfrage plus Zustand
Für die Paginierung ist nichts Besonderes nötig: Die Seitennummer wird in den Schlüssel aufgenommen, und eine Änderung dieses Wertes im Zustand erzeugt eine neue Abfrage.
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)}
/>
</>
);
}
Es ist kein erneutes Abrufen notwendig; eine neue Schlüsselwerte bedeuten einfach nur eine neue Abfrage. Im Praxiseinsatz sind zwei Verbesserungen wichtig. Beim ersten Render ist data undefined, weshalb data.items einen Schutzmechanismus benötigt. Zudem beginnt die neue Schlüsselwerte bei jedem Seitenwechsel mit leeren Inhalten, wodurch die Tabelle kurzzeitig angezeigt wird; durch das Setzen von placeholderData: keepPreviousData bleibt die vorherige Seite sichtbar, während die nächste geladen wird.
Vorladen der Seite, nach der die Benutzer als Nächstes fragen werden
Kombiniert man dies mit dem Vorladen, ist die nächste Seite in der Regel bereits vor dem Klick bereit.
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 füllt den Cache aus, ohne eine Komponente anzumelden. Wenn der Benutzer zur nächsten Seite wechselt, findet die Abfrage frische Daten und rendernt diese sofort. Es funktioniert auch beim Überfahren mit der Maus oder vor dem Laden einer Route, doch man sollte es gezielt einsetzen: Jedes Vorladen stellt eine echte Anfrage dar.
Unendliche Listen mit Zeigern
Für „Mehr laden“ oder unendliches Scrollen speichert useInfiniteQuery eine Liste der Seiten und überwacht den Cursor für Sie. Sie beschreiben in getNextPageParam, wie der nächste Cursor gefunden wird, und die Bibliothek kümmert sich um die Verwaltung.
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>
</>
);
}
Beachten Sie, dass in TanStack Query v5 der spezielle Hilfsfunktion für diese Struktur infiniteQueryOptions ist, der den unendlichen Feldern die richtigen Typen zuweist; prüfen Sie die aktuellen Dokumentationen, falls queryOptions sie ablehnt. Verwenden Sie außerdem hasNextPage, um die Schaltfläche zu verbergen, sobald getNextPageParam undefined zurückgibt.
Konsistente Schlüssel mit einer Factory
Manuell erstellte Schlüssel weichen voneinander ab: Eine Datei schreibt ['contacts', 'list'], eine andere ['contact', 'lists'], und die Validierung übersieht dies stillschweigend. Eine Schlüssel-Factory beschreibt die Hierarchie einmalig.
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
Weil die Schlüssel anhand eines Präfixes übereinstimmen, ermöglicht die Hierarchie eine breite oder enge Invalidation: contactKeys.all aktualisiert alles zu Kontakten, während contactKeys.lists() nur Abfragen nach Listen betrifft und die im Cache gespeicherten Details unberührt lässt.
Daten mit Mutationen ändern
Ein grundlegender Mutation-Hook
Schreibvorgänge laufen über useMutation. Die Umhüllung in einen Hook sorgt dafür, dass Nebeneffekte wie Benachrichtigungen neben der Anfrage verbleiben.
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 und onSettled werden in den entsprechenden Phasen ausgeführt; isPending macht es einfach, die Schaltfläche solange deaktiviert zu halten, bis die Anfrage abgeschlossen ist.
Ausweisen, was durch eine Mutation ungültig wird
Nach einer Schreiboperation müssen die betroffenen Abfragen erneut abgerufen werden. Anstatt invalidateQueries in jedem Hook aufzurufen, kann eine Mutation ihre Ziele in meta angeben, und ein einziger globaler Handler kann darauf reagieren.
// 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 })
)
);
}
},
},
},
});
Die Idee ist gut, aber überprüfen Sie die Verkabelung anhand Ihrer Version. In der angezeigten Callback-Signatur ist das vierte Argument der von onMutate zurückgegebene Wert, nicht die Mutation selbst, weshalb context?.meta die deklarierten Schlüssel nicht finden wird. Zudem ersetzt eine Mutation, die ihren eigenen onSettled definiert, diesen Standardwert anstelle dessen, dass sie parallel dazu ausgeführt wird. Ein robusterer Ort für den Handler ist ein MutationCache, der an den QueryClient übergeben wird: Seine Callbacks erhalten das Mutation-Objekt (mit mutation.meta) und werden immer zusätzlich zu den pro-Mutation-Callbacks ausgeführt. In TypeScript erfordert das Typisieren von meta.invalidates die Registrierung eines benutzerdefinierten Meta-Typs.
Globale Fehlerbehandlung
Auch querliegende Fehler wie abgelaufene Sessions sollten an einem Ort erfasst werden.
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');
}
},
},
},
});
Jede Mutation reagiert somit auf einen 401-Fehler oder ein Netzwerkproblem auf dieselbe Weise. Gilt weiterhin die gleiche Einschränkung: Die Funktion useDeleteContact definiert ihre eigene Methode onError, die diese Standardbehandlung ersetzt, weshalb MutationCache’s onError-Methode wieder die zuverlässige Wahl ist. Beachten Sie außerdem, dass error.status sowie die Meldung „Network Error“ vom verwendeten HTTP-Client abhängen; letztere wird von Axios ausgegeben, während fetch einen TypeError auslöst.
Optimistische Aktualisierungen
Bei optimistischen Benutzeroberflächen wird das Ergebnis einer Schreiboperation bereits angezeigt, bevor der Server es bestätigt. Es gibt zwei Ebenen hierfür.
Benutzeroberflächen-Ebene: Elemente während des Löschvorgangs verstecken
useMutationState macht Mutationen, die gerade ausgeführt werden, überall im Baum sichtbar. Durch Filtern nach Schlüssel und Status erhält man die IDs der gerade gelöschten Elemente, die die Liste einfach verbergen kann.
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} />;
}
Im Cache ändert sich nichts, sodass das Element bei einem Fehler von selbst wieder erscheint. Diese Abgleichmethode setzt voraus, dass die Mutation mutationKey: ['deleteContact'] enthält – was der frühere Hook nicht vorgenommen hat; fügen Sie diesen Wert hinzu, sonst findet das Filtern nichts.
Cachenebene: Den Cache bearbeiten und bei Fehler zurücksetzen
Der gründlichere Ansatz besteht darin, die im Cache gespeicherte Liste direkt zu bearbeiten, damit alle Komponenten, die sie lesen, gleichzeitig aktualisiert werden.
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()
});
},
});
}
Die Reihenfolge ist wichtig. cancelQueries verhindert, dass eine laufende Neuladung die optimistische Änderung überschreibt. Der Snapshot wird von onMutate zurückgegeben, damit onError ihn wiederherstellen kann. onSettled lädt erneut, unabhängig davon, ob die Anfrage erfolgreich war oder fehlgeschlagen ist, sodass der Cache letztendlich mit dem Server übereinstimmt. Achten Sie auch auf den Schlüssel: getQueryData und setQueryData entsprechen genau einander, daher berührt das Targeting von contactKeys.lists() nichts, falls Ihre Listen unter contactKeys.list(filters) gekachtet sind; setQueriesData aktualisiert hingegen jeden Eintrag unter einem Präfix. Schützen Sie auch den Wert old, da die Liste möglicherweise noch nicht gekachtet ist.
Suspense für Ladezustände
useSuspenseQuery stellt sicher, dass data definiert ist, und überträgt das Warten an eine React Suspense-Grenze.
// 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>
);
}
Eine Grenze ersetzt die verstreuten Spinner durch ein einziges Skelett. Der Kompromiss liegt in der Granularität: Die Grenze wartet auf ihr langsamstes Kind, daher sollten Grenzen dort platziert werden, wo ein kombinierter Ladezustand tatsächlich sinnvoll ist, und Daten sollten im Voraus geladen werden, um Anfragenketten zu vermeiden.
Die kombinierten Muster
Hier ist die Kontakte-Funktion mit allen Komponenten zusammengefasst: eine Schlüsselgenerierung, parametrisierte Optionen, eine Löschmutation, die ihre Invalidierung deklariert und optimistisch aktualisiert, sowie eine Liste, die den Ladevorgang aussetzt und im Voraus Daten lädt.
// 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 }}
/>
);
}
Das Ergebnis ist von Anfang bis Ende typisiert, schnell und behält die Cache-Regeln in einem Modul bei. Etwas, worauf geachtet werden muss: Mit useSuspenseQuery führt ein Ändern von page erneut zu einem Aussetzen des Ladevorgangs und zeigt die Fallback-Ansicht bei jedem Seitenwechsel; das Einhüllen von setPage in startTransition hält die aktuelle Seite auf dem Bildschirm, während die nächste geladen wird.
Kernpunkte
- Führen Sie jede Abfragedaten-Eingabe unter deren Schlüssel ein; diese einzige Regel verhindert die meisten Cache-Fehler.
- Definieren Sie Abfragen als
queryOptions-Objekte, damit Hooks, Vorausladevorgänge und Cache-Lesungen von einer einheitlichen, typisierten Quelle profitieren. - Wählen Sie frühzeitig eine Schlüsselgenerierungslogik; durch Präfixabgleich wird die Invalidation präziser gesteuert.
- Zentralisieren Sie Invalidation und Fehlerbehandlung, idealerweise in
MutationCache-Callbacks, damit Callbacks pro Mutation sie nicht stillschweigend überschreiben. - Wählen Sie auf UI-Ebene Optimismus für einfaches Verbergen von Inhalten und auf Cache-Ebene Optimismus mit Snapshot- und Rollback-Funktionen, wenn viele Komponenten die Daten lesen.
Zusätzliche Literatur
- React Query und Redux: Die Server-State in großen Anwendungen neu überdenken — Erfahren Sie, warum eine Produktions-Chat-Anwendung TanStack Query anstelle von Redux zur Verwaltung von Serverdaten verwendet hat, und wo Redux in der modernen React-Architektur noch seinen Platz findet.
- Daten mit RTK Query senden: Ein praktischer Leitfaden zu Mutations — Lernen Sie, wie Sie builder.mutation() in RTK Query nutzen können, um POST-Anfragen zu senden, Lade- und Fehlerzustände zu verwalten sowie ein funktionsfähiges Formularkomponente zu erstellen.