Skip to main content

Command Palette

Search for a command to run...

Apollo Client GraphQL: React State Management

Updated
13 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Apollo Client & GraphQL: A Complete Guide to React State Management
≈ 1 600 words


Table of Contents

  1. Why Use Apollo Client for State Management?
  2. Getting Started: Installing & Configuring Apollo
  3. Core Concepts: Queries, Mutations, Cache, and Hooks
  4. [Five Proven Patterns for Managing React State with Apollo]
    1. Pattern 1 – Declarative Data Fetching with useQuery
    2. Pattern 2 – Optimistic UI Updates for Mutations
    3. Pattern 3 – Pagination & Infinite Scrolling (fetchMore)
    4. Pattern 4 – Re‑using Fragments & Local‑Only Fields
    5. Pattern 5 – Centralised Error Handling & Refetch Strategies
  5. Frequently Asked Questions (FAQ)
  6. Conclusion – When Apollo Is the Right Choice

1. Why Use Apollo Client for State Management?

Apollo Client started as a GraphQL data‑fetching library, but over the years it has evolved into a full‑stack state manager that can replace Redux, MobX, or the Context API for many applications. Its key advantages are:

FeatureBenefit for React Developers
Declarative GraphQL queriesNo more manual fetch/axios calls; the UI describes what it needs.
Normalized, in‑memory cacheAutomatic deduplication, cache updates, and UI consistency without extra boilerplate.
React hooks (useQuery, useMutation, useLazyQuery)Seamless integration with functional components and the modern React ecosystem.
Optimistic UI & pagination helpersBuild responsive experiences that feel instant to users.
Local‑state extensionsStore UI‑only data (e.g., modal visibility) alongside remote data in a single store.
DevToolsReal‑time inspection of queries, cache, and network activity.

When your app already talks to a GraphQL server, Apollo gives you a single source of truth for both remote and local data, dramatically reducing the amount of glue code you need to write.


2. Getting Started: Installing & Configuring Apollo

2.1 Install the Packages

npm install @apollo/client graphql
# or with Yarn
yarn add @apollo/client graphql
  • @apollo/client bundles the core client, React bindings, and the cache implementation.
  • graphql provides the language parser needed at runtime.

2.2 Create the Apollo Client Instance

// src/apolloClient.js
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';

// Optional: Global error handling link
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message, locations, path }) =>
      console.warn(`[GraphQL error] Message: ${message}, Location: ${locations}, Path: ${path}`)
    );
  }
  if (networkError) console.warn(`[Network error] ${networkError}`);
});

// HTTP link points to your GraphQL endpoint
const httpLink = new HttpLink({
  uri: 'https://your-graphql-api.com/graphql',
  credentials: 'include', // send cookies if needed
});

// Assemble the client
export const client = new ApolloClient({
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache({
    // Optional: type policies for pagination, field merging, etc.
    typePolicies: {
      Query: {
        fields: {
          users: {
            // Enables offset‑based pagination merging
            keyArgs: false,
            merge(existing = [], incoming) {
              return [...existing, ...incoming];
            },
          },
        },
      },
    },
  }),
  // Enable devtools in production if you like
  connectToDevTools: true,
});

2.3 Wrap Your React Tree

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ApolloProvider } from '@apollo/client';
import { client } from './apolloClient';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
);

Tip: If you need a separate client for SSR (Next.js, Remix, etc.), create a factory function that returns a fresh ApolloClient per request.


3. Core Concepts: Queries, Mutations, Cache, and Hooks

ConceptWhat It DoesTypical Hook
QueryReads data from the server (or cache).useQuery
MutationSends write operations (create, update, delete).useMutation
SubscriptionReal‑time push updates (WebSocket).useSubscription
CacheNormalised store that mirrors the server schema.Direct client.cache access or useApolloClient
Local‑only fieldsUI‑specific data stored in the same cache.@client directive in queries

All of these are expressed as GraphQL documents (gql template literals). The client parses them, sends the request, and writes the response back into the cache. React hooks then subscribe to the cache and re‑render when the relevant slice changes.


4. Five Proven Patterns for Managing React State with Apollo

Below are five patterns you can adopt, each accompanied by a short explanation, code sample, and best‑practice notes.

4.1 Pattern 1 – Declarative Data Fetching with useQuery

When to use: Simple read‑only data that should stay in sync with the server (e.g., a list of users, product catalog, settings).

Key ideas:

  • useQuery returns { data, loading, error, refetch, networkStatus }.
  • The hook automatically subscribes to the cache entry for the query.
  • You can control fetch policy (cache-first, network-only, cache-and-network, etc.) to fine‑tune when the network is hit.

Example – Fetching a list of users

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

const GET_USERS = gql`
  query GetUsers {
    users {
      id
      name
      avatarUrl
    }
  }
`;

export default function UsersList() {
  const { data, loading, error, refetch } = useQuery(GET_USERS, {
    fetchPolicy: 'cache-and-network', // show cached data, then update from network
    pollInterval: 30000, // optional: refresh every 30 s
  });

  if (loading && !data) return <p>Loading users…</p>;
  if (error) return <p>❌ {error.message}</p>;

  return (
    <section>
      <h2>Team Members</h2>
      <button onClick={() => refetch()}>Refresh</button>
      <ul>
        {data.users.map(({ id, name, avatarUrl }) => (
          <li key={id}>
            <img src={avatarUrl} alt={name} width={32} height={32} />
            {name}
          </li>
        ))}
      </ul>
    </section>
  );
}

Best‑practice notes

  • Avoid over‑fetching: Use GraphQL’s field selection to request only what you need.
  • Cache‑first for static data: For rarely‑changing data (e.g., site navigation), cache-first eliminates unnecessary network calls.
  • Polling vs. subscriptions: Use pollInterval only when the server does not support real‑time subscriptions.

4.2 Pattern 2 – Optimistic UI Updates for Mutations

When to use: When a mutation should feel instantaneous (e.g., adding a comment, toggling a like, updating a profile picture).

How it works:

  1. You send a mutation with an optimisticResponse that mimics the shape of the expected server response.
  2. Apollo writes this optimistic data to the cache immediately, causing UI components that depend on the affected query to re‑render.
  3. When the real response arrives, Apollo reconciles the optimistic data with the actual data.

Example – Adding a new user

import { gql, useMutation } from '@apollo/client';
import { v4 as uuidv4 } from 'uuid';

const ADD_USER = gql`
  mutation AddUser($name: String!) {
    addUser(name: $name) {
      id
      name
    }
  }
`;

const GET_USERS = gql`
  query GetUsers {
    users {
      id
      name
    }
  }
`;

export default function AddUserForm() {
  const [addUser, { loading, error }] = useMutation(ADD_USER, {
    // Update the cache manually after the mutation succeeds
    update(cache, { data: { addUser } }) {
      const existing = cache.readQuery({ query: GET_USERS });
      cache.writeQuery({
        query: GET_USERS,
        data: { users: [addUser, ...existing.users] },
      });
    },
  });

  const handleSubmit = async (e) => {
    e.preventDefault();
    const name = e.target.elements.name.value.trim();
    if (!name) return;

    await addUser({
      variables: { name },
      optimisticResponse: {
        __typename: 'Mutation',
        addUser: {
          __typename: 'User',
          id: uuidv4(), // temporary client‑side ID
          name,
        },
      },
    });

    e.target.reset();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="New user name" required />
      <button type="submit" disabled={loading}>
        {loading ? 'Saving…' : 'Add User'}
      </button>
      {error && <p className="error">❗ {error.message}</p>}
    </form>
  );
}

Why it matters

  • Perceived performance: Users see the new item instantly, even before the server acknowledges it.
  • Consistency: Because the optimistic data lives in the same normalized cache, any component that reads the users query will instantly reflect the change.

Caveats

  • The optimistic response must match the server’s shape exactly, including __typename fields.
  • If the mutation fails, Apollo automatically rolls back the optimistic update and surfaces the error.

4.3 Pattern 3 – Pagination & Infinite Scrolling (fetchMore)

When to use: Lists that can be arbitrarily long (e.g., feeds, product catalogs, search results).

Two common pagination strategies

  1. Offset‑based (skip/limit) – simple but can cause duplicate rows if items are inserted/removed while paging.
  2. Cursor‑based (after/first) – more robust; recommended for production APIs.

Implementation with fetchMore (offset example)

import { gql, useQuery } from '@apollo/client';
import { useEffect, useRef } from 'react';

const GET_USERS = gql`
  query GetUsers($limit: Int!, $offset: Int!) {
    users(limit: $limit, offset: $offset) {
      id
      name
    }
  }
`;

export default function InfiniteUsers() {
  const { data, loading, error, fetchMore, networkStatus } = useQuery(
    GET_USERS,
    {
      variables: { limit: 10, offset: 0 },
      notifyOnNetworkStatusChange: true, // needed to detect loading more
    }
  );

  const loadingMore = networkStatus === 3; // 3 = fetchMore

  // Simple scroll listener – in production use IntersectionObserver
  const sentinelRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && data?.users?.length) {
          fetchMore({
            variables: {
              offset: data.users.length,
            },
            updateQuery: (prev, { fetchMoreResult }) => {
              if (!fetchMoreResult) return prev;
              return {
                users: [...prev.users, ...fetchMoreResult.users],
              };
            },
          });
        }
      },
      { rootMargin: '200px' }
    );

    if (sentinelRef.current) observer.observe(sentinelRef.current);
    return () => observer.disconnect();
  }, [data, fetchMore]);

  if (error) return <p>❌ {error.message}</p>;

  return (
    <section>
      <h2>All Users</h2>
      <ul>
        {data?.users?.map((u) => (
          <li key={u.id}>{u.name}</li>
        ))}
      </ul>

      {loading && <p>Loading…</p>}
      {loadingMore && <p>Loading more…</p>}

      {/* Sentinel element for IntersectionObserver */}
      <div ref={sentinelRef} />
    </section>
  );
}

Key points

  • fetchMore merges the new page into the existing cache using the updateQuery function (or a typePolicy merge function, as shown in the client setup).
  • notifyOnNetworkStatusChange lets the component differentiate between the initial load (loading) and a “load‑more” state (loadingMore).
  • For cursor‑based pagination, replace offset with after and use the pageInfo fields (hasNextPage, endCursor) to decide when to stop.

Performance tip: Use React.memo or React.lazy for list items when the list grows large, and consider virtualization libraries like react-window.


4.4 Pattern 4 – Re‑using Fragments & Local‑Only Fields

4.4.1 GraphQL Fragments

Fragments let you define reusable field sets, reducing duplication across queries and mutations.

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

export const USER_FIELDS = gql`
  fragment UserFields on User {
    id
    name
    avatarUrl
    __typename
  }
`;

export const GET_USERS = gql`
  query GetUsers {
    users {
      ...UserFields
    }
  }
  ${USER_FIELDS}
`;

export const ADD_USER = gql`
  mutation AddUser($name: String!) {
    addUser(name: $name) {
      ...UserFields
    }
  }
  ${USER_FIELDS}
`;

Benefits

  • Single source of truth for the shape of a type.
  • Easier refactoring: Change the fragment once, and all queries update automatically.
  • Cache consistency: Because the fragment includes __typename, Apollo can correctly normalize and merge objects.

4.4.2 Storing UI‑Only State in the Apollo Cache

Sometimes you need UI state (e.g., a modal’s open/closed flag) that doesn’t belong in Redux or component state. Apollo lets you store it alongside remote data using the @client directive.

// src/localState.js
import { gql, makeVar } from '@apollo/client';

// Reactive variable (a lightweight local store)
export const isAddUserModalOpenVar = makeVar(false);

// Local‑only query
export const GET_MODAL_STATE = gql`
  query GetModalState {
    isAddUserModalOpen @client
  }
`;

Using the reactive variable

import { useReactiveVar } from '@apollo/client';
import { isAddUserModalOpenVar } from './localState';

export default function AddUserModal() {
  const isOpen = useReactiveVar(isAddUserModalOpenVar);

  if (!isOpen) return null;

  return (
    <dialog open>
      <h3>Add a new user</h3>
      {/* …form goes here… */}
      <button onClick={() => isAddUserModalOpenVar(false)}>Close</button>
    </dialog>
  );
}

Why this works

  • makeVar creates a reactive variable that lives in the Apollo cache but does not require a GraphQL server round‑trip.
  • Components that call useReactiveVar automatically re‑render when the variable changes, just like useState.

When to prefer this over Context/Redux

  • When the UI state is tightly coupled to GraphQL data (e.g., a “selected” item that also appears in a query).
  • When you already have Apollo Provider at the root and want to avoid adding another global store.

4.5 Pattern 5 – Centralised Error Handling & Refetch Strategies

Error handling is often scattered across many components, leading to duplicated UI and inconsistent UX. Apollo gives you two powerful tools:

  1. Global error link (configured in the client) – logs or redirects on authentication failures.
  2. Component‑level errorPolicy – lets you decide whether to surface partial data or treat any error as fatal.
import { onError } from '@apollo/client/link/error';

const errorLink = onError(({ graphQLErrors, networkError }) => {
  // Example: redirect to login on 401
  if (networkError?.statusCode === 401) {
    window.location.href = '/login';
  }
});

5.5.2 Component‑Level Error Policy

const { data, error, loading, refetch } = useQuery(GET_USERS, {
  errorPolicy: 'all', // returns partial data + errors
});
  • errorPolicy: 'ignore' – swallow errors, return whatever data is in the cache.
  • errorPolicy: 'all' – useful for “best‑effort” UI where you still want to render whatever succeeded.

5.5.3 Refetch on Demand

Sometimes a mutation invalidates a query, but you don’t want to write a manual cache update. Apollo’s refetchQueries option automates this.

const [deleteUser] = useMutation(DELETE_USER, {
  refetchQueries: [{ query: GET_USERS }],
  awaitRefetchQueries: true, // ensures UI waits for the refetch to finish
});

When to use refetchQueries vs. update

  • update is more efficient because it manipulates the cache directly without a network round‑trip.
  • refetchQueries is simpler when the mutation’s impact is complex (e.g., many related queries) or when you’re unsure how to merge the new data.

5.5.4 UI Pattern for Errors

function UsersList() {
  const { data, loading, error, refetch } = useQuery(GET_USERS);

  if (loading) return <Spinner />;
  if (error) {
    return (
      <section className="error">
        <p>❌ Oops! Something went wrong.</p>
        <pre>{error.message}</pre>
        <button onClick={() => refetch()}>Try again</button>
      </section>
    );
  }

  // Normal render path …
}

Best practice checklist

  • ✅ Show a fallback UI (spinner, skeleton) while loading.
  • ✅ Provide a retry button that calls refetch.
  • ✅ Log errors centrally (error link) and optionally send them to a monitoring service (Sentry, LogRocket).
  • ✅ Keep the UI responsive: use optimistic updates where appropriate, and fall back to a graceful error state if the optimistic write fails.

5. Frequently Asked Questions (FAQ)

QuestionShort Answer
Do I still need Redux or Context if I use Apollo?Not for data that lives in GraphQL. For pure UI state that never touches the server, you can still use useState, Context, or Apollo’s reactive variables.
Can Apollo work with a REST backend?Yes. Use the apollo-link-rest package to treat REST endpoints as GraphQL fields, but you lose many of the cache benefits that come from a true GraphQL schema.
What is the difference between cache-first and network-only?cache-first returns cached data if present and only hits the network when the cache is empty. network-only always makes a request, useful for “force refresh” scenarios.
How do I persist the Apollo cache across page reloads?Use apollo3-cache-persist (or apollo-cache-persist for older versions). It serialises the cache to localStorage or IndexedDB.
Is Apollo suitable for mobile (React Native)?Absolutely. The same client works in React Native; just ensure you polyfill fetch if needed.
How do I handle authentication tokens?Add an ApolloLink that injects the Authorization header on each request, e.g., setContext from @apollo/client/link/context.
What if my GraphQL schema changes?Apollo’s TypeScript codegen (@graphql-codegen) can generate typed hooks that will break at compile time, alerting you to required updates.
Can I use Apollo with server‑side rendering (SSR)?Yes. Use getDataFromTree (for Next.js) or the @apollo/client/react/ssr utilities to pre‑fetch data on the server and hydrate the cache on the client.
How does Apollo’s cache differ from Redux?Apollo’s cache is normalized (objects stored by ID) and schema‑aware, which enables automatic merging and fine‑grained updates. Redux stores plain JS objects and requires you to write reducers for every change.
Is there a performance penalty for using GraphQL over REST?Not inherently. GraphQL can reduce over‑fetching, which often improves performance. The biggest cost is the size of the query document; keep queries concise and enable persisted queries if needed.

6. Conclusion – When Apollo Is the Right Choice

Apollo Client has matured from a simple GraphQL fetcher into a full‑featured state management solution that works hand‑in‑hand with modern React. If your application already communicates with a GraphQL server, adopting Apollo gives you:

  • Declarative data fetching via useQuery and useLazyQuery.
  • Automatic cache normalization, eliminating the need for manual reducers.
  • Optimistic UI and pagination helpers that make complex UX patterns trivial.
  • Local‑state capabilities that let you keep UI flags, form values, and even temporary IDs in the same store as remote data.
  • Rich developer tooling (Apollo DevTools, TypeScript codegen, error links) that speeds up debugging and onboarding.

That said, Apollo is not a silver bullet. If your app never talks to GraphQL, or if you need extremely fine‑grained control over every slice of state (e.g., a large, non‑data‑driven game), a dedicated state library like Redux Toolkit or Zustand may still be appropriate.

Bottom line: For most data‑centric React applications—especially those that already have a GraphQL API—Apollo Client provides a single, coherent source of truth that reduces boilerplate, improves performance, and delivers a smoother developer experience. By mastering the five patterns above, you’ll be able to build responsive, maintainable UIs that scale from a handful of queries to complex, real‑time dashboards.

Happy coding! 🚀