The Art of Caching: State Management in GraphQL UIs

In Part 2, we architected an efficient data flow from the server. But what happens once that data arrives on the client? Fetching data efficiently is pointless if you have to fetch it again every time the user navigates. This is where caching transforms a good app into a great one.

A cache is the beating heart of a modern frontend. It turns loading spinners into instant interactions and provides a single source of truth for your application’s state.

The Magic of Normalized Caching

Sophisticated GraphQL clients like Apollo and Relay don’t just store raw JSON responses. They use a normalized cache. Instead of storing a nested tree of data, they flatten it, giving each object a unique ID. Think of it as a mini, in-memory database.

Example: You fetch a post and its author.

// Raw Response
{
  "post": {
    "id": "p1",
    "title": "My Post",
    "author": {
      "id": "u1",
      "name": "Jane Doe"
    }
  }
}

A naive cache stores this whole blob. A normalized cache stores:

  • Post:p1: { title: "My Post", author: "User:u1" }
  • User:u1: { name: "Jane Doe" }

Why does this matter? If Jane updates her name anywhere in the app, every component that displays User:u1 will update automatically and consistently. This is the key to a predictable UI.

Apollo/Relay vs. Custom Caches: A Trade-Off

  • Apollo/Relay: These frameworks provide powerful, normalized caches out of the box. They handle the heavy lifting of automatic updates, garbage collection, and deep integration with React. The trade-off is a steeper learning curve and more boilerplate.
  • Custom Caches (React Query, SWR): These libraries offer simpler, key-value based caching. They are incredibly flexible and easy to start with but place the burden of normalization and manual cache updates on you. As your app grows, you may find yourself rebuilding features that Apollo or Relay give you for free.

The core question to ask is: Do you want to build a cache, or build your feature? For complex applications, a normalized cache is almost always the right long-term investment.

The Hardest Problem in Computer Science: Cache Invalidation

When data changes on the server, the client’s cache becomes stale. How do you update it? You have two main options:

  1. Refetching (The Simple Way): After a mutation, simply tell your client to re-run the queries affected by the change. This is easy and guarantees consistency but can feel slow, as it requires another network round trip.

    const [addPost] = useMutation(ADD_POST, {
      refetchQueries: [GET_POSTS]
    });
    
  2. Direct Cache Manipulation (The Fast Way): For an optimistic UI that feels instant, you can surgically update the cache yourself. When a mutation completes, you use its response to write the new data directly into the normalized store.

    const [addPost] = useMutation(ADD_POST, {
      update(cache, { data: { newPost } }) {
        const { posts } = cache.readQuery({ query: GET_POSTS });
        cache.writeQuery({
          query: GET_POSTS,
          data: { posts: [newPost, ...posts] },
        });
      }
    });
    

This is more complex but provides a superior user experience.

How Hydration Connects SSR and Caching

Server-Side Rendering (SSR) and caching are a powerful duo. The process is elegant:

  1. The server receives a request for a page.
  2. It fetches all the necessary GraphQL data.
  3. It renders the page to HTML and serializes the fetched data into a script tag (e.g., <script id="__APOLLO_STATE__">...).
  4. The browser receives the fully-rendered HTML and the data payload.
  5. The client-side GraphQL client boots up, sees the serialized data, and hydrates its cache with it before rendering anything.

This means the client doesn’t need to re-fetch any data on its initial load. The page appears instantly and is immediately interactive.

Conclusion: Predictability Is a Form of Performance

A well-architected cache does more than just make an app fast; it makes it predictable. By normalizing data and managing state with intention, you eliminate an entire class of bugs related to inconsistent or stale UI. Users perceive this reliability as a core part of the performance experience.

In Part 4, we will take a deeper dive into the world of SSR and Hydration, exploring the trade-offs between different rendering strategies in Next.js to achieve the perfect balance of SEO, speed, and server load.