skip to content

An Apollo Client 3 shop passes local resolvers to new ApolloClient and reads cache from the resolver context; what changes when you move them to Apollo Client 4's LocalState?

level: seniorimportance: should knowfreq 22%

answer

  1. opt-in, no longer in core
  2. resolvers move off the client
  3. a separate import path
  4. the third argument changed shape
  5. throw, undefined and __typename rules

basics

~20 s

Resolvers move from the client's resolvers option into new LocalState({ resolvers }) from @apollo/client/local-state, passed as localState. The context becomes { requestContext, client, phase }, so cache is client.cache, and errors, undefined and missing __typename are handled strictly.

solid answer

~40 s

In Apollo Client 4 local state is opt-in. The `resolvers` constructor option is gone: you create `new LocalState({ resolvers })`, imported from `@apollo/client/local-state`, and pass it as `localState`. `client.addResolvers` becomes `client.localState.addResolvers`, and `getResolvers`/`setResolvers` are removed. The resolver's third argument is now `{ requestContext, client, phase }`, so `cache` becomes `client.cache` and request context values move under `requestContext`. Behaviour is stricter too: a thrown error sets the field to `null` and adds an entry to the result's `errors`, returning `undefined` yields `null` plus a warning, and an object or list returned for a field with a sub-selection must carry `__typename` or the field becomes `null` with an error. Fields served only by type-policy `read` functions also need a `LocalState` instance now.

code

ts · 25 lines
ts
import { ApolloClient, HttpLink, InMemoryCache, gql } from "@apollo/client";
import { LocalState } from "@apollo/client/local-state";

const THEME = gql`query Theme { theme @client }`;

const localState = new LocalState({
  resolvers: {
    Query: {
      theme: () => window.localStorage.getItem("theme") ?? "light",
    },
    Mutation: {
      setTheme: (_root, { theme }, { client }) => {
        window.localStorage.setItem("theme", theme);
        client.cache.writeQuery({ query: THEME, data: { theme } });
        return theme;
      },
    },
  },
});

export const client = new ApolloClient({
  link: new HttpLink({ uri: "/graphql" }),
  cache: new InMemoryCache(),
  localState,
});

go deeper

for a junior

Recall that Apollo Client 4 moved local resolvers into a LocalState instance passed as the client's localState option.

for a middle

Explain the new resolver context, requestContext, client and phase, and how to reach the cache through client.cache.

for a senior

Walk through the stricter contract: thrown errors surfacing as CombinedGraphQLErrors, undefined becoming null, required __typename, and read-function-only fields that now need LocalState.

for a principal

Plan the upgrade as a sweep: inventory every @client field and resolver, decide which should become read functions, and add tests before flipping the client.

## Why the move is not a rename In Apollo Client 3, local resolvers were part of the core client: you passed `resolvers` to `new ApolloClient`, and any `@client` field just worked. Apollo Client 4 moved all of that into a separate class, **`LocalState`**, so apps that never use `@client` do not ship the code. The consequence is that migration touches configuration, resolver signatures and error behaviour at once. Take a shop that keeps its theme locally. In 3.x it looked like this: ```ts new ApolloClient({ uri: "/graphql", cache: new InMemoryCache(), resolvers: { Query: { theme: () => window.localStorage.getItem("theme") ?? "light" }, Mutation: { setTheme: (_root, { theme }, { cache }) => { window.localStorage.setItem("theme", theme); cache.writeQuery({ query: THEME, data: { theme } }); return theme; }, }, }, }); ``` ## What changes, item by item | Apollo Client 3 | Apollo Client 4 | |---|---| | `new ApolloClient({ resolvers })` | `new ApolloClient({ localState: new LocalState({ resolvers }) })` | | `@client` works with no setup | a `@client` field without `localState` throws | | `client.addResolvers(r)` | `client.localState.addResolvers(r)` | | `client.getResolvers()` / `setResolvers()` | removed, no replacement | | context `{ cache, ...requestContext }` | context `{ requestContext, client, phase }` | | `fragmentMatcher` client option | removed; the cache's `fragmentMatches` does the matching | `LocalState` is imported from its own entry point, `@apollo/client/local-state`. The main `@apollo/client` entry exports `ApolloClient`, `InMemoryCache` and `makeVar`, but not `LocalState`. The migration steps, in order: 1. Install the opt-in: create one `LocalState`, move the `resolvers` map into it and pass it as `localState`. Also pass the client a `link` such as `new HttpLink({ uri })`, because the `uri` option is gone too. 2. Rewrite each resolver's third argument: `{ cache }` becomes `{ client }` and you use `client.cache`; values a caller put on the request context are read from `requestContext`. 3. Replace `client.addResolvers` calls with `client.localState.addResolvers`, and delete any `getResolvers`/`setResolvers` logic. 4. Search for `@client` fields that are served only by type-policy `read` functions. They had no resolver, so nothing forced the 3.x client to know about local state; in 4.x they still need `localState: new LocalState()`. ## Stricter resolver behaviour The resolver contract is now closer to a server resolver's: - **Thrown errors are reported, not swallowed.** A resolver that throws sets its field to `null` and adds an entry to the result's `errors`. With the default `errorPolicy` of `none`, the hook's `error` is then a `CombinedGraphQLErrors`, exactly as for a server field error. `try/catch` blocks written only to keep 3.x from misbehaving can go. - **`undefined` is not a value.** Returning `undefined` sets the field to `null` and logs a warning; return `null` on purpose. The exception is the `exports` phase, when a resolver runs to supply an `@export` variable: there, `undefined` omits the variable. The `phase` property in the context tells the two apart. - **`__typename` is enforced.** A resolver that returns an object or a list for a field with a sub-selection must include `__typename`; otherwise the field is `null` and an error is recorded. This protects normalization, which needs the type name to store the object correctly. - **`data: null` from the server skips local resolvers**, so they do not run against a failed parent. ## Typing and context `LocalState` accepts a `Resolvers` generic, usually generated from a local schema, so resolver arguments and return values are type-checked. An optional `context` function builds the `requestContext` value each resolver receives, so values from outside the operation arrive under one typed key instead of being spread into the context object as in 3.x. ## Checking the result After the move, run the app in development and read the console. Apollo Client 4 warns when a resolver returns `undefined`, and since 4.1 when a `@client` field has neither a resolver, a `read` function nor a cached value. Each warning names the field, such as `Query.theme`, which makes a quick inventory of what the migration missed. Watch the `error` returned by the affected hooks too, since resolver errors that used to vanish now reach it. ## What stays the same `makeVar` and `useReactiveVar` did not change, type-policy `read` functions keep their shape, and `@client`, `@client(always: true)` and `@export` keep their meaning in documents. The migration is about where resolvers live and how strictly their results are checked, not about how local fields are written in queries.

  • A migrated resolver returns early with a bare return when the cart is empty. What does the query now get?
    In Apollo Client 4 a resolver returning `undefined` during normal resolution sets the field to `null` and logs a warning that the resolver returned `undefined`. Return `null` explicitly when that is the intended value. Only while supplying an `@export` variable, where `phase` is `"exports"`, does `undefined` have a meaning: it leaves the variable out of the request.
  • Code elsewhere registers extra resolvers after the client is created. How does that look in 4.x?
    Call `client.localState.addResolvers(resolvers)`, or keep a reference to the `LocalState` instance and call `addResolvers` on it. New resolvers are merged with the existing map, and a new resolver for the same field takes precedence. `client.addResolvers`, `getResolvers` and `setResolvers` no longer exist on `ApolloClient`.

saying these in an interview costs you the question

  • Local resolvers still go into the ApolloClient constructor's resolvers option in 4.x.
  • A resolver's third argument still carries cache directly, as it did in Apollo Client 3.
  • LocalState is imported from @apollo/client alongside ApolloClient and InMemoryCache.
  • Returning undefined from a resolver leaves the field missing so the cache can fill it.
  • @client fields served only by read functions keep working in 4.x without LocalState.
  • A resolver may return an object without __typename; Apollo infers it from the schema.