skip to content

In urql's default document cache, why does an empty cart keep showing no items after an addToCart mutation succeeds, and how do you fix it?

level: middleimportance: should knowfreq 28%

answer

  1. invalidation by type name
  2. an empty list has no types
  3. tag the query by hand
  4. memoize the context object
  5. or return the parent object

basics

~20 s

urql's document cache invalidates cached queries whose results contained a __typename that the mutation result contains. An empty cart's lines list carries no CartLine typename, so nothing links it to addToCart; additionalTypenames adds that link.

solid answer

~50 s

The document cache stores one result per query-and-variables key and tags it with every `__typename` found in that result; urql adds `__typename` to each selection set so the tags exist. When a mutation result comes back, every cached query tagged with one of the mutation's typenames is dropped, and active ones are re-run as `network-only`. An empty cart's result contains only `Cart`, while `addToCart` returns a `CartLine` and its `Product`, so no tag matches and the empty result keeps being served. The fix is to tag it yourself: pass `context: useMemo(() => ({ additionalTypenames: ['CartLine'] }), [])` to the cart's `useQuery`, or execute the mutation with `{ additionalTypenames: ['Cart'] }`, or select the parent `cart { id }` in the mutation so its typename appears. The same mechanism also over-invalidates: every cached query containing a `Product` is dropped after each add.

code

tsx · 17 lines
tsx
import { useMemo } from 'react';
import { useMutation, useQuery } from 'urql';

function CartBadge() {
  const context = useMemo(() => ({ additionalTypenames: ['CartLine'] }), []);
  const [{ data }] = useQuery({ query: CartQuery, context });
  return <span>{data?.cart.lines.length ?? 0}</span>;
}

function AddToCartButton({ productId }: { productId: string }) {
  const [{ fetching }, addToCart] = useMutation(AddToCartMutation);
  return (
    <button disabled={fetching} onClick={() => addToCart({ productId })}>
      Add to cart
    </button>
  );
}

go deeper

for a junior

Recall that urql's default cache refreshes queries after a mutation by matching __typename, and that an empty list is the known gap.

for a middle

Walk through tagging, invalidation and the network-only re-run, explain why an empty list escapes it, and give the additionalTypenames fix with a memoized context.

for a senior

Discuss over-invalidation: a Product in a mutation result drops every Product query. Judge when the hand-written tags cost more than moving to Graphcache.

for a principal

Relate the cache choice to the schema: mutations that return their parent objects make type-based invalidation work, and a growing tag list is a signal to change strategy.

## How the document cache stores and tags results urql's default `cacheExchange` is a **document cache**: it keeps one result per request, keyed by the query document plus its variables, much like a browser caches one response per URL. It does not split results into entities and has no notion of ids. To know when a stored result goes out of date, it relies on **`__typename`**, the introspection field that returns an object's GraphQL type name. Before a query or mutation is sent, urql rewrites the document to select `__typename` in every nested selection set. When a query result comes back, the cache walks it, collects every typename it finds, and records the query's key under each of them. ## What a mutation invalidates When a mutation result passes back through `cacheExchange`, the cache: 1. Collects every `__typename` in the mutation's result data, plus any `additionalTypenames` from the mutation's context. 2. Looks up every cached query recorded under any of those typenames. 3. Deletes each of those cached results. 4. Asks the client to re-run each one with `network-only`. The client only does that for queries a component is still using; an unused query is simply gone from the cache and fetched on its next use. The docs call this an **aggressive** strategy: it is coarse, but it needs no configuration and suits content-driven pages. ## The empty-cart trap Take a storefront whose header reads the cart: ```graphql query Cart { cart { id lines { id quantity product { id name } } } } ``` For a new shopper the result is `{ cart: { __typename: "Cart", id: "c1", lines: [] } }`. The only typename in it is `Cart`, because an empty list has no objects to carry a `CartLine` or `Product` typename. Now the shopper adds an item: ```graphql mutation AddToCart($productId: ID!) { addToCart(productId: $productId) { id quantity product { id name } } } ``` The result holds a `CartLine` and a `Product`. The cart query was never recorded under either, so it is not invalidated, and the header keeps rendering the cached empty cart until something else refetches it. Once the cart has one line, the same mutation does invalidate it, which is why the bug often shows only on the first add. ## Three fixes | Fix | Where it goes | Effect | |---|---|---| | `additionalTypenames: ['CartLine']` on the query | `useQuery({ query, context })` | records the cart query under `CartLine` even when the list is empty | | `additionalTypenames: ['Cart']` on the mutation | `executeMutation(vars, { additionalTypenames: ['Cart'] })` | this mutation invalidates every query tagged `Cart` | | select the parent in the mutation | `addToCart { id cart { id } }` | the result carries a `Cart` typename, so the tag matches without configuration | The query-side option is passed through `context`, and that object must be **memoized**, for example with `useMemo`. A new object on every render makes `useQuery` see a new request each time and re-run it in a loop. ## The cost of aggressive invalidation The same rule that misses the empty cart over-fires elsewhere: - `addToCart` returns a `Product`, so a product grid on screen next to the add button is dropped and refetched after every add, although nothing in it changed. - A search results page and a recently-viewed list that also contain `Product` objects are dropped too. - Because invalidation is by type, one quantity change invalidates every cached query that has any `CartLine` in it, for any cart. For a small catalogue this is cheap. Once cart mutations are frequent and many screens share the same entities, the refetch traffic and the list of hand-written `additionalTypenames` both grow, and that is the usual trigger for moving to Graphcache, urql's normalized cache, which updates entities in place instead of dropping whole results. ## Seeing what the cache did Two signals show which case you are in, without guessing: - In development, `client.subscribeToDebugTarget(event => ...)` receives a `cacheInvalidation` event for each mutation result, naming the typenames it invalidated. If `CartLine` and `Product` are listed but the cart query never refetches, the query was simply never tagged with them. - Each result's `operation.context.meta.cacheOutcome` reads `hit` or `miss`, which tells you whether the empty cart came from the store or from the network. ## Subscriptions and the typename rule Subscription results do not invalidate by their own typenames. Only `additionalTypenames` set on a subscription's context make its events drop cached queries, so a live 'stock changed' subscription does not refresh the product page unless you tag it deliberately.

  • Why must the context object passed to urql's useQuery be memoized?
    `useQuery` builds its request from the query, variables and context. A context object created inline is a new object on every render, so the hook sees a changed request, re-runs the query, re-renders, and repeats. Wrapping it in `useMemo` with a stable dependency list keeps the same object across renders, which is what urql's own docs show for `additionalTypenames`.
  • A cached query was invalidated while no component used it. When is it fetched again?
    Only when something asks for it. The document cache deletes the stored result immediately, then asks the client to re-run the operation as `network-only`, but the client re-executes only operations with an active subscriber. The next time a component mounts that query, the cache misses and the request is sent.

saying these in an interview costs you the question

  • urql's document cache matches cached results by entity id after a mutation.
  • The document cache never invalidates anything unless you write an updater.
  • Selecting __typename in the cart query fixes the empty-list case.
  • An inline context object in useQuery is harmless.
  • Only the queries a mutation's fields directly name are refetched.