den GraphQL-Cache richtig normalisieren
Apollo Client normalisiert GraphQL-Antworten automatisch in einen flachen Objektgraphen, aber ohne saubere Type Policies und ohne durchdachte Cache-Updates entstehen schnell veraltete Ansichten. Dieser Artikel zeigt, wie InMemoryCache wirklich funktioniert, wie eigene Cache-Schlüssel mit keyFields definiert werden und wie Mutations, Pagination und Debugging in der Praxis zusammenspielen.
Inhaltsverzeichnis
- 1. Warum Cache-Normalisierung bei Apollo Client zählt
- 2. InMemoryCache: wie Apollo Client normalisiert
- 3. Type Policies: eigene Cache-Schlüssel mit keyFields
- 4. Fragments: Queries konsistent und wiederverwendbar halten
- 5. Mutations: update-Funktion, cache.modify und cache.writeQuery
- 6. optimisticResponse: sofortiges Feedback ohne Wartezeit
- 7. Pagination im Cache: relayStylePagination und Field Policies
- 8. Debugging: Apollo DevTools, cache.gc() und typische Fehlerbilder
- 9. Apollo Client im Vergleich zu anderen Datenschicht-Lösungen
- 10. Zusammenfassung
- 11. FAQ
1. Warum Cache-Normalisierung bei Apollo Client zählt
Apollo Client ist mehr als ein Fetch-Wrapper für GraphQL, weil er jede Antwort in einen normalisierten Objektgraphen zerlegt und diesen Graphen über alle Komponenten hinweg konsistent hält. Wer eine Nutzerin in einer Liste editiert, sieht die Änderung ohne zusätzlichen Refetch auch in der Detailansicht, sofern der Cache korrekt normalisiert wurde. Genau dieser Effekt unterscheidet Apollo Client von einem simplen fetch-und-speichern-Ansatz und ist der eigentliche Grund, warum Teams die zusätzliche Komplexität in Kauf nehmen.
Die Krux liegt darin, dass Normalisierung nur funktioniert, wenn Apollo jedes Objekt eindeutig identifizieren kann. Fehlt eine ID im Query-Ergebnis oder wird eine benutzerdefinierte ID falsch konfiguriert, fallen mehrere eigentlich identische Objekte im GraphQL-Cache auseinander, und Komponenten zeigen widersprüchliche Daten an. Das Ergebnis sind Bugs, die sich nur sporadisch reproduzieren lassen, weil sie von der Reihenfolge der ausgeführten Queries abhängen. Die folgenden Abschnitte zeigen, wie man diese Fallen systematisch vermeidet, von den Grundlagen der Normalisierung bis zu Pagination und Debugging.
2. InMemoryCache: wie Apollo Client normalisiert
Der InMemoryCache ist die Standard-Cache-Implementierung von Apollo Client und arbeitet nach einem einfachen, aber wirkungsvollen Prinzip: Jedes Objekt mit einem __typename und einer id (oder _id) wird als eigener Eintrag im Cache gespeichert, referenziert über einen Schlüssel im Format Typename:id. Eine Query, die ein Array von Beiträgen mit verschachtelten Autoren zurückgibt, wird also nicht als verschachtelter Blob abgelegt, sondern in einzelne, flache Objekte zerlegt, die sich gegenseitig über Referenzen verlinken.
Dieser flache Objektgraph ist der Grund, warum Apollo Client Updates so effizient propagieren kann. Wird ein Autor-Objekt durch eine andere Query oder Mutation aktualisiert, bemerken alle Komponenten, die diesen Autor irgendwo referenzieren, automatisch die Änderung, ohne dass ein manueller Refetch nötig wäre. Voraussetzung ist, dass die Query tatsächlich das Feld id anfragt. Ein häufiger Anfängerfehler ist, id aus einer Query wegzulassen, weil sie in der UI nicht gebraucht wird, was die Normalisierung für dieses Objekt komplett deaktiviert und es zu einem nicht identifizierbaren, eingebetteten Objekt macht.
// apollo-client.ts — Basic InMemoryCache setup
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
export const client = new ApolloClient({
link: new HttpLink({ uri: "https://api.mironsoft.de/graphql" }),
cache: new InMemoryCache({
// Apollo Client uses __typename + id by default to build cache keys
// Custom type policies override this per type (see next section)
typePolicies: {
Query: {
fields: {
// Merge function for a paginated field, avoids overwriting on refetch
products: {
keyArgs: ["category", "sortBy"],
},
},
},
},
}),
defaultOptions: {
watchQuery: { fetchPolicy: "cache-and-network" },
},
});
3. Type Policies: eigene Cache-Schlüssel mit keyFields
Nicht jede API liefert eine simple numerische id. Manche Typen werden über zusammengesetzte Schlüssel identifiziert, etwa eine Bestellposition über orderId und lineNumber gemeinsam. Für diese Fälle bietet Apollo Client keyFields in den Type Policies an, mit denen man exakt definiert, welche Felder zusammen den eindeutigen Schlüssel eines Typs bilden. Ohne diese Konfiguration würde Apollo entweder eine falsche Standard-ID verwenden oder das Objekt als nicht normalisierbar behandeln, was zu doppelten Cache-Einträgen für dasselbe logische Objekt führt.
Ein weiteres wichtiges Werkzeug sind read- und merge-Funktionen auf Feldebene. Eine read-Funktion kann etwa einen berechneten Wert wie einen formatierten Preis direkt aus dem Cache liefern, ohne dass der Server diesen Wert mitschicken muss. Eine merge-Funktion entscheidet, wie neue Daten mit bereits vorhandenen Feld-Werten zusammengeführt werden, was besonders bei Arrays und Pagination entscheidend ist. Wer diese Policies bewusst konfiguriert, verhindert die häufigste Fehlerquelle im GraphQL-Cache: dass ein Refetch bestehende, korrekt sortierte Listen unbeabsichtigt überschreibt.
// cache-policies.ts — Custom keyFields for composite identifiers
import { InMemoryCache } from "@apollo/client";
const cache = new InMemoryCache({
typePolicies: {
OrderLine: {
// Composite key: orderId + lineNumber uniquely identify an OrderLine
keyFields: ["orderId", "lineNumber"],
},
Product: {
fields: {
// Computed field, read directly from cached price data
formattedPrice: {
read(_, { readField }) {
const price = readField("priceCents");
return typeof price === "number"
? `${(price / 100).toFixed(2)} EUR`
: null;
},
},
},
},
User: {
// Users have no numeric id in this API, only a UUID string field "uuid"
keyFields: ["uuid"],
},
},
});
4. Fragments: Queries konsistent und wiederverwendbar halten
GraphQL-Fragments sind das zweite Fundament für einen sauberen GraphQL-Cache. Ein Fragment bündelt die Felder, die eine Komponente für ihre Darstellung braucht, in einer benannten, wiederverwendbaren Einheit. Wenn zwei verschiedene Queries dasselbe Fragment für den Typ Product einbinden, garantiert Apollo Client, dass beide Queries exakt dieselben Felder anfragen und somit denselben Cache-Eintrag konsistent befüllen. Fehlen in einer Query Felder, die eine Komponente eigentlich braucht, entstehen sogenannte partielle Cache-Treffer, die Apollo zu einem stillen Netzwerk-Refetch zwingen.
Fragment Colocation, also das Definieren des Fragments direkt neben der Komponente, die es nutzt, verhindert, dass Datenanforderungen und Darstellungslogik auseinanderlaufen. Ändert sich eine Komponente und braucht ein zusätzliches Feld, wird das Fragment direkt dort erweitert, wo es benutzt wird, ohne eine zentrale, unübersichtliche Query-Datei durchsuchen zu müssen. Diese Praxis skaliert deutlich besser als monolithische Queries und ist einer der Hauptgründe, warum Apollo Client in großen React-Codebasen gut wartbar bleibt.
5. Mutations: update-Funktion, cache.modify und cache.writeQuery
Eine Mutation in Apollo Client aktualisiert per Default automatisch den Cache-Eintrag des zurückgegebenen Objekts, sofern die Antwort dieselben Felder wie eine vorhandene Query enthält. Für alles darüber hinaus, etwa das Entfernen eines Elements aus einer Liste oder das Aktualisieren eines Zählers, braucht es eine explizite update-Funktion. Diese Funktion erhält Zugriff auf den Cache und kann mit cache.modify gezielt einzelne Felder verändern, ohne die komplette betroffene Query neu abzufragen.
cache.modify ist präziser als ein kompletter Refetch, weil es nur den betroffenen Feld-Wert ändert und alle Komponenten, die diesen Wert lesen, automatisch neu rendern lässt. Für komplexere Fälle, in denen ein völlig neues Objekt in eine bestehende Liste eingefügt werden muss, kommt cache.writeQuery oder cache.writeFragment zum Einsatz. Wer diese Werkzeuge ignoriert und stattdessen bei jeder Mutation einen kompletten Refetch der Elternliste auslöst, verschenkt den zentralen Performancevorteil von Apollo Client und produziert unnötige Netzwerklast.
// DeleteTodoButton.tsx — Cache update after a mutation without a full refetch
import { useMutation, gql } from "@apollo/client";
const DELETE_TODO = gql`
mutation DeleteTodo($id: ID!) {
deleteTodo(id: $id) {
id
}
}
`;
function DeleteTodoButton({ todoId }: { todoId: string }) {
const [deleteTodo] = useMutation(DELETE_TODO, {
update(cache, { data }) {
if (!data?.deleteTodo) return;
cache.modify({
fields: {
todos(existingRefs = [], { readField }) {
// Remove the deleted todo's reference from the cached list
return existingRefs.filter(
(ref) => readField("id", ref) !== data.deleteTodo.id
);
},
},
});
// Explicitly evict the object itself to free memory
cache.evict({ id: cache.identify({ __typename: "Todo", id: todoId }) });
cache.gc();
},
});
return <button onClick={() => deleteTodo({ variables: { id: todoId } })}>Löschen</button>;
}
6. optimisticResponse: sofortiges Feedback ohne Wartezeit
optimisticResponse ist die eingebaute Antwort von Apollo Client auf das Problem, dass Nutzerinnen und Nutzer auf jede Mutation warten müssten, bis der Server antwortet. Statt die UI erst nach der Serverantwort zu aktualisieren, wird sofort ein vorläufiges Ergebnis in den Cache geschrieben, das exakt dieselbe Form wie die echte Antwort hat. Sobald der Server tatsächlich antwortet, ersetzt Apollo Client das optimistische Ergebnis automatisch durch die reale Antwort, ohne dass die Komponente diesen Wechsel manuell behandeln muss.
Der entscheidende Unterschied zu Reacts eingebautem useOptimistic-Hook liegt darin, dass Apollo diesen Mechanismus direkt in den globalen Cache integriert: Ändert sich ein optimistisch aktualisiertes Objekt, sehen sofort alle Komponenten im Baum den neuen Zustand, nicht nur die Komponente, die die Mutation ausgelöst hat. Schlägt die Mutation fehl, macht Apollo die optimistische Änderung automatisch rückgängig und stellt den vorherigen Cache-Zustand wieder her, was manuelles Rollback-Handling überflüssig macht.
7. Pagination im Cache: relayStylePagination und Field Policies
Pagination ist der Bereich, in dem die meisten Teams erstmals tief mit Field Policies in Berührung kommen, weil eine naive Konfiguration bei jedem Refetch die bereits geladene Liste überschreibt, statt neue Seiten anzuhängen. Apollo Client liefert dafür die Hilfsfunktion relayStylePagination, die eine korrekte merge-Funktion für Relay-kompatible Connections mit edges und pageInfo bereitstellt und neue Seiten korrekt an bestehende Einträge anhängt, statt sie zu ersetzen.
Für Offset-basierte Pagination ohne Relay-Konventionen lässt sich eine eigene merge-Funktion schreiben, die anhand von offset und limit Argumenten die richtige Position im Array bestimmt. Wichtig ist dabei, keyArgs korrekt zu setzen, damit Apollo Filterungen wie unterschiedliche Sortierungen oder Kategorien als separate Cache-Einträge behandelt, statt sie versehentlich zu vermischen. Fehlt diese Konfiguration, entstehen inkonsistente Listen, sobald Nutzerinnen zwischen Filtern wechseln.
// pagination-policy.ts — Relay-style pagination with correct merge behavior
import { InMemoryCache } from "@apollo/client";
import { relayStylePagination } from "@apollo/client/utilities";
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Handles edges/pageInfo merging automatically, keyed by filter args
articles: relayStylePagination(["category"]),
},
},
},
});
// Component side: fetchMore appends the next page via the merge function above
function ArticleList() {
const { data, fetchMore } = useQuery(GET_ARTICLES, {
variables: { category: "react", first: 20 },
});
const loadMore = () => {
fetchMore({
variables: { after: data?.articles.pageInfo.endCursor },
});
};
return null; // rendering omitted for brevity
}
8. Debugging: Apollo DevTools, cache.gc() und typische Fehlerbilder
Die Apollo Client DevTools Browser-Extension zeigt den kompletten normalisierten Cache als durchsuchbaren Baum an und ist das erste Werkzeug bei jedem Debugging-Problem rund um Apollo Client. Mit dem integrierten Explorer lassen sich Queries direkt gegen den laufenden Cache testen, ohne Code zu ändern, und man sieht sofort, ob ein erwartetes Feld tatsächlich normalisiert vorliegt oder als eingebettetes, nicht referenziertes Objekt im Cache hängt.
Ein typisches Fehlerbild ist ein wachsender Speicherverbrauch durch verwaiste Cache-Einträge, die nach einem cache.evict()-Aufruf nicht mehr referenziert werden, aber ohne cache.gc() im Speicher verbleiben. cache.gc() entfernt alle Objekte, auf die keine aktive Query mehr verweist, und sollte nach jedem manuellen evict-Aufruf ausgeführt werden. Ein zweites häufiges Problem sind widersprüchliche Anzeigen zwischen zwei Komponenten, die meist auf fehlende id-Felder oder falsch konfigurierte keyFields zurückzuführen sind, siehe Abschnitt drei.
9. Apollo Client im Vergleich zu anderen Datenschicht-Lösungen
Apollo Client ist nicht die einzige Möglichkeit, GraphQL in React zu konsumieren, und die Wahl hängt stark von Teamgröße, Bundle-Größe-Anforderungen und der gewünschten Kontrolle über den Cache ab. Die folgende Tabelle stellt die wichtigsten Alternativen gegenüber, jeweils mit Fokus auf Normalisierung und Cache-Kontrolle.
| Lösung | Normalisierung | Bundle-Größe | Einsatzbereich |
|---|---|---|---|
| Apollo Client | Automatisch, konfigurierbar über Type Policies | Mittel bis groß | Große Teams, komplexe Datenmodelle |
| Relay | Strikt, compile-time erzwungen | Mittel | Sehr große Codebasen, hohe Disziplin |
| urql | Optional, austauschbare Exchanges | Klein | Leichtgewichtige Anwendungen |
| TanStack Query + fetch | Keine, Cache pro Query-Key | Klein | REST-lastige oder gemischte APIs |
| graphql-request + eigener Cache | Manuell | Minimal | Skripte, einfache Integrationen |
Für Teams, die bereits stark auf GraphQL setzen und mehrere zusammenhängende Views mit überlappenden Daten pflegen, bleibt Apollo Client die Lösung mit dem größten Funktionsumfang in Sachen Cache-Kontrolle. Relay erzwingt zwar noch strengere Disziplin, verlangt aber einen Compiler-Schritt und eine steilere Lernkurve. Wer nur gelegentlich GraphQL neben überwiegend REST-Endpunkten nutzt, fährt oft mit TanStack Query und einem schlanken GraphQL-Client besser, da die Normalisierungs-Overhead entfällt.
Mironsoft
React, GraphQL und Datenschicht-Architektur aus einer Hand
Apollo Client, der zuverlässig konsistente Daten liefert?
Wir konfigurieren Type Policies, Pagination-Strategien und Mutation-Updates für euren GraphQL-Cache und beheben widersprüchliche Ansichten, die durch fehlerhafte Normalisierung entstehen.
Cache-Audit
Analyse bestehender Type Policies und Aufspüren fehlender keyFields
Pagination-Setup
relayStylePagination und eigene merge-Funktionen für Listen und Infinite Scroll
Migration
Umstieg von manuellen Refetches auf cache.modify und optimisticResponse
10. Zusammenfassung
Apollo Client normalisiert GraphQL-Antworten in einen flachen Objektgraphen, sofern jedes Objekt über __typename und id eindeutig identifizierbar ist. Type Policies mit keyFields lösen den Fall zusammengesetzter Schlüssel, während read- und merge-Funktionen berechnete Felder und Pagination sauber steuern. Fragments halten Queries über Komponenten hinweg konsistent und verhindern partielle Cache-Treffer, die zu stillen Refetches führen.
Mutations sollten update-Funktionen mit cache.modify nutzen, statt komplette Elternlisten neu abzufragen, und optimisticResponse liefert sofortiges UI-Feedback mit automatischem Rollback bei Fehlern. Für Pagination ist relayStylePagination der Standardweg bei Relay-kompatiblen APIs, für alles andere braucht es eine eigene merge-Funktion mit korrekt gesetzten keyArgs. Die Apollo DevTools und cache.gc() runden das Werkzeugset für den produktiven Alltag mit Apollo Client ab.
Apollo Client Cache-Normalisierung — Das Wichtigste auf einen Blick
Normalisierung
Jedes Objekt braucht __typename und id, sonst wird es als eingebettetes, nicht referenzierbares Objekt gespeichert.
Type Policies
keyFields für zusammengesetzte Schlüssel, read/merge für berechnete Felder und Pagination.
Mutations
update mit cache.modify statt vollständigem Refetch, optimisticResponse für sofortiges Feedback.
Debugging
Apollo DevTools zum Inspizieren des Caches, cache.gc() nach jedem manuellen evict.