Concept
The beginner framing: Apollo Client is the dominant library for consuming a GraphQL API from a React app, useQuery/useMutation hooks fetch and mutate data, and a shared cache underneath means data fetched once can be reused across the whole app without redundant network requests.
Version-currency callout, confirmed directly: hooks moved to a subpath
// ❌ this is the OLD (v3-era) import pattern, confirmed to no longer work for hooks:
import { useQuery } from "@apollo/client"; // useQuery is undefined here in v4
// ✅ confirmed working in the currently installed @apollo/client v4:
import { useQuery, useMutation } from "@apollo/client/react";
import { ApolloClient, InMemoryCache } from "@apollo/client"; // core client/cache STAY on the rootConfirmed directly: importing useQuery from the root @apollo/client package resolves to undefined on the installed version, while ApolloClient and InMemoryCache (the framework-agnostic core) remain available from that same root import. Apollo Client 4 genuinely split the package: React-specific hooks now live under the @apollo/client/react subpath, separate from the core client/cache machinery. A lot of existing tutorials still teach the flat, single-import v3 pattern, that code will fail specifically on the hook imports, not the client setup.
const { data, loading, error } = useQuery(GET_USER, { variables: { id: "1" } });
const [addComment, { loading: submitting }] = useMutation(ADD_COMMENT);Beyond the import path, the hooks themselves work as most existing knowledge expects: useQuery returns data/loading/error, useMutation returns a trigger function plus a result object.
The normalized cache: confirmed by writing and reading it directly
cache.writeQuery({
query: gql`query { user(id: "1") { __typename id name } }`,
data: { user: { __typename: "User", id: "1", name: "Ada" } },
});// cache.extract() afterward:
{ "User:1": { "__typename": "User", "id": "1", "name": "Ada" }, "ROOT_QUERY": { "user(...)": { "__ref": "User:1" } } }// Query A, run on the profile page:query { user(id: 1) { id name } }
Query A fetches a user. Apollo Client doesn't cache this as a blob keyed by the query, it NORMALIZES it, storing the user as its own entry, identified by __typename + id.
Confirmed by running this exact write against a real InMemoryCache: Apollo Client doesn't cache a query's result as one opaque blob, it normalizes it, storing the User object as its own standalone entry, keyed as User:1 (from __typename + id), with the query's actual cached result just holding a { "__ref": "User:1" } pointer to it.
// a SECOND, different query, whose result includes the SAME user:
cache.writeQuery({
query: gql`query { post(id: "5") { author { __typename id name } } }`,
data: { post: { author: { __typename: "User", id: "1", name: "Ada" } } },
});Confirmed by running this immediately after the first write: the second query's author field does not create a duplicate User entry, Apollo Client recognizes the matching __typename + id and stores author as a reference to the same existing User:1 entry.
The payoff: one entity update, every query sees it
cache.writeFragment({
id: cache.identify({ __typename: "User", id: "1" }),
fragment: gql`fragment UpdatedName on User { name }`,
data: { name: "Ada Lovelace" },
});Confirmed by writing this fragment and then reading both the original user query AND the post query afterward: both now report the updated name, "Ada Lovelace", because they were never separate copies of the data to begin with, just two different references pointing at the identical User:1 cache entry. This is the mechanism behind Apollo Client's "it just updates everywhere automatically" reactivity, it's not magic, it's a direct consequence of normalization.
Try It
Predict the outcome before checking the solution.
// Component A somewhere in the app:
const { data } = useQuery(gql`query { user(id: "1") { name } }`);
// Component B, elsewhere, runs a mutation:
const [updateName] = useMutation(gql`
mutation { updateUser(id: "1", name: "New Name") { __typename id name } }
`);If Component B's mutation succeeds and its response includes the updated User object with matching __typename/id, does Component A's already-rendered data update without Component A doing anything itself?
Solution
Yes, as long as the mutation's response includes a User object with the same __typename and id that's already normalized in the cache (confirmed: User:1), Apollo Client automatically merges the updated fields into that existing cache entry. Component A's useQuery is subscribed to the cache, not just to a one-time fetch result, so when the underlying User:1 entry changes, Component A automatically re-renders with the new data, with no manual refetch, no prop drilling between the two components, and no explicit "tell Component A to update" logic anywhere.
Implement It Yourself
Build a minimal normalized cache, to see the actual mechanism Apollo Client's InMemoryCache implements:
function createNormalizedCache() {
const entities = new Map(); // "Type:id" -> entity data
function identify(obj) {
return obj.__typename && obj.id ? `${obj.__typename}:${obj.id}` : null;
}
function normalize(obj) {
if (Array.isArray(obj)) return obj.map(normalize);
if (obj && typeof obj === "object") {
const normalized = {};
for
This captures the essential idea, recursively walk any written data, and whenever an object has an identifiable __typename + id, store it once in a shared entity map (merging into whatever's already there) rather than embedding a full copy wherever it appears.
Under the Hood
useQuery/useMutation are ordinary React hooks built on the same rules covered in Hooks Overview, Apollo Client doesn't invent a new component model, it just wraps its cache-subscription logic inside the standard hook pattern. Client-side cache policies (fetchPolicy, when to treat cached data as stale) get their own deeper treatment in Caching Strategies, building on the normalization mechanism established here.
Common Mistakes
1. Importing hooks from the root @apollo/client package
import { useQuery } from "@apollo/client"; // ❌ confirmed undefined in the current v4This is the single most likely place existing v3-era code/tutorials will break on upgrade, the fix is a straightforward import-path change (@apollo/client/react), but it's easy to miss since the error (a runtime "not a function" rather than a clear migration message) doesn't point directly at the cause.
2. Assuming two queries returning "the same" entity get cached as separate copies
// assuming a second query fetching the same user creates a SEPARATE cache entryConfirmed false, normalization means any object with a matching __typename + id shares one cache entry, regardless of which query fetched it or from how many different places it's referenced.
3. Omitting __typename/id from a mutation's response selection
mutation { updateUser(id: "1", name: "New") { name } } # ❌ missing __typename and idWithout __typename and id in the response, Apollo Client can't identify which existing cache entity to merge the update into, the automatic cross-query update behavior confirmed above depends entirely on that identification succeeding.
Best Practices
- Import hooks from
@apollo/client/react, not the root package, on the current major version. - Always include
__typenameandid(or whatever the configured identifying fields are) in mutation response selections, this is what makes automatic cache updates across the app actually work, not an optional nicety. - Trust normalization rather than manually keeping multiple components' local copies of the same entity in sync, that's exactly the problem the normalized cache exists to solve.
- Reach for
cache.identify()(as used in the fragment-write example) rather than manually constructing cache-entry ID strings, since the exact ID format is an Apollo Client implementation detail that could vary based on cache configuration.
Performance Tips
- Normalization itself has a real, if usually small, per-write computational cost (recursively walking the response, identifying entities), negligible for typical query sizes, worth profiling only for genuinely huge nested responses.
- The practical performance win normalization provides is avoiding redundant network requests entirely, once an entity is cached, any other query referencing it (with a compatible
fetchPolicy) can be satisfied from the cache instantly, no request needed.
