von __typename+id bis zur optimistic Response
Der Apollo Client Cache ist mehr als ein einfacher Response-Speicher: Er normalisiert jedes Objekt anhand von __typename und id zu einem flachen Graphen, hält Queries konsistent synchron und erlaubt gezielte Updates ohne Refetch. Wer typePolicies, cache.modify und optimistic Responses versteht, baut spürbar schnellere und konsistentere React-Anwendungen.
Inhaltsverzeichnis
- 1. Normalisierung: __typename und id als Fundament
- 2. typePolicies und keyFields konfigurieren
- 3. cache.readQuery und cache.writeQuery
- 4. cache.modify: gezielte Feldänderungen ohne Refetch
- 5. Optimistic Responses für sofortiges UI-Feedback
- 6. Cache-Invalidierung nach Mutations
- 7. Pagination im Cache: relayStylePagination und merge
- 8. Typische Fallstricke im Apollo Client Cache
- 9. Update-Strategien im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Normalisierung: __typename und id als Fundament
Der zentrale Unterschied zwischen dem Apollo Client Cache und einem naiven Response-Cache liegt in der Normalisierung. Statt jede Query-Antwort als eigenständiges, verschachteltes JSON-Objekt zu speichern, zerlegt der Apollo Client Cache jede Antwort in einzelne Entitäten und legt sie flach in einer internen Map ab. Der Schlüssel jeder Entität setzt sich standardmäßig aus __typename und id zusammen, etwa Product:42. Apollo fügt __typename automatisch zu jeder Query hinzu, ohne dass Entwickler es selbst schreiben müssen.
Diese Normalisierung hat einen entscheidenden Vorteil: Taucht dieselbe Entität in zwei völlig unterschiedlichen Queries auf, etwa einer Produktliste und einer Produktdetailseite, landet sie im Apollo Client Cache trotzdem nur einmal. Ändert sich ein Feld dieser Entität durch eine Mutation, sehen automatisch alle Komponenten die aktualisierten Daten, die diese Entität irgendwo referenzieren. Ohne Normalisierung müsste jede Query separat neu geladen werden, um Konsistenz zu garantieren. Der Apollo Client Cache löst dieses Problem strukturell, nicht durch manuelles Nachziehen.
Wichtig ist dabei: Entitäten ohne id-Feld werden standardmäßig nicht normalisiert, sondern als eingebettetes Objekt unter dem Pfad der übergeordneten Query gespeichert. Das betrifft häufig Wertobjekte wie Address oder Money, die keine eigene Identität besitzen. Für solche Typen kann man mit keyFields: false explizit festlegen, dass sie niemals eigenständig normalisiert werden sollen, was Verwirrung bei Debugging-Sessions vermeidet.
2. typePolicies und keyFields konfigurieren
typePolicies ist die zentrale Konfigurationsstelle, um das Standardverhalten des Apollo Client Cache pro GraphQL-Typ anzupassen. Über keyFields lässt sich festlegen, welche Felder anstelle von id die Cache-Identität eines Typs bestimmen. Das ist notwendig, wenn ein Backend zusammengesetzte Schlüssel verwendet, etwa eine Kombination aus sku und storeCode, oder wenn ein Typ überhaupt kein id-Feld liefert, aber ein anderes eindeutiges Feld existiert.
Ein häufiger Praxisfall bei Magento-nahen Headless-Frontends: Der GraphQL-Typ SimpleProduct liefert zwar eine id, aber diese ist store-view-spezifisch und kollidiert zwischen verschiedenen Sprachversionen. Mit keyFields: ["sku"] im Apollo Client Cache normalisiert man stattdessen konsistent über die SKU, die über Store-Views hinweg stabil bleibt. Ohne diese Anpassung würde derselbe Artikel in zwei Sprachversionen als zwei unabhängige Cache-Einträge behandelt, was zu inkonsistenten Warenkorb-Zuständen führen kann.
// apollo-client.ts — configure normalization behavior per type
import { ApolloClient, InMemoryCache } from "@apollo/client";
const cache = new InMemoryCache({
typePolicies: {
Product: {
// Normalize by SKU instead of the store-view-specific id field
keyFields: ["sku"],
},
Money: {
// Value object without its own identity — always embed, never normalize
keyFields: false,
},
CartItem: {
// Composite key: same product can appear once per configured option set
keyFields: ["id", "configuredOptions", ["optionId", "value"]],
},
Query: {
fields: {
// See section 7 for the merge function behind this field policy
products: {
keyArgs: ["categoryId", "sortBy"],
},
},
},
},
});
export const client = new ApolloClient({
uri: "/graphql",
cache,
});
Der letzte Block im Beispiel zeigt keyArgs, das eng mit keyFields verwandt ist, aber auf Feldebene statt Typebene wirkt: Es bestimmt, welche Query-Argumente eine eigenständige Cache-Zeile für ein paginiertes oder gefiltertes Feld erzeugen. Beide Mechanismen zusammen bilden das Fundament, mit dem der Apollo Client Cache über simple ID-Zuordnung hinaus an reale Backend-Datenmodelle angepasst wird.
3. cache.readQuery und cache.writeQuery
Neben dem automatischen Lesen über useQuery bietet der Apollo Client Cache mit cache.readQuery und cache.writeQuery einen imperativen Zugriff, der sich exakt wie eine normale GraphQL-Query verhält, aber direkt gegen den lokalen Zustand arbeitet, ohne einen Netzwerk-Request auszulösen. Das ist besonders in Mutation-Callbacks nützlich, wenn man den aktuellen Zustand einer Liste lesen, ein Element ergänzen und das Ergebnis zurückschreiben möchte.
cache.readQuery wirft standardmäßig einen Fehler, wenn Teile der angefragten Daten nicht im Cache vorhanden sind. Für tolerante Lesezugriffe, bei denen fehlende Felder kein Abbruchkriterium sein sollen, existiert cache.diff mit der Option optimistic: true. In der Praxis reicht für die meisten Update-Fälle aber readQuery gefolgt von writeQuery, solange man sicherstellt, dass die betroffene Query bereits einmal ausgeführt wurde und im Apollo Client Cache existiert.
// Reading and writing a query directly against the Apollo Client cache
import { gql, useMutation } from "@apollo/client";
const GET_WISHLIST = gql`
query GetWishlist {
wishlist {
id
items {
id
product { sku name }
}
}
}
`;
const ADD_TO_WISHLIST = gql`
mutation AddToWishlist($sku: String!) {
addToWishlist(sku: $sku) {
id
product { sku name }
}
}
`;
function useAddToWishlist() {
const [addToWishlist] = useMutation(ADD_TO_WISHLIST, {
update(cache, { data }) {
const newItem = data.addToWishlist;
// Read current state — throws if GET_WISHLIST was never fetched
const existing = cache.readQuery({ query: GET_WISHLIST });
if (!existing) return;
// Write the merged result back without a network round trip
cache.writeQuery({
query: GET_WISHLIST,
data: {
wishlist: {
...existing.wishlist,
items: [...existing.wishlist.items, newItem],
},
},
});
},
});
return addToWishlist;
}
Ein häufig übersehener Punkt: cache.writeQuery löst genau dieselben React-Re-Renders aus wie ein echter Netzwerk-Response. Jede Komponente, die GET_WISHLIST per useQuery beobachtet, aktualisiert sich sofort, ohne dass sie selbst irgendetwas vom Update-Mechanismus wissen muss. Das ist der eigentliche Wert des imperativen Zugriffs auf den Apollo Client Cache: Die UI bleibt an einer einzigen Quelle der Wahrheit synchronisiert.
4. cache.modify: gezielte Feldänderungen ohne Refetch
Für punktuelle Änderungen an einzelnen Feldern einer bereits normalisierten Entität ist cache.modify das präzisere Werkzeug gegenüber readQuery/writeQuery. Statt eine ganze Query neu zusammenzusetzen, adressiert cache.modify ein konkretes Objekt über seine ID und definiert Modifier-Funktionen pro Feld, die den bisherigen Wert erhalten und den neuen zurückgeben. Das ist deutlich weniger fehleranfällig, weil man nicht versehentlich Felder überschreibt, die man gar nicht ändern wollte.
Ein klassisches Beispiel ist das Erhöhen eines Zählers nach einer Mutation, etwa Likes oder Lagerbestand. Statt die komplette Produktliste neu zu laden, greift cache.modify direkt auf das betroffene Product-Objekt im Apollo Client Cache zu und inkrementiert nur das eine Feld. Auch das Entfernen eines Elements aus einer referenzierten Liste, etwa nach dem Löschen eines Kommentars, lässt sich so lösen, indem der Modifier die Referenz aus dem Array filtert.
// Targeted field updates in the Apollo Client cache — no refetch needed
import { gql, useMutation } from "@apollo/client";
const TOGGLE_LIKE = gql`
mutation ToggleLike($productId: ID!) {
toggleLike(productId: $productId) {
id
liked
}
}
`;
function useToggleLike() {
const [toggleLike] = useMutation(TOGGLE_LIKE, {
update(cache, { data }, { variables }) {
cache.modify({
id: cache.identify({ __typename: "Product", id: variables.productId }),
fields: {
likeCount(existing = 0) {
return data.toggleLike.liked ? existing + 1 : existing - 1;
},
liked() {
return data.toggleLike.liked;
},
},
});
},
});
return toggleLike;
}
// Removing an item from a normalized list via cache.modify
function removeCommentFromCache(cache, commentId) {
cache.modify({
fields: {
comments(existingRefs = [], { readField }) {
return existingRefs.filter(
(ref) => readField("id", ref) !== commentId
);
},
},
});
}
cache.identify ist der Helfer, der aus einem Objekt mit __typename und id denselben internen Cache-Schlüssel berechnet, den der Apollo Client Cache intern verwendet, inklusive respektierter keyFields-Konfiguration. Wer cache.modify ohne id aufruft, wirkt auf das ROOT_QUERY-Objekt, was für Root-Level-Felder wie comments im zweiten Beispiel korrekt ist. Nach jedem cache.modify-Aufruf lässt sich mit dem DELETE-Sentinel aus @apollo/client zusätzlich ein Feld vollständig entfernen, statt es nur zu überschreiben.
5. Optimistic Responses für sofortiges UI-Feedback
Netzwerk-Latenz ist bei jeder Mutation spürbar, wenn die UI erst nach der Server-Antwort reagiert. Der Apollo Client Cache löst dieses UX-Problem mit optimistic Responses: Man liefert der Mutation eine vorhergesagte Antwort, die sofort in den Cache geschrieben wird, noch bevor die Server-Antwort eintrifft. Sobald die echte Antwort ankommt, ersetzt Apollo die optimistische Version automatisch durch die reale, und falls die Mutation fehlschlägt, wird der optimistische Zustand automatisch zurückgerollt.
Entscheidend ist, dass die optimistic Response exakt dieselbe Form wie die reale Mutation-Response haben muss, inklusive __typename für jedes enthaltene Objekt, sonst kann der Apollo Client Cache die Normalisierung nicht korrekt durchführen und die Werte landen nicht an der richtigen Stelle. Für Objekte, deren finale ID der Server erst vergibt, etwa beim Anlegen eines neuen Kommentars, verwendet man eine temporäre negative ID oder eine UUID als Platzhalter, die nach der echten Antwort durch die reale ID ersetzt wird.
// Optimistic response — UI updates instantly, before the server replies
import { gql, useMutation } from "@apollo/client";
const ADD_COMMENT = gql`
mutation AddComment($postId: ID!, $text: String!) {
addComment(postId: $postId, text: $text) {
id
text
author { id name }
createdAt
}
}
`;
function useAddComment(currentUser) {
const [addComment] = useMutation(ADD_COMMENT, {
optimisticResponse: (variables) => ({
addComment: {
__typename: "Comment",
id: `temp-${Date.now()}`,
text: variables.text,
author: {
__typename: "User",
id: currentUser.id,
name: currentUser.name,
},
createdAt: new Date().toISOString(),
},
}),
update(cache, { data }, { variables }) {
cache.modify({
id: cache.identify({ __typename: "Post", id: variables.postId }),
fields: {
comments(existingRefs = [], { toReference }) {
return [...existingRefs, toReference(data.addComment)];
},
},
});
},
});
return addComment;
}
Ein wichtiger Nebeneffekt: Die update-Funktion wird bei optimistic Responses zweimal ausgeführt, einmal mit den optimistischen Daten und ein zweites Mal mit den echten Server-Daten. Der Code in update muss deshalb idempotent sein und darf sich nicht auf Seiteneffekte verlassen, die nur einmal passieren dürfen. Das ist einer der subtileren Fallstricke im Umgang mit dem Apollo Client Cache, der in Code-Reviews leicht übersehen wird.
6. Cache-Invalidierung nach Mutations
Nach einer Mutation gibt es im Apollo Client Cache grundsätzlich drei Strategien, um betroffene Daten aktuell zu halten: refetchQueries, eine manuelle update-Funktion oder cache.evict in Kombination mit cache.gc. refetchQueries ist am einfachsten zu implementieren, verursacht aber zusätzliche Netzwerk-Requests und ist bei komplexen Seiten mit vielen aktiven Queries schnell teuer. Es eignet sich vor allem für Fälle, in denen die Serverseite komplexe abgeleitete Werte berechnet, die im Client nicht rekonstruierbar sind.
Die manuelle update-Funktion, wie in den vorigen Abschnitten gezeigt, vermeidet zusätzliche Requests vollständig, erfordert aber mehr Code und ein genaues Verständnis der betroffenen Cache-Struktur. cache.evict wiederum entfernt eine Entität oder ein Feld komplett aus dem Apollo Client Cache, etwa nach dem Löschen eines Datensatzes. Nach einem evict-Aufruf bleiben oft verwaiste Referenzen zurück, die erst durch einen expliziten cache.gc()-Aufruf per Garbage Collection entfernt werden.
// Three invalidation strategies compared in code
import { gql, useMutation } from "@apollo/client";
const DELETE_PRODUCT = gql`
mutation DeleteProduct($id: ID!) {
deleteProduct(id: $id) {
success
}
}
`;
// Strategy A: refetchQueries — simple, costs an extra round trip
const [deleteProductRefetch] = useMutation(DELETE_PRODUCT, {
refetchQueries: ["GetProductList"],
awaitRefetchQueries: true,
});
// Strategy B: manual update — no extra request, more code
const [deleteProductManual] = useMutation(DELETE_PRODUCT, {
update(cache, _result, { variables }) {
cache.modify({
fields: {
products(existingRefs = [], { readField }) {
return existingRefs.filter(
(ref) => readField("id", ref) !== variables.id
);
},
},
});
},
});
// Strategy C: cache.evict + cache.gc — removes the entity entirely
const [deleteProductEvict] = useMutation(DELETE_PRODUCT, {
update(cache, _result, { variables }) {
const id = cache.identify({ __typename: "Product", id: variables.id });
cache.evict({ id });
cache.gc();
},
});
In der Praxis kombiniert man häufig Strategie B und C: cache.evict entfernt das Objekt selbst aus dem Apollo Client Cache, während eine begleitende cache.modify-Funktion die Referenz aus allen Listen entfernt, die noch aktiv beobachtet werden. Diese Kombination vermeidet sowohl unnötige Netzwerk-Requests als auch verwaiste Referenzen, die sonst zu Laufzeitfehlern führen können, wenn eine Komponente versucht, ein bereits gelöschtes Objekt zu rendern.
7. Pagination im Cache: relayStylePagination und merge
Paginierte Felder sind im Apollo Client Cache ein Sonderfall, weil dasselbe Feld je nach Argumenten unterschiedliche Ergebnismengen liefert, aber trotzdem in einer sinnvollen Gesamtliste zusammengeführt werden soll. Ohne eigene Konfiguration überschreibt der Apollo Client Cache bei jedem neuen Request standardmäßig die vorherigen Ergebnisse desselben Feldes, was Infinite-Scroll-Implementierungen sofort bricht: Seite zwei würde Seite eins ersatzlos ersetzen.
Für den offset-basierten Fall reicht oft eine eigene merge-Funktion, die bestehende und neue Ergebnisse zusammenführt. Für Relay-konforme Connections mit edges, node und pageInfo liefert @apollo/client/utilities die fertige Hilfsfunktion relayStylePagination, die genau dieses Zusammenführen inklusive Cursor-Verwaltung übernimmt, ohne dass man die Merge-Logik selbst schreiben muss.
// Field policies for pagination in the Apollo Client cache
import { InMemoryCache } from "@apollo/client";
import { relayStylePagination } from "@apollo/client/utilities";
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Relay-style connection — cursor merging handled automatically
products: relayStylePagination(["categoryId"]),
// Offset-based pagination — custom merge function
reviews: {
keyArgs: ["productId"],
merge(existing = [], incoming, { args }) {
const offset = args?.offset ?? 0;
const merged = existing ? existing.slice(0) : [];
for (let i = 0; i < incoming.length; i++) {
merged[offset + i] = incoming[i];
}
return merged;
},
},
},
},
},
});
Der zweite Parameter von relayStylePagination entspricht keyArgs und legt fest, welche Argumente eine eigene Cache-Zeile erzeugen, während Pagination-Argumente wie after oder first bewusst ausgeschlossen bleiben, damit alle Seiten in dieselbe zusammengeführte Liste einfließen. Fehlt diese Konfiguration, entstehen im Apollo Client Cache pro Argumentkombination unabhängige, nicht zusammengeführte Listen, was zu doppelt geladenen und scheinbar verschwundenen Einträgen führt.
8. Typische Fallstricke im Apollo Client Cache
Der häufigste Fehler ist eine fehlende oder falsche id in einer Query. Fragt eine Komponente ein Feld ab, ohne id mit anzufragen, kann der Apollo Client Cache das zurückgegebene Objekt nicht normalisieren und speichert es stattdessen eingebettet unter dem Query-Pfad. Wird dieselbe Entität an anderer Stelle mit id abgefragt, entstehen im Cache zwei getrennte Kopien derselben Daten, die unabhängig voneinander veralten können. Die Faustregel: Jede Query, die eine Entität mit eigenem id-Feld enthält, sollte diese id immer mit abfragen, auch wenn die UI sie gar nicht direkt anzeigt.
Ein zweiter klassischer Fallstrick sind doppelte Netzwerk-Requests durch unterschiedliche fetchPolicy-Einstellungen. Nutzt eine Komponente network-only, während eine andere dieselbe Query mit cache-first abfragt, laufen beide unabhängig, obwohl der Apollo Client Cache theoretisch beide bedienen könnte. Auch inkonsistente Variablen-Objekte sind ein Problem: Zwei Aufrufe derselben Query mit inhaltlich identischen, aber unterschiedlich referenzierten Variablen-Objekten können je nach fetchPolicy als unterschiedliche Cache-Einträge behandelt werden.
Veraltete UI nach einer Mutation entsteht fast immer, weil weder update noch refetchQueries konfiguriert wurden und die Mutation-Response Felder enthält, die im Apollo Client Cache nicht automatisch mit bereits geladenen Listen verknüpft werden können. Apollo aktualisiert automatisch nur Entitäten, die bereits über id und __typename im Cache existieren und deren Felder sich in der Mutation-Response 1:1 wiederfinden. Neue Listeneinträge, gelöschte Objekte oder Änderungen an abgeleiteten Aggregatwerten müssen dagegen immer explizit behandelt werden.
9. Update-Strategien im direkten Vergleich
Die vier vorgestellten Update-Strategien im Apollo Client Cache unterscheiden sich deutlich in Netzwerk-Overhead, Komplexität und den Situationen, in denen sie die richtige Wahl sind. Die folgende Tabelle fasst die praxisrelevanten Unterschiede zusammen.
| Ansatz | Wann nutzen | Netzwerk-Overhead | Komplexität |
|---|---|---|---|
| cache.modify | Einzelnes Feld einer bekannten Entität ändern (Zähler, Flag) | Keiner | Niedrig |
| update-Funktion | Neue Listeneinträge einfügen, mehrere Felder gleichzeitig ändern | Keiner | Mittel |
| cache.evict + gc | Objekt vollständig löschen, verwaiste Referenzen bereinigen | Keiner | Mittel |
| refetchQueries | Server berechnet komplexe abgeleitete Werte, clientseitig nicht rekonstruierbar | Hoch | Niedrig |
Als Faustregel gilt: cache.modify und die manuelle update-Funktion sollten die Standardwahl im Apollo Client Cache sein, weil sie ohne zusätzliche Requests auskommen und dem Nutzer sofortiges Feedback geben. refetchQueries bleibt die Notlösung für Fälle, in denen serverseitige Berechnungen zu komplex sind, um sie clientseitig nachzubilden, etwa bei Rabattlogik oder Steuerberechnungen, die von vielen Faktoren gleichzeitig abhängen.
Mironsoft
GraphQL-Frontends, Apollo-Client-Architektur und Headless-Magento-Integrationen
Apollo Client Cache, der eure UI konsistent hält?
Wir analysieren bestehende Apollo-Client-Setups, entfernen unnötige Refetches und bauen typePolicies, cache.modify-Strategien und optimistic Responses, die euer React-Frontend spürbar schneller machen.
Cache-Audit
Analyse bestehender typePolicies, keyFields und Pagination-Field-Policies auf Inkonsistenzen
Update-Strategien
Refetches durch cache.modify und optimistic Responses ersetzen, wo sinnvoll
Headless-Integration
Apollo Client Cache für Magento-GraphQL-Frontends sauber konfigurieren
10. Zusammenfassung
Der Apollo Client Cache ist kein simpler Zwischenspeicher, sondern ein normalisierter Graph, der Entitäten anhand von __typename und id flach ablegt und über alle beobachtenden Queries hinweg konsistent hält. typePolicies mit keyFields passt diese Normalisierung an reale Backend-Datenmodelle an, während cache.readQuery, cache.writeQuery und vor allem cache.modify gezielte, netzwerkfreie Updates einzelner Felder ermöglichen. Optimistic Responses schließen die verbleibende Lücke zur wahrgenommenen Latenz, indem sie UI-Feedback liefern, bevor die Server-Antwort überhaupt eintrifft.
Bei der Cache-Invalidierung nach Mutations gilt: cache.modify und manuelle update-Funktionen sind fast immer die effizientere Wahl gegenüber refetchQueries, weil sie ohne zusätzliche Requests auskommen. relayStylePagination und eigene merge-Funktionen lösen das Zusammenführen paginierter Ergebnisse strukturiert, statt bei jedem Request die vorherige Seite zu überschreiben. Wer diese Mechanismen bewusst einsetzt, vermeidet die häufigsten Fallstricke: fehlende id-Felder, inkonsistente fetchPolicy-Kombinationen und veraltete UI nach unbehandelten Mutations.
Apollo Client Cache — Das Wichtigste auf einen Blick
Normalisierung
__typename + id normalisieren Objekte zu einem flachen Graphen. keyFields passt das an zusammengesetzte oder alternative Schlüssel an.
cache.modify
Gezielte Feldänderungen an bekannten Entitäten ohne Refetch — der effizienteste Weg für Zähler, Flags und Listen-Updates.
Optimistic Responses
Sofortiges UI-Feedback vor der Server-Antwort. update-Funktion läuft zweimal — muss idempotent sein.
Pagination
relayStylePagination oder eigene merge-Funktion verhindern, dass neue Seiten alte Ergebnisse überschreiben.