In Apollo Client 4, how do you add a locally computed isInCart field to a server Product queried alongside its name and price?
answer
- the server never sees it
- a directive marks the field local
- a type policy computes the value
- readField plus a reactive variable
- local state is opt-in since 4.0
basics
~20 sSelect 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 sIt 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 linesimport { 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
Remember the three pieces: @client on the field, a read function under typePolicies, and localState: new LocalState() on the client in Apollo Client 4.
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.
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.
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.