skip to content

In Apollo Client 4, how do you add a locally computed isInCart field to a server Product queried alongside its name and price?

level: middleimportance: must knowfreq 32%

answer

  1. the server never sees it
  2. a directive marks the field local
  3. a type policy computes the value
  4. readField plus a reactive variable
  5. local state is opt-in since 4.0

basics

~20 s

Select isInCart @client in the query, give Product.isInCart a read function in InMemoryCache typePolicies, and pass localState: new LocalState() to ApolloClient. Apollo strips the field from the request and fills it from the read function.

solid answer

~40 s

It takes three pieces. The query selects `isInCart @client` next to `id`, `name` and `price`; the directive marks the field as local, so Apollo removes it from the document sent through the link and the server never has to know it exists. `InMemoryCache` gets a field policy, `typePolicies.Product.fields.isInCart.read`, which calls `readField("id")` for the product's id and checks it against `cartItemIdsVar()`, a reactive variable holding the cart. Because the read function reads that variable, adding an item recomputes the field and every active query selecting `isInCart` updates. Finally, local state has been opt-in since Apollo Client 4.0: the client needs `localState: new LocalState()` from `@apollo/client/local-state`, even with no resolvers, or a query with a `@client` field throws an error saying local state has not been configured.

code

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

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

const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      fields: {
        isInCart: {
          read(_existing, { readField }) {
            const id = readField<string>("id");
            return id != null && cartItemIdsVar().includes(id);
          },
        },
      },
    },
  },
});

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

go deeper

for a junior

Remember the three pieces: @client on the field, a read function under typePolicies, and localState: new LocalState() on the client in Apollo Client 4.

for a middle

Walk through the request: the field is stripped before the link, the server fields are cached, and the read function fills the local field when the result is read.

for a senior

Explain why a read function that reads a reactive variable stays current, and name the cases that break it: no-cache policies, untracked sources, async lookups.

for a principal

Treat derived local fields as part of the client's data model and decide which client facts deserve to live on server entities in the cache.

## What a local-only field is A **local-only field** is a field your query selects that the GraphQL server does not define. You mark it with the `@client` directive, and Apollo Client computes its value in the browser. The point is that one query can return server data and client data together, so a product page reads `name`, `price` and `isInCart` from a single `data.product` object instead of stitching two sources together in the component. ## The three pieces 1. **The query.** Select the field with `@client`, and select `id` too, because the local computation needs it: ```graphql query ProductPage($id: ID!) { product(id: $id) { id name price isInCart @client } } ``` 2. **A field policy with a `read` function.** In `InMemoryCache`'s `typePolicies`, `Product.fields.isInCart.read` returns the value. Its second argument carries helpers; `readField("id")` reads another field of the same `Product` object from the cache. The function then checks that id against `cartItemIdsVar()`, a reactive variable created with `makeVar<string[]>([])`. 3. **`LocalState` on the client.** Since Apollo Client 4.0, `@client` support is not built into the core. You pass `localState: new LocalState()`, imported from `@apollo/client/local-state`, to `new ApolloClient`. A `LocalState` with no resolvers is enough when every local field is served by a `read` function. Without it the query throws, and development builds name the cause: the operation contains `@client` fields but local state has not been configured. ## What happens when the query runs | Step | What Apollo does | |---|---| | Before the request | removes `isInCart` from the document; a query made only of `@client` fields sends no request at all | | Server responds | writes `id`, `name` and `price` to the `Product` entity in the cache | | Result is read | reads the entity back; for `isInCart` it calls the `read` function instead of looking for a stored value | | Hook delivers | `data.product` contains all four fields | Nothing is written for `isInCart`: the value is computed each time the cached result is recomputed, from whatever the cart holds at that moment. ## Why it stays current Inside a `read` function, reading a reactive variable is tracked. Apollo remembers that `Product.isInCart` depends on `cartItemIdsVar`. When an "Add to cart" button calls `cartItemIdsVar([...cartItemIdsVar(), product.id])`, Apollo invalidates the cached results that used that field and re-delivers every active query selecting it. The product page, a search result list and a recommendations strip that select the field all flip to "In cart" without a refetch, because none of them depended on the server for that bit. The same would not happen if the read function looked at a plain module-level array: Apollo cannot see a change it was never told about. If the local value lives somewhere Apollo does not track, you must trigger the refresh yourself. ## Edge cases worth knowing - **Read functions are synchronous.** They must return a value immediately; asynchronous lookups belong in a `LocalState` resolver instead. - **`no-cache` bypasses them.** A query run with `fetchPolicy: "no-cache"` does not read the cache, so a field served only by a `read` function comes back `null`; since 4.1 Apollo also warns about it. A `LocalState` resolver for the field avoids that. - **Nested selections.** Putting `@client` on a field with a sub-selection makes the whole sub-selection local. - **Local values as variables.** A `@client` field can feed a variable of the same query with `@export(as: "name")`, as long as it appears before the fields that use the variable. - **`@client` and `@defer` cannot share an operation**; Apollo throws when a document contains both. ## Why not write the value onto the entity An alternative is to store `isInCart` on each cached `Product` yourself, writing it with `cache.writeFragment` whenever the cart changes. It works, but it turns a derived value into a copy: every add, remove and cart reset must find and update every affected product, including ones loaded later. The `read` function avoids that bookkeeping because it computes the value from the current cart each time the field is read. ## Why interviewers ask it The question tests whether a candidate knows the current wiring (the 4.x `LocalState` opt-in trips most people who learned on 3.x) and whether they understand that a `read` function turns local state into a derived, reactive field on a server entity rather than a copy that must be kept in sync by hand.

  • The same product query is run with fetchPolicy: "no-cache" and isInCart comes back null. Why?
    The `read` function belongs to `InMemoryCache`, and a `no-cache` query neither reads nor writes the cache, so nothing computes the field. `LocalState` falls back to `null`, and since Apollo Client 4.1 it logs a warning suggesting a local resolver. Either use a policy that goes through the cache or add a `LocalState` resolver for `Product.isInCart`.
  • How could a locally stored currency choice be passed as an argument to a remote price field in the same query?
    Select a local field such as `currency @client @export(as: "currency")` before the remote field and use `$currency` as its argument. Apollo resolves exported values before the request goes out, strips the `@client` field and sends the variable to the server. The exporting field must carry `@client` and must appear before the fields that use the variable.

saying these in an interview costs you the question

  • @client fields are sent to the server, which just ignores unknown fields.
  • A type-policy read function alone enables @client fields in Apollo Client 4.
  • The read function must be async to compute a value from local state.
  • isInCart only changes after the product query is refetched from the server.
  • A query cannot mix @client fields with fields fetched from the server.
  • The read function's result is written onto the Product entity in the cache.