Apollo Client in React: Normalizing the GraphQL Cache Correctly
AI generated
</>
{ }
React · GraphQL · Apollo Client · Caching
Apollo Client in React
normalizing the GraphQL cache correctly

Apollo Client automatically normalizes GraphQL responses into a flat object graph, but without clean type policies and thoughtful cache updates, stale views appear quickly. This article shows how InMemoryCache really works, how custom cache keys are defined with keyFields, and how mutations, pagination and debugging play together in practice.

18 min read InMemoryCache · Type Policies · optimisticResponse · Pagination React 19 · Apollo Client 3.x

1. Why cache normalization matters with Apollo Client

Apollo Client is more than a fetch wrapper for GraphQL, because it breaks down every response into a normalized object graph and keeps that graph consistent across all components. Someone editing a user in a list sees the change without an extra refetch in the detail view too, provided the cache was normalized correctly. This exact effect is what distinguishes Apollo Client from a simple fetch and store approach, and it is the real reason teams accept the added complexity.

The catch is that normalization only works if Apollo can identify every object uniquely. If an ID is missing from a query result, or a custom ID is configured incorrectly, several objects that are actually identical fall apart in the GraphQL cache, and components show conflicting data. The result is bugs that only reproduce sporadically, because they depend on the order in which queries run. The following sections show how to systematically avoid these traps, from the basics of normalization to pagination and debugging.

2. InMemoryCache: how Apollo Client normalizes

InMemoryCache is the default cache implementation of Apollo Client and works on a simple but powerful principle: every object with a __typename and an id (or _id) is stored as its own entry in the cache, referenced through a key in the format Typename:id. A query that returns an array of posts with nested authors is therefore not stored as a nested blob, but broken down into individual, flat objects that reference each other.

This flat object graph is why Apollo Client can propagate updates so efficiently. If an author object is updated through a different query or mutation, every component referencing that author anywhere automatically notices the change, no manual refetch required. The precondition is that the query actually requests the id field. A common beginner mistake is leaving id out of a query because it is not needed in the UI, which completely disables normalization for that object and turns it into an unidentifiable, embedded object.


// 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: custom cache keys with keyFields

Not every API provides a simple numeric id. Some types are identified by composite keys, for example an order line through orderId and lineNumber together. For these cases, Apollo Client offers keyFields in type policies, which let you define exactly which fields together form a type's unique key. Without this configuration, Apollo would either use an incorrect default ID or treat the object as not normalizable, leading to duplicate cache entries for the same logical object.

Another important tool is read and merge functions at the field level. A read function can, for example, deliver a computed value such as a formatted price directly from the cache, without the server having to send that value. A merge function decides how new data is merged with already existing field values, which is especially crucial for arrays and pagination. Whoever configures these policies deliberately prevents the most common source of errors in the GraphQL cache: a refetch unintentionally overwriting an already loaded and correctly sorted list.


// 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: keeping queries consistent and reusable

GraphQL fragments are the second foundation for a clean GraphQL cache. A fragment bundles the fields a component needs for its rendering into a named, reusable unit. When two different queries include the same fragment for the type Product, Apollo Client guarantees that both queries request exactly the same fields and thus fill the same cache entry consistently. If fields a component actually needs are missing from a query, so called partial cache hits occur, which force Apollo into a silent network refetch.

Fragment colocation, meaning defining the fragment directly next to the component that uses it, prevents data requirements and rendering logic from drifting apart. If a component changes and needs an extra field, the fragment is extended right where it is used, without having to search through a central, unwieldy query file. This practice scales considerably better than monolithic queries and is one of the main reasons Apollo Client stays maintainable in large React codebases.

5. Mutations: update function, cache.modify and cache.writeQuery

A mutation in Apollo Client automatically updates the cache entry of the returned object by default, provided the response contains the same fields as an existing query. For anything beyond that, such as removing an item from a list or updating a counter, an explicit update function is needed. This function gets access to the cache and can use cache.modify to change individual fields precisely, without refetching the whole affected query.

cache.modify is more precise than a full refetch, because it only changes the affected field value and automatically re-renders every component reading that value. For more complex cases where a completely new object needs to be inserted into an existing list, cache.writeQuery or cache.writeFragment is used. Whoever ignores these tools and instead triggers a full refetch of the parent list on every mutation gives away the central performance advantage of Apollo Client and produces unnecessary network load.


// 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 } })}>Delete</button>;
}

6. optimisticResponse: instant feedback without waiting

optimisticResponse is the built-in answer of Apollo Client to the problem that users would otherwise have to wait for every mutation until the server responds. Instead of updating the UI only after the server responds, a preliminary result is immediately written into the cache, shaped exactly like the real response. As soon as the server actually responds, Apollo Client automatically replaces the optimistic result with the real response, without the component having to handle that switch manually.

The decisive difference to React's built in useOptimistic hook is that Apollo integrates this mechanism directly into the global cache. If an optimistically updated object changes, every component in the tree sees the new state immediately, not just the component that triggered the mutation. If the mutation fails, Apollo automatically rolls back the optimistic change and restores the previous cache state, making manual rollback handling unnecessary.

7. Pagination in the cache: relayStylePagination and field policies

Pagination is where most teams first come into deep contact with field policies, because a naive configuration overwrites an already loaded list on every refetch instead of appending new pages. Apollo Client provides the helper function relayStylePagination for this, which supplies a correct merge function for Relay compatible connections with edges and pageInfo, correctly appending new pages to existing entries instead of replacing them.

For offset based pagination without Relay conventions, a custom merge function can be written that determines the correct position in the array based on offset and limit arguments. It is important to set keyArgs correctly so Apollo treats filters such as different sort orders or categories as separate cache entries instead of accidentally mixing them. Without this configuration, inconsistent lists appear as soon as users switch between filters.


// 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() and common failure patterns

The Apollo Client DevTools browser extension shows the entire normalized cache as a searchable tree and is the first tool for any debugging problem around Apollo Client. The built in explorer lets you test queries directly against the running cache without changing code, and you immediately see whether an expected field is actually normalized or sitting embedded, unreferenced, in the cache.

A typical failure pattern is growing memory usage from orphaned cache entries that are no longer referenced after a cache.evict() call, but remain in memory without cache.gc(). cache.gc() removes every object no active query references anymore, and should be run after every manual evict call. A second common problem is conflicting displays between two components, which usually trace back to missing id fields or incorrectly configured keyFields, see section three.

9. Apollo Client compared to other data layer solutions

Apollo Client is not the only way to consume GraphQL in React, and the choice depends heavily on team size, bundle size requirements and the desired control over the cache. The following table compares the most important alternatives, each with a focus on normalization and cache control.

Solution Normalization Bundle size Use case
Apollo Client Automatic, configurable through type policies Medium to large Large teams, complex data models
Relay Strict, enforced at compile time Medium Very large codebases, high discipline
urql Optional, swappable exchanges Small Lightweight applications
TanStack Query + fetch None, cache per query key Small REST heavy or mixed APIs
graphql-request + custom cache Manual Minimal Scripts, simple integrations

For teams already heavily invested in GraphQL and maintaining several connected views with overlapping data, Apollo Client remains the solution with the widest feature set for cache control. Relay enforces even stricter discipline, but demands a compiler step and a steeper learning curve. Teams using GraphQL only occasionally alongside mostly REST endpoints often fare better with TanStack Query and a slim GraphQL client, since the normalization overhead disappears.

Mironsoft

React, GraphQL and data layer architecture from a single source

Apollo Client that reliably delivers consistent data?

We configure type policies, pagination strategies and mutation updates for your GraphQL cache, and fix conflicting views caused by faulty normalization.

Cache audit

Analysis of existing type policies and finding missing keyFields

Pagination setup

relayStylePagination and custom merge functions for lists and infinite scroll

Migration

Moving from manual refetches to cache.modify and optimisticResponse

10. Summary

Apollo Client normalizes GraphQL responses into a flat object graph, provided every object is uniquely identifiable through __typename and id. Type policies with keyFields solve the case of composite keys, while read and merge functions cleanly control computed fields and pagination. Fragments keep queries consistent across components and prevent partial cache hits that lead to silent refetches.

Mutations should use update functions with cache.modify instead of refetching entire parent lists, and optimisticResponse delivers instant UI feedback with automatic rollback on failure. For pagination, relayStylePagination is the standard path for Relay compatible APIs, while everything else needs a custom merge function with correctly set keyArgs. The Apollo DevTools and cache.gc() round out the toolset for the everyday practice of working with Apollo Client.

Apollo Client cache normalization, the essentials at a glance

Normalization

Every object needs a __typename and id, otherwise it is stored as an embedded, unreferenceable object.

Type policies

keyFields for composite keys, read/merge for computed fields and pagination.

Mutations

update with cache.modify instead of a full refetch, optimisticResponse for instant feedback.

Debugging

Apollo DevTools for inspecting the cache, cache.gc() after every manual evict.

11. FAQ: Apollo Client and GraphQL cache normalization

1Why does Apollo Client normalize at all?
So the same object stays consistent across different queries. If it changes in one place, every referencing component automatically sees the new state.
2What if an object has no id?
Apollo stores it embedded in the parent field. Updates elsewhere are not automatically propagated.
3When do I need keyFields?
For composite keys like orderId and lineNumber, or when the identifying field is named differently than id or _id.
4Why does refetch overwrite my list?
Without a merge function, Apollo replaces the field value entirely. relayStylePagination or a custom merge function appends pages instead.
5cache.modify vs. cache.writeQuery?
modify precisely changes individual field values. writeQuery writes a complete query result, useful for inserting new objects.
6Does optimisticResponse roll back automatically?
Yes. If the mutation fails, Apollo Client removes the optimistic response and restores the previous state.
7How do I find conflicting cache entries?
With the Apollo Client DevTools, which show the normalized cache as a searchable tree. Cause is usually missing id fields.
8When to call cache.gc()?
After every manual evict(). gc() removes unreferenced objects and prevents growing memory usage.
9Do I need fragments with just one query?
Not strictly, but they pay off as soon as several queries request the same fields for the same type.
10Is Apollo Client too big for small projects?
For very small projects, urql or graphql-request is often enough. With overlapping data across views, Apollo Client pays off.