โ† Back to DevBytes

State Management in Apollo: Patterns and Libraries

State Management in Apollo: Patterns and Libraries

Apollo Client is widely known as a GraphQL data-fetching library, but it also ships with a powerful, built-in state management system. Instead of pairing Apollo with Redux, MobX, or Zustand, many teams choose to manage both remote and local state entirely within Apollo. This tutorial walks through the core concepts, practical patterns, and best practices for managing application state with Apollo Client.

What Is Apollo State Management?

State management in Apollo refers to the mechanisms Apollo Client provides for storing, reading, and updating data beyond what comes directly from your GraphQL server. At its core, Apollo maintains a normalized in-memory cache that acts as a single source of truth for your application's data. On top of this cache, Apollo offers several tools for managing local state:

Why It Matters

When you fetch data with Apollo, that data already lives in a reactive store. Adding a second state library means synchronizing two sources of truth, which leads to bugs, duplicated logic, and increased bundle size. By consolidating state inside Apollo, you gain a unified data layer where remote and local state share the same query language, the same reactivity model, and the same dev tools. This reduces cognitive overhead and simplifies testing.

Setting Up Apollo Client

Before exploring state patterns, let's establish a baseline Apollo Client configuration. Install the required packages:

npm install @apollo/client graphql

Then initialize the client with a cache and link:

import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";

const client = new ApolloClient({
  link: new HttpLink({ uri: "https://api.example.com/graphql" }),
  cache: new InMemoryCache({
    typePolicies: {
      // We'll add policies here shortly
    },
  }),
});

The InMemoryCache is the heart of Apollo's state management. It normalizes responses by __typename and id, storing each entity once and referencing it everywhere it appears.

Reading and Writing the Cache

Imperative Cache Access

You can read from and write to the cache directly using readQuery, readFragment, writeQuery, and writeFragment. This is useful when you need to update state after a mutation without refetching from the server.

import { gql } from "@apollo/client";

const GET_TODOS = gql`
  query GetTodos {
    todos {
      id
      text
      completed
    }
  }
`;

function addTodoLocally(cache, newTodo) {
  const { todos } = cache.readQuery({ query: GET_TODOS });
  cache.writeQuery({
    query: GET_TODOS,
    data: { todos: [...todos, newTodo] },
  });
}

Using Fragments for Targeted Updates

Fragments let you update a single entity without reading the entire query result. This is more efficient and less error-prone:

import { gql } from "@apollo/client";

const TODO_FRAGMENT = gql`
  fragment TodoFields on Todo {
    id
    text
    completed
  }
`;

function toggleTodoLocally(cache, id, completed) {
  cache.writeFragment({
    id: `Todo:${id}`,
    fragment: TODO_FRAGMENT,
    data: { id, completed, __typename: "Todo" },
  });
}

The id argument combines the __typename and the entity's id to locate the cached object.

Reactive Variables

Reactive variables are Apollo's answer to standalone UI state that doesn't belong in the normalized cache โ€” things like selected filters, theme preferences, or modal visibility. They are simple functions that hold a value and notify subscribers when it changes.

Creating a Reactive Variable

import { makeVar } from "@apollo/client";

export const selectedFilterVar = makeVar("all");
export const themeVar = makeVar("light");

Reading and Updating

Call the variable with no arguments to read its value, and with an argument to set it:

import { selectedFilterVar } from "./state";

// Read
const current = selectedFilterVar();

// Write
selectedFilterVar("completed");

Subscribing in Components

Use the useReactiveVar hook to subscribe a component to a reactive variable:

import { useReactiveVar } from "@apollo/client";
import { selectedFilterVar } from "./state";

function FilterBar() {
  const filter = useReactiveVar(selectedFilterVar);

  return (
    <div>
      <button onClick={() => selectedFilterVar("all")}>All</button>
      <button onClick={() => selectedFilterVar("active")}>Active</button>
      <button onClick={() => selectedFilterVar("completed")}>Completed</button>
      <p>Current filter: {filter}</p>
    </div>
  );
}

Reactive variables integrate seamlessly with Apollo queries. You can even expose them as local-only fields so components can read them through standard GraphQL queries.

Local-Only Fields with @client

The @client directive tells Apollo to resolve a field locally instead of sending it to the server. This lets you combine remote and local state in a single query.

Defining Local Resolvers

Modern Apollo (v3+) prefers field policies over the older resolvers map. Here's how to define a local field using a read function backed by a reactive variable:

import { InMemoryCache, makeVar } from "@apollo/client";

export const cartOpenVar = makeVar(false);

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        cartOpen: {
          read() {
            return cartOpenVar();
          },
        },
        isLoggedIn: {
          read() {
            return localStorage.getItem("token") !== null;
          },
        },
      },
    },
  },
});

Querying Local Fields

Now any component can query cartOpen and isLoggedIn just like server fields:

import { gql, useQuery } from "@apollo/client";

const GET_LOCAL_STATE = gql`
  query GetLocalState {
    cartOpen @client
    isLoggedIn @client
  }
`;

function Header() {
  const { data } = useQuery(GET_LOCAL_STATE);

  return (
    <header>
      {data?.isLoggedIn && <button>Logout</button>}
      {data?.cartOpen && <div className="cart">Cart is open</div>}
    </header>
  );
}

This pattern is powerful because your components never need to know whether a field comes from the server or from local state โ€” they just query it.

Field Policies for Cache Behavior

Field policies control how Apollo reads and writes specific fields. They are essential for pagination, merging paginated lists, and customizing default behavior.

Pagination with concatPagination

import { InMemoryCache, concatPagination } from "@apollo/client";

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: concatPagination(),
      },
    },
  },
});

Custom Merge Functions

For more control, define a custom merge function. This is useful when the server returns paginated results with cursors:

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: ["type"],
          merge(existing = [], incoming, { args }) {
            const merged = existing ? existing.slice(0) : [];
            const offset = args?.offset ?? 0;
            for (let i = 0; i < incoming.length; i++) {
              merged[offset + i] = incoming[i];
            }
            return merged;
          },
        },
      },
    },
  },
});

The keyArgs option tells Apollo which arguments affect the cache key, so different filter values are stored separately.

Updating State After Mutations

After a mutation completes, you often need to update the cache to reflect the change. Apollo provides two main approaches: the update callback and cache.modify.

Using the update Callback

import { gql, useMutation } from "@apollo/client";

const ADD_TODO = gql`
  mutation AddTodo($text: String!) {
    addTodo(text: $text) {
      id
      text
      completed
    }
  }
`;

function AddTodoForm() {
  const [addTodo] = useMutation(ADD_TODO, {
    update(cache, { data: { addTodo } }) {
      cache.modify({
        fields: {
          todos(existingTodos = []) {
            const newTodoRef = cache.writeFragment({
              data: addTodo,
              fragment: gql`
                fragment NewTodo on Todo {
                  id
                  text
                  completed
                }
              `,
            });
            return [...existingTodos, newTodoRef];
          },
        },
      });
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const input = e.target.elements.text;
        addTodo({ variables: { text: input.value } });
        input.value = "";
      }}
    >
      <input name="text" />
      <button type="submit">Add</button>
    </form>
  );
}

Using cache.modify for Deletions

For removing items, cache.modify with an evict call is the cleanest approach:

const [deleteTodo] = useMutation(DELETE_TODO, {
  update(cache, { data: { deleteTodo } }) {
    cache.evict({ id: `Todo:${deleteTodo.id}` });
    cache.gc();
  },
});

Calling cache.gc() removes orphaned references from the cache after eviction.

Optimistic Updates

Optimistic updates make your UI feel instant by applying predicted changes before the server responds. Apollo rolls them back automatically if the mutation fails.

const [toggleTodo] = useMutation(TOGGLE_TODO, {
  optimisticResponse: ({ id, completed }) => ({
    __typename: "Mutation",
    toggleTodo: {
      __typename: "Todo",
      id,
      completed: !completed,
    },
  }),
  update(cache, { data: { toggleTodo } }) {
    cache.writeFragment({
      id: `Todo:${toggleTodo.id}`,
      fragment: gql`
        fragment ToggleFields on Todo {
          completed
        }
      `,
      data: { completed: toggleTodo.completed },
    });
  },
});

Best Practices

Conclusion

Apollo Client provides a complete state management solution that extends well beyond simple data fetching. By combining the normalized cache for remote entities, reactive variables for UI state, local-only fields for a unified query interface, and field policies for fine-grained cache control, you can build complex applications without reaching for a separate state library. The key is understanding which tool fits each scenario: use the cache for server data, reactive variables for ephemeral UI state, and cache.modify or update callbacks for post-mutation synchronization. When applied consistently, these patterns produce a predictable, maintainable, and performant state layer that scales with your application.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles