Managing Local State in Apollo Client: Reactive Variables vs. Cache Normalization
Stop mixing Redux with Apollo. Learn how to use InMemoryCache normalization for server data and Reactive Variables for local UI state to create a single source of truth.
17 Apr 2026, 05:06 UTC

The Struggle with Fragmented State
When building a GraphQL-powered application, you often hit a wall where some data lives on the server (like a user profile) and some lives only in the browser (like a "isSidebarOpen" toggle or a temporary filter string). The common mistake is introducing a second state management library—like Redux or Zustand—alongside Apollo Client. This creates a fragmented architecture where you are syncing two different sources of truth, leading to redundant boilerplate and synchronization bugs.
The takeaway: You can handle both remote and local state within Apollo Client by combining InMemoryCache normalization for entity data and Reactive Variables for global UI state.
How Apollo Normalizes Remote Data
Apollo Client doesn't store query results as a nested tree. Instead, it uses a process called normalization. When a query returns an object with an id and a __typename, the InMemoryCache flattens that object into a lookup table. For example, a User object with ID 123 is stored as User:123 regardless of which query fetched it.
This is critical for UI consistency. If you update the name of User:123 in a profile edit mutation, every other component on the screen displaying that user updates automatically because they all point to the same normalized reference in the cache.
Using Reactive Variables for Local State
Not everything belongs in a normalized cache. UI toggles or session-based filters don't have a __typename or a unique ID. For this, Apollo provides Reactive Variables. These are standalone variables that exist outside the cache but are integrated into the Apollo ecosystem.
When a Reactive Variable changes, any GraphQL query that references that variable via the @client directive will automatically trigger a re-render. This allows you to keep your components "GraphQL-pure," as they only ever interact with the useQuery hook, regardless of where the data actually resides.
Implementation Example: Mixing Remote and Local State
Assume we are using Apollo Client v3.x. We want to fetch a user's name from the server but track whether the user has "starred" the profile locally.
import { makeVar, ApolloClient, InMemoryCache, gql } from '@apollo/client';
// 1. Define a Reactive Variable for local state
export const isStarredVar = makeVar(false);
const client = new ApolloClient({
uri: 'https://your-api.com/graphql',
cache: new InMemoryCache(),
});
// 2. Query mixing remote data and local state
const GET_USER_PROFILE = gql`
query GetUserProfile {
user(id: "123") {
id
username
# The @client directive tells Apollo to look locally
isStarred @client
}
}
`;
// 3. To update the state from anywhere in the app:
isStarredVar(true);
Trade-offs and Limitations
While this approach simplifies the stack, there are specific constraints to keep in mind:
- Normalization Failures: If your server schema omits the
idfield or the__typenameis missing, Apollo cannot normalize the object. It will store the data as a nested blob, meaning updates to that entity won't propagate across the UI. - Complexity of cache.modify: While Reactive Variables are simple, updating complex lists inside the normalized cache requires
cache.modify. This API can become verbose and error-prone when dealing with deeply nested references. - State Persistence: Reactive Variables are stored in memory. If the user refreshes the page, the state is wiped unless you manually implement a synchronization layer with
localStorage.
Verifying Your State Strategy
To ensure your data is flowing correctly, use the Apollo Client DevTools browser extension. Check the Cache tab: if you see a flat list of objects (e.g., User:123, Post:456), normalization is working. If you see large, nested query results, you are missing IDs in your schema.
To verify Reactive Variables, trigger a value change (e.g., isStarredVar(!isStarredVar())) and confirm that the component using the @client query re-renders without a page refresh or a network request.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.