Apollo Client in React: GraphQL-Cache richtig normalisieren
AI generated
</>
{ }
React · GraphQL · Apollo Client · Caching
Apollo Client in React
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.

18 Min. Lesezeit InMemoryCache · Type Policies · optimisticResponse · Pagination React 19 · Apollo Client 3.x

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.

11. FAQ: Apollo Client und GraphQL-Cache-Normalisierung

1Warum normalisiert Apollo Client überhaupt?
Damit dasselbe Objekt aus unterschiedlichen Queries konsistent bleibt. Ändert es sich an einer Stelle, sehen alle referenzierenden Komponenten automatisch den neuen Zustand.
2Was, wenn ein Objekt keine id hat?
Apollo speichert es eingebettet im übergeordneten Feld. Updates an anderer Stelle werden dann nicht automatisch propagiert.
3Wann brauche ich keyFields?
Bei zusammengesetzten Schlüsseln wie orderId und lineNumber, oder wenn das Identifikationsfeld anders als id oder _id heißt.
4Warum überschreibt der Refetch meine Liste?
Ohne merge-Funktion ersetzt Apollo den Feld-Wert komplett. relayStylePagination oder eine eigene merge-Funktion hängt Seiten stattdessen an.
5cache.modify vs. cache.writeQuery?
modify ändert gezielt einzelne Feld-Werte. writeQuery schreibt ein komplettes Query-Ergebnis, nützlich beim Einfügen neuer Objekte.
6Rollt optimisticResponse automatisch zurück?
Ja. Schlägt die Mutation fehl, entfernt Apollo Client die optimistische Antwort und stellt den vorherigen Zustand wieder her.
7Wie finde ich widersprüchliche Cache-Einträge?
Mit den Apollo Client DevTools, die den normalisierten Cache als durchsuchbaren Baum anzeigen. Ursache sind meist fehlende id-Felder.
8Wann cache.gc() aufrufen?
Nach jedem manuellen evict(). gc() entfernt nicht mehr referenzierte Objekte und verhindert wachsenden Speicherverbrauch.
9Brauche ich Fragments bei nur einer Query?
Nicht zwingend, aber sie zahlen sich aus, sobald mehrere Queries dieselben Felder für denselben Typ anfragen.
10Ist Apollo Client für kleine Projekte zu groß?
Für sehr kleine Projekte reicht oft urql oder graphql-request. Bei überlappenden Daten über mehrere Views zahlt sich Apollo Client aus.