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:
- Normalized cache โ automatically stores entities by ID so updates propagate everywhere.
- Reactive variables โ lightweight observable stores for UI state outside the cache.
- Local-only fields โ the
@clientdirective lets you resolve fields locally. - Field policies โ customize how fields are read, merged, and paginated.
- Cache modifiers โ imperative APIs like
updateQueryandcache.modify.
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
- Keep remote data in the cache, UI state in reactive variables. Don't force modal toggles or filter selections into the normalized cache โ reactive variables are lighter and simpler.
- Always include
idand__typenamein queries. Normalization depends on these fields. Without them, Apollo cannot deduplicate entities. - Prefer
cache.modifyoverwriteQueryfor list updates. It is more resilient to changes in query shape and avoids overwriting unrelated fields. - Use
keyArgson paginated fields. Without it, Apollo may merge results from different filters into a single list. - Co-locate local field policies in a dedicated module. This keeps your cache configuration maintainable as your schema grows.
- Use optimistic updates sparingly. They shine for simple toggles and additions but can create confusing UX for complex, multi-step mutations.
- Leverage Apollo DevTools. The cache inspector lets you visualize normalized data, watch reactive variable changes, and debug query flows.
- Test cache updates in isolation. Mock the cache with
mockClientor test helpers from@apollo/client/testingto verify yourupdatefunctions behave correctly.
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.