skip to content

In Apollo Client 4, an isInCart @client field computed by a LocalState resolver shows stale values after the cart changes, while a read-function version stays current; why?

level: seniorimportance: nice to knowfreq 16%

answer

  1. when does each one run
  2. execution time versus read time
  3. resolver output is cached with the result
  4. cache hits skip ordinary resolvers
  5. always: true forces a rerun

basics

~20 s

A LocalState resolver runs when the operation executes, and its output is cached with the server result, so cache-first reads return the stored value. A read function computes the field on read and tracks the reactive variables it reads.

solid answer

~50 s

The two mechanisms run at different times. A `LocalState` resolver is part of executing the operation: Apollo fetches the remote fields, runs the resolver, merges both and writes the result, `isInCart` included, to the cache. With the default `cache-first` policy, the next read finds a complete cached result, runs no ordinary resolvers, and returns the stored value, so the cart change goes unseen until the query executes again. A type-policy `read` function is part of reading the cache: nothing is stored for the field, and because Apollo records the reactive variables it reads, changing `cartItemIdsVar` recomputes it for every active query. For a value derived from other client state, use a `read` function. Keep resolvers for asynchronous lookups, local mutations and `no-cache` queries, and add `@client(always: true)` when a resolver must run on every request, including cache hits.

code

ts · 28 lines
ts
import { InMemoryCache, makeVar } from "@apollo/client";
import { LocalState } from "@apollo/client/local-state";

export const cartItemIdsVar = makeVar<string[]>([]);

// Stale: runs when the query executes; its output is cached
export const localState = new LocalState({
  resolvers: {
    Product: {
      isInCart: (product: { id: string }) =>
        cartItemIdsVar().includes(product.id),
    },
  },
});

// Current: computed on cache reads, tracks cartItemIdsVar
export const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      fields: {
        isInCart: {
          read: (_existing, { readField }) =>
            cartItemIdsVar().includes(readField<string>("id") ?? ""),
        },
      },
    },
  },
});

go deeper

for a junior

Remember that Apollo has two ways to fill a @client field: a read function in the cache's type policies and a resolver in LocalState.

for a middle

Explain when each runs: the resolver during execution with its output cached, the read function when results are read from the cache, with reactive variables tracked.

for a senior

Diagnose staleness by asking where the value is stored, and pick between a read function, @client(always: true), a refetch or a fetch-policy change on purpose.

for a principal

Set a team rule: derived client values are read functions, resolvers are for async or mutation cases, and always: true needs a stated reason.

## Two ways to fill a @client field Apollo Client 4 offers two mechanisms for computing a local-only field such as `Product.isInCart`: - a **type-policy `read` function**, defined in `InMemoryCache`'s `typePolicies`, which belongs to the cache; - a **`LocalState` resolver**, defined in `new LocalState({ resolvers })`, which belongs to operation execution. Both produce a value for the same `isInCart @client` selection, so they look interchangeable. They are not, because they run at different moments. ## When a resolver runs A resolver runs when the operation is **executed**, the path Apollo takes when it cannot answer from the cache or the fetch policy tells it to ask the network: 1. Apollo strips `@client` fields and sends the rest of the query to the server. 2. When the response arrives, `LocalState` runs the resolvers for the local fields. For `Product.isInCart`, the first argument is the product object from the server response, so the resolver can read its `id`. 3. Local and remote results are merged and the whole result, including `isInCart`, is written to the cache. 4. On the next `cache-first` read, the cache holds a complete result. Apollo returns it and runs **no** ordinary resolvers. Nothing links the stored `isInCart` to the cart. When `cartItemIdsVar` changes, the cached value does not, so the product page keeps showing "Add to cart" until the query executes again, for example after a refetch. ## When a read function runs A `read` function runs when the field is **read from the cache**, that is, when a query result is assembled from cached data; Apollo memoizes those results and reruns the function when something it read has changed. It stores nothing. While it runs, Apollo records the reactive variables and cached fields it reads. When one of those changes, the dependent results are recomputed and active queries re-deliver. That is why the read-function version of `isInCart` flips to "In cart" as soon as the variable changes. ## Comparing the options | | type-policy `read` | `LocalState` resolver | resolver with `@client(always: true)` | |---|---|---|---| | runs | when the field is read from the cache | when the operation executes | on every request, cache hits included | | result stored in cache | no | yes, with the query result | yes, when the query executes | | may be asynchronous | no, synchronous only | yes, may return a Promise | yes | | under `no-cache` | field is `null`, with a warning since 4.1 | runs normally | runs normally | | natural fit | values derived from cache or reactive variables | async lookups, local mutations | a cheap check needed on every request | `@client(always: true)` answers a different complaint: "my resolver does not run on the second request". It forces the resolver to run whenever the query is requested, even when the rest of the query comes from the cache. The docs warn to use it sparingly, because the resolver then costs something on every request, and any side effect it has repeats. ## Choosing for isInCart `isInCart` is a pure function of two things the client already holds: the product's id in the cache and the ids in a reactive variable. That is the case a `read` function is built for. A resolver would be the right tool if the answer came from an asynchronous source, such as an IndexedDB lookup, or if the field had to work in a `no-cache` query. Two further details matter when mixing local and remote fields with resolvers: - If the server returns `data: null`, local resolvers do not run at all. - Since Apollo Client 4.1, a `@client` field with no resolver, no `read` function and no cached value resolves to `null` with a warning, instead of silently staying missing. ## How to diagnose it in an interview State the moment each mechanism runs, then ask where the stale value lives. Inspecting the cached `Product` entity settles it: a stored `isInCart` key means a resolver wrote it, while a field served by a `read` function has no stored key at all. If it lives in the cache because a resolver's output was written there, the fix is either to derive it at read time with a `read` function, or to make the resolver run again: `@client(always: true)`, a refetch, or a different fetch policy.

  • Would switching the product query to fetchPolicy: "network-only" fix the resolver version?
    Partly. Every execution then goes to the server and reruns the resolver, so a fresh mount shows the right value. But a cart change still does not re-execute an already mounted query, so an open page stays stale until something refetches it, and you pay a network request for a value the client already knew. A `read` function fixes both problems without any request.
  • When is a LocalState resolver the better choice for a local field?
    When the value needs asynchronous work, such as reading IndexedDB, because `read` functions must be synchronous. Also for local mutations handled through `useMutation` with a `@client` field, and for fields that must resolve under `no-cache`, which skips the cache and therefore every `read` function.

A read function is a spreadsheet formula: the cell recalculates whenever a cell it refers to changes. A resolver's result is a value pasted into the cell: it was right when pasted and stays as it is until someone pastes again.

saying these in an interview costs you the question

  • A LocalState resolver reruns every time a component reads the query from the cache.
  • Adding @client(always: true) turns a resolver into a cache read function.
  • A read function's result is written to the cache and reused until evicted.
  • Type-policy read functions may return a Promise for asynchronous local lookups.
  • Read functions and resolvers are interchangeable; the choice is purely stylistic.