Apollo Client’s React hooks (useQuery, useMutation, useLazyQuery) look simple enough that an AI coding agent will write them confidently — and get several specific things wrong, because the normalized cache underneath them doesn’t behave like a plain fetch-and-render hook. The most consequential mistake right now: useQuery and useMutation no longer live at import { useQuery } from "@apollo/client" — that top-level export was removed in Apollo Client 4.0 (stable since September 2025, now on the 4.2.x line), and most training data, tutorials, and Stack Overflow answers still describe Apollo Client 3.
We went through the Apollo Client 4 migration guide, the apollo-client GitHub source, and the GraphQL Code Generator docs directly to verify each of the eight mistakes below — not summarized from a blog post, but checked against the actual changesets and current package versions. Each one comes with the exact wrong pattern an agent tends to produce and the fix to put in CLAUDE.md.
Why this keeps happening
Apollo Client 3 has been the dominant version since 2020, so the overwhelming majority of code samples, GitHub issues, and course material an LLM trained on describe v3’s API surface. Apollo Client 4.0 shipped in September 2025 and is now at 4.2.12 — genuinely current, but only about a year old relative to a training corpus with six years of v3 content behind it. The result is predictable: an agent asked to “add a query” or “wire up a mutation” defaults to the pattern it’s seen ten thousand times, which is frequently the v3 one. None of these mistakes are hypothetical — they’re the specific diffs Apollo’s own codemod exists to fix.
Mistake 1: Importing hooks from @apollo/client instead of @apollo/client/react
Apollo Client 4 made the core package framework-agnostic. React hooks moved to a dedicated subpath so non-React consumers (Vue, Angular, vanilla) don’t pull in React as a dependency. An agent working from v3-era memory writes the old import and it either fails to resolve or pulls in a shim, depending on your package.json exports config.
// ❌ Apollo Client 3 pattern — no longer exported from the top level in v4
import { useQuery, useMutation } from "@apollo/client";
// ✅ Apollo Client 4 — React hooks live in the /react subpath
import { useQuery, useMutation } from "@apollo/client/react";
ApolloClient, InMemoryCache, and gql are unaffected — only the React hooks and React-specific utilities moved. Apollo ships a codemod that fixes this mechanically across a whole codebase, which is worth pointing CLAUDE.md at directly rather than relying on the agent to catch every file by hand.
Mistake 2: Checking error.graphQLErrors or a separate errors property
Apollo Client 3 attached both an error and (only when errorPolicy: "all") an errors property to query results, and wrapped everything in an ApolloError instance with optional graphQLErrors, networkError, protocolErrors, and clientErrors fields. An agent copying that shape writes defensive checks against properties that no longer exist.
Apollo Client 4 removed ApolloError entirely and unified everything into a single error property, guaranteed to be an ErrorLike (has message and name). GraphQL-specific errors are now identified with a type guard:
// ❌ Apollo Client 3 — ApolloError shape, removed in v4
const { data, error, errors } = useQuery(QUERY);
if (error?.graphQLErrors) {
error.graphQLErrors.forEach((e) => console.log(e.message));
} else if (errors) {
// ...
}
// ✅ Apollo Client 4 — one error property, explicit type guard
import { CombinedGraphQLErrors } from "@apollo/client";
const { data, error } = useQuery(QUERY);
if (error && CombinedGraphQLErrors.is(error)) {
error.errors.forEach((e) => console.log(e.message));
}
Network errors are simplified too — error.networkError.message becomes just error.message, since network errors are no longer wrapped. If your CLAUDE.md still documents ApolloError as the error shape, every error-handling snippet the agent generates from it is wrong on v4.
Mistake 3: Confusing no-cache with network-only
Both fetch policies skip reading from the cache on the initial request, which is exactly close enough to make an agent treat them as interchangeable. They aren’t, and the difference determines whether later parts of your app see fresh data or stale data silently.
| fetchPolicy | Reads cache first | Writes result to cache | Reacts to other queries’ cache writes |
|---|---|---|---|
cache-first (default) | Yes | Yes | Yes |
cache-and-network | Yes (returns immediately, then refetches) | Yes | Yes |
network-only | No | Yes | Yes |
no-cache | No | No | No — completely isolated from the cache |
cache-only | Yes (exclusive) | No | Yes |
An agent that reaches for no-cache to “always get fresh data” will get a query result that’s invisible to every other component reading the same fields — useMutation’s update function can’t reference it, refetchQueries won’t pick it up as changed, and a second component fetching the same query with cache-first will show stale data even though a “fresher” fetch just ran a moment earlier.
// ❌ "Always fresh" reasoning — but this result never touches the shared cache
const { data } = useQuery(GET_PROFILE, { fetchPolicy: "no-cache" });
// ✅ Fresh on this call, but the result stays in the cache for everyone else
const { data } = useQuery(GET_PROFILE, { fetchPolicy: "network-only" });
Use no-cache deliberately — for one-off reads that should never pollute the normalized store — not as a synonym for “skip the cache.”
Mistake 4: Assuming a mutation response updates every list that contains the item
Apollo’s normalized cache automatically merges a mutation’s response into any object that’s already cached under the same __typename + key fields. What it does not do automatically is insert a brand-new object into a cached list field — a todos array, a paginated posts connection — because Apollo has no way to know which lists should include it.
// ❌ Agent assumes the new todo appears in the TodoList query automatically
const [addTodo] = useMutation(ADD_TODO);
await addTodo({ variables: { text: "Ship the release" } });
// TodoList's `useQuery(GET_TODOS)` still shows the old array — no re-render
// ✅ Explicitly append the new item via cache.modify + writeFragment
const [addTodo] = useMutation(ADD_TODO, {
update(cache, { data }) {
cache.modify({
fields: {
todos(existingRefs = []) {
const newRef = cache.writeFragment({
data: data.addTodo,
fragment: gql`
fragment NewTodo on Todo {
id
text
}
`,
});
return [...existingRefs, newRef];
},
},
});
},
});
The alternative — refetchQueries: ["GetTodos"] — works too, but costs a network round trip and, per Apollo’s own docs, doesn’t update the UI until the refetch resolves unless it’s combined with the update function. For an editing-heavy list where users expect an item to appear immediately, update is the correct default; refetchQueries is the fallback when reconciling cache logic by hand isn’t worth it.
Mistake 5: Losing __typename in a custom fetch, mock, or MSW handler
Apollo normalizes every cached object by __typename plus its key fields (id by default, or a custom keyFields policy). The client adds __typename to outgoing queries automatically — but if an agent hand-writes a mock resolver, a test fixture, or a custom fetch wrapper that strips fields it doesn’t recognize, __typename is the one it’s most likely to drop, because it looks like GraphQL-internal noise rather than real data.
// ❌ Mock/fixture missing __typename — cache can't normalize it
const mockUser = { id: "1", name: "Ada" };
// ✅ __typename must match the actual GraphQL type exactly
const mockUser = { __typename: "User", id: "1", name: "Ada" };
Without it, Apollo can’t determine which type policy or keyFields config applies, fragment matching against interfaces/unions breaks, and the object frequently gets cached as an anonymous, un-mergeable blob instead of a normalized entity — meaning a later cache.modify or readFragment call against that type silently returns nothing. If your test setup uses MockedProvider, this is worth a specific CLAUDE.md line, since it’s the single most common reason “the test passes but the real cache update doesn’t work.”
Mistake 6: Wiring in codegen-generated hooks instead of TypedDocumentNode
@graphql-codegen/typescript-react-apollo used to be the standard way to get typed hooks (useGetUserQuery, useAddTodoMutation) from a .graphql file. Apollo’s own migration guide now states directly:
“The generated hooks created by this package are no longer compatible with Apollo Client 4.0 … In the long term, consider using the plugins directly with our recommended starter configuration” — Apollo Client 4 migration guide
An agent that scaffolds a new project by copying an older codegen config will produce hooks that either don’t compile against @apollo/client/react’s new type signatures, or compile but silently lose the overload-based type safety Apollo Client 4 added.
// ❌ Generated hook from typescript-react-apollo — incompatible typing with AC4
import { useGetUserQuery } from "./generated/graphql";
const { data } = useGetUserQuery({ variables: { id } });
// ✅ TypedDocumentNode + Apollo's own hooks — the recommended AC4 pattern
import { useQuery } from "@apollo/client/react";
import { GetUserDocument } from "./generated/graphql"; // TypedDocumentNode
const { data } = useQuery(GetUserDocument, { variables: { id } });
TypedDocumentNode output comes from @graphql-codegen/typed-document-node (or the client-preset, with the caveats Apollo notes about some incompatible client-preset features) rather than typescript-react-apollo. If CLAUDE.md still points an agent at the old plugin, it will keep generating hooks the current Apollo Client can’t fully type-check.
Mistake 7: Passing variables to useLazyQuery’s hook call instead of its execute function
useLazyQuery behaved like a deferred useQuery in v3 — you could set variables in the hook options and they’d apply once you called the execute function. Apollo Client 4 rewrote this: variables and context are no longer accepted as hook options at all. They belong on the execute call.
// ❌ v3 pattern — variables on the hook, not accepted in v4
const [execute, { data }] = useLazyQuery(SEARCH, {
variables: { term: "graphql" },
});
execute();
// ✅ v4 — variables move to the execute call
const [execute, { data }] = useLazyQuery(SEARCH);
execute({ variables: { term: "graphql" } });
A second, related trap: calling execute() during render now throws an error instead of silently working — Apollo’s guidance is to use useQuery with the skip option if you need conditional execution tied to render, and reserve useLazyQuery strictly for callbacks triggered by user interaction (a button click, not a useEffect).
Mistake 8: Relying on useQuery’s onCompleted/onError callbacks
Apollo Client 4 removed the onCompleted and onError callback options from useQuery specifically (they remain relevant for useMutation, which is imperative by nature). The Apollo team’s stated reason: these callbacks made it easy to introduce bugs where side effects fired on stale renders or fired more than once. An agent writing v3-flavored data-fetching code will still reach for them on a query hook.
// ❌ Removed from useQuery in Apollo Client 4
useQuery(GET_USER, {
onCompleted: (data) => setLocalState(data.user),
onError: (error) => showToast(error.message),
});
// ✅ Derive the side effect from the returned state instead
const { data, error } = useQuery(GET_USER);
useEffect(() => {
if (data) setLocalState(data.user);
}, [data]);
useEffect(() => {
if (error) showToast(error.message);
}, [error]);
A CLAUDE.md block that catches all eight
Pin the version explicitly — Apollo Client 4’s changes are large enough that “which major version” has to be stated, not inferred:
## Apollo Client (v4.x — verify with `npm ls @apollo/client` before editing)
- Import React hooks from `@apollo/client/react`, not the `@apollo/client` root export.
- Errors are unified on `error`. Use `CombinedGraphQLErrors.is(error)` /
`CombinedProtocolErrors.is(error)` to narrow — `ApolloError`, `graphQLErrors`,
and a separate `errors` property no longer exist.
- `no-cache` and `network-only` are not interchangeable: `no-cache` results never
enter the shared cache. Default to `network-only` unless a read genuinely must
stay isolated.
- Adding an item via `useMutation` does not insert it into cached list fields.
Use `update` + `cache.modify` for lists the UI must reflect immediately.
- Every object in mocks, fixtures, and custom fetch wrappers must include a
correct `__typename`, or cache normalization silently fails for that object.
- Do not use `@graphql-codegen/typescript-react-apollo` generated hooks — they
are not compatible with Apollo Client 4. Use `TypedDocumentNode` output with
Apollo's own `useQuery`/`useMutation` instead.
- `useLazyQuery` no longer accepts `variables`/`context` as hook options — pass
them to the execute function. Never call the execute function during render.
- `useQuery` no longer supports `onCompleted`/`onError` — derive side effects
from `data`/`error` in a `useEffect` instead.
Where this fits with the rest of your GraphQL setup
This list is specifically about the React-side consumption layer — hooks, cache, and codegen. If the CLAUDE.md gap is on the server (schema design, resolvers, DataLoader batching, N+1 queries, Apollo Federation), see our GraphQL API CLAUDE.md guide, which covers that side in depth and links out to this one. For real-world examples of how teams have structured Apollo Client rules before, our gallery has a community-maintained .cursorrules file for React + Apollo Client — worth comparing against the version-pinned rules above, since it (like most public examples) predates Apollo Client 4 and doesn’t distinguish v3 from v4 behavior. A companion GraphQL + Apollo entry covers the server side.
FAQ
Is Apollo Client 4 required, or can I stay on v3? Apollo Client 3.x is still maintained and plenty of production apps haven’t migrated. The point isn’t “upgrade” — it’s that CLAUDE.md needs to state which major version the project is actually on, because the two APIs are different enough that an agent guessing wrong produces code that won’t compile or will compile and silently misbehave.
Does the codemod fix everything in this list?
It handles the mechanical parts — import paths, createHttpLink → HttpLink, some type renames. It does not fix cache-update logic, __typename gaps in test fixtures, or a switch away from codegen-generated hooks, all of which need a human (or a well-instructed agent) to actually rewrite.
Why does no-cache feel like the “safe” choice for an agent?
Because it maps to the intuitive reading of the name — “don’t use the cache” — without surfacing that it also means “don’t let anyone else see this data.” That’s exactly the kind of plausible-sounding-but-wrong inference CLAUDE.md needs to override explicitly, rather than leaving the agent to reason it out from the option name alone.