skip to content

When an urql storefront moves from the document cache to Graphcache, what do its keys, updates and optimistic options configure, and what breaks without them?

level: seniorimportance: should knowfreq 22%

answer

  1. swap one exchange, same name
  2. entities need a key
  3. links the cache cannot infer
  4. default updater only for creates
  5. optimistic layer is reverted

basics

~10 s

Graphcache's keys say how to identify entities without an id, updates.Mutation holds updaters for list and link changes a mutation result cannot express, and optimistic returns a temporary result applied until the server answers.

solid answer

~50 s

You replace `cacheExchange` from `urql` with the one from `@urql/exchange-graphcache`, which normalizes results into entities keyed by `__typename` plus `id` or `_id`. `keys` covers types without those fields: `Cart: c => c.token` for a custom key, or `Money: () => null` to mark embedded data and silence the invalid-key warning. Edits to an entity that the mutation returns update everywhere without config, but adding a line to `Cart.lines` or deleting one is a link change, so it needs an updater in `updates.Mutation` using `cache.updateQuery`, `cache.link` or `cache.invalidate`. Since Graphcache 7, a mutation without an updater whose returned entity is not cached yet invalidates cached entities of that type; for an empty cart there are none, so nothing refreshes. `optimistic` functions return a fake result that is applied at once and reverted when the real one arrives.

code

ts · 29 lines
ts
import { cacheExchange } from '@urql/exchange-graphcache';
import { CartQuery } from './queries';

export const storefrontCache = cacheExchange({
  keys: {
    Money: () => null,
  },
  updates: {
    Mutation: {
      addToCart(result, _args, cache) {
        cache.updateQuery({ query: CartQuery }, (data) => {
          if (!data?.cart) return data;
          data.cart.lines.push(result.addToCart);
          return data;
        });
      },
      removeCartLine(_result, args, cache) {
        cache.invalidate({ __typename: 'CartLine', id: args.lineId as string });
      },
    },
  },
  optimistic: {
    setCartLineQuantity: (args) => ({
      __typename: 'CartLine',
      id: args.lineId,
      quantity: args.quantity,
    }),
  },
});

go deeper

for a junior

Recall that Graphcache is urql's normalized cache, installed by swapping the cacheExchange import, and that it identifies entities by __typename plus id.

for a middle

Explain what keys, updates and optimistic each configure, and which mutations need no updater because they return the entity they change.

for a senior

Diagnose the empty-cart case under the Graphcache 7 default updater, write a list updater with updateQuery, and explain when an optimistic change fails to show.

for a principal

Plan the migration: audit types without ids, list mutations that change links, and weigh returning parent objects from mutations against a growing set of updaters.

## Swapping the exchange Graphcache is urql's **normalized cache**: instead of storing one result per query, it splits every result into **entities** (objects it can identify) and **links** between them, and rebuilds each query's data from that graph. It ships as `@urql/exchange-graphcache` and exports an exchange with the same name as the default one, so the move is a change of import and a configuration object: ```ts import { Client, fetchExchange } from 'urql'; import { cacheExchange } from '@urql/exchange-graphcache'; const client = new Client({ url: '/graphql', exchanges: [cacheExchange({ keys, updates, optimistic }), fetchExchange], }); ``` It **replaces** the document cache; the two are not stacked. | Option | Shape | Configures | Without it | |---|---|---|---| | `keys` | `{ TypeName: (data) => key or null }` | how entities without `id`/`_id` are identified | an "Invalid key" warning and embedded storage under the parent | | `updates` | `{ Mutation: { field: (result, args, cache, info) => void } }` | link and list changes after a mutation or subscription | only the default create fallback runs | | `optimistic` | `{ field: (args, cache, info) => result }` | a temporary result applied before the server answers | the UI waits for the round trip | The request policies keep working, and `additionalTypenames` tags stop being the mechanism that keeps screens fresh. ## keys: identifying entities By default an entity's key is its `__typename` plus its `id` field, falling back to `_id`, so a product becomes `Product:42`. Types that lack both need an entry in `keys`: - **A different identifier**: `keys: { Cart: (cart) => cart.token }`. - **Embedded data**, such as a `Money` price or an `Image`, that is only ever reached through its parent: `keys: { Money: () => null }`. Without an entry, Graphcache logs an "Invalid key" warning and stores the object as embedded data under its parent's key and field, such as `Product:42.price`. That is fine for a price, but a real entity stored that way stops being shared: a rename written elsewhere never reaches the embedded copy. ## updates: what a mutation result cannot say An updater is a function in `updates.Mutation` (or `updates.Subscription`) named after the field, called as `(result, args, cache, info)` after the result has been written. - **No updater needed** when the mutation returns the changed entity with its id: `setCartLineQuantity` returning `{ id, quantity }` updates that line on every screen. - **An updater needed** when a *link* changes: adding a line to `Cart.lines`, removing one, or moving an item between lists. The result does not say which lists contain it. Inside an updater you use `cache.updateQuery({ query }, data => ...)` to edit a query's data (it hands you a copy, which may be `null` if the query is not fully cached), `cache.link(entity, field, value)` to rewrite a link, `cache.resolve(entity, field)` to read, and `cache.invalidate(entity)` to drop an entity so dependent queries refetch. ## The default updater since Graphcache 7 A mutation field **without** an updater gets a fallback: when the returned entity is not in the cache yet, Graphcache treats the mutation as a create and invalidates the other cached entities of that `__typename`, which makes queries reading them refetch. Two consequences: 1. For an **empty cart** there are no cached `CartLine` entities to invalidate, so nothing refreshes and the cart still shows no lines. 2. As soon as you write an updater for that field, the fallback no longer runs; the updater owns the outcome. ## optimistic: a temporary result `optimistic` maps mutation field names to functions `(args, cache, info)` that return what the server is expected to return. Graphcache applies it in a separate layer at once, runs any updater against it, and **reverts the layer when the real result arrives**, whether that result succeeded or failed, then applies the real data. Fields you leave out are read from the cache, but if that causes a cache miss for a query, the optimistic change does not show for it. ## Rules that surprise people - Cache methods work only inside `updates`, `resolvers` and `optimistic` functions; calling a stored `cache` elsewhere throws the "Invalid Cache call" error, because Graphcache only records reactions to server results. - `cache.updateQuery` may pass `null`; guard it. - `cache.resolveFieldByKey` was removed in Graphcache 7; use `cache.resolve`. - Pagination helpers, `simplePagination` and `relayPagination`, come from `@urql/exchange-graphcache/extras` and are configured under `resolvers`.

  • The cart updater in Graphcache calls cache.updateQuery, but the header still shows no new line. What do you check first?
    Whether the updater's query matches what the cache holds. `cache.updateQuery` passes `null` when the cache cannot fully resolve that query, for example when its selection asks for a field the cart query never fetched, and returning `null` writes nothing. Also check that the field name in `updates.Mutation` matches the schema's mutation field, not the operation name.
  • Why can you not write to Graphcache from a click handler the way you might with other caches?
    Graphcache only accepts writes inside `updates`, `resolvers` and `optimistic` functions, so every change is a reaction to a server result or an expected one. A stored `cache` reference used elsewhere throws the 'Invalid Cache call' error. Client-only UI state belongs in React state or a store instead.

saying these in an interview costs you the question

  • Graphcache is added next to the default cacheExchange rather than replacing it.
  • Graphcache appends a created entity to every list of its type automatically.
  • An entity without an id makes Graphcache refuse to cache the result.
  • Optimistic results stay in the cache when the mutation succeeds.
  • You can call cache.updateQuery from a click handler outside any updater.