skip to content

In Apollo Client 4, fetchMore with the next cursor returns page two of a feed, yet the screen still shows only page one; what is wrong in the cache configuration?

level: seniorimportance: should knowfreq 40%

answer

  1. where did page two land
  2. arguments in the storage key
  3. keyArgs plus merge
  4. relayStylePagination helper

basics

~20 s

Without a field policy, Query.feed is stored once per argument set, so page two lands under its own after-cursor key and the original query never sees it. Set keyArgs to exclude the cursor and add a merge function, or use relayStylePagination.

solid answer

~40 s

By default `InMemoryCache` includes every argument in a field's storage key, so `feed({"first":10})` and `feed({"after":"c10","first":10})` are separate entries. `fetchMore` sends the request and writes the result using its own variables, which puts page two in the second entry while `useQuery` keeps reading the first. The fix is a field policy on `Query.feed`: `keyArgs` names the arguments that really distinguish lists, such as `filter`, leaving cursors out, and a `merge` function splices each incoming page into the existing value. `keyArgs` alone is not enough, because the default merge replaces the value and the screen would show only page two. For a connection-shaped feed, `relayStylePagination(["filter"])` from `@apollo/client/utilities` supplies `keyArgs`, `merge` and a `read` that returns all edges.

code

ts · 12 lines
ts
import { InMemoryCache } from "@apollo/client";
import { relayStylePagination } from "@apollo/client/utilities";

export const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: relayStylePagination(["filter"]),
      },
    },
  },
});

go deeper

for a junior

Recall that a paginated field needs a field policy with keyArgs and a merge function, or a helper such as relayStylePagination.

for a middle

Explain storage keys built from arguments, why fetchMore's result lands under a different key, and why keyArgs without merge shows only the new page.

for a senior

Diagnose from cache.extract which storage keys exist, choose keyArgs from which arguments change the list, and anticipate refetch and filter-switch behaviour of the merge you pick.

for a principal

Standardise pagination field policies per connection shape so every paginated field is configured once in the cache, instead of each screen merging pages with its own updateQuery.

## What the cache does with field arguments In `InMemoryCache`, a field that takes arguments is stored under a **storage key** built from the field name and its argument values. With no configuration, all arguments count, so the root query object can hold: - `feed({"filter":"FOLLOWING","first":10})`: the first page - `feed({"after":"c10","filter":"FOLLOWING","first":10})`: the second page Each entry is independent. That default is safe for fields where arguments change the result, but for pagination it scatters one logical list across many entries. ## Tracing the failed fetchMore The feed component calls `useQuery(FEED, { variables: { filter: "FOLLOWING", first: 10 } })`, then `fetchMore({ variables: { after: pageInfo.endCursor } })` when the user scrolls. 1. `fetchMore` merges its variables with the query's current variables and sends the request straight to the network. 2. When the result arrives, and no `updateQuery` callback was passed, Apollo Client writes it to the cache with the `fetchMore` variables, including `after`. 3. With no field policy, that write creates the second storage key listed above. 4. The original query is still watching the first key, whose value did not change. 5. The screen keeps showing page one, even though the network panel shows page two arrived. ## Fixing it with keyArgs and merge A **field policy** on `Query.feed` changes both how the field is keyed and how writes combine: - **`keyArgs`** lists the arguments that belong in the storage key. `keyArgs: ["filter"]` keeps separate lists per filter while every cursor for the same filter shares one entry. `keyArgs: false` puts no arguments in the key. - **`merge(existing, incoming, { args })`** decides how an incoming page combines with the cached value. It can read `args.after` to splice the page in the right position. - **`read(existing, { args })`** optionally shapes what queries see. A read that ignores `args` returns the whole accumulated list, which is what an infinite feed wants. | Configuration | What the screen shows after fetchMore | |---|---| | no field policy | page one only; page two sits under its own key | | `keyArgs` only | page two only; the default merge replaces the value | | `keyArgs` plus appending `merge` | pages one and two | | `relayStylePagination(["filter"])` | pages one and two, with merged `pageInfo` | A hand-written merge for a plain list of posts can be as small as `merge(existing = [], incoming) { return [...existing, ...incoming]; }`, which is essentially what `concatPagination` generates. It is correct only for strictly forward paging: a refetch of the first page would append a duplicate copy, so production merge functions usually read `args` to decide where a page belongs. The second row surprises people. Once the cursor leaves the key, both pages write to the same entry, and without a merge function the incoming list replaces the existing one. ## Using the built-in helpers `@apollo/client/utilities` exports field-policy factories for common shapes: - `concatPagination(keyArgs)` appends incoming items to the existing array. - `offsetLimitPagination(keyArgs)` places items by `args.offset`. - `relayStylePagination(keyArgs)` handles connection-shaped fields with `edges`, `node`, `cursor` and `pageInfo`. Each defaults `keyArgs` to `false`, so pass the arguments that must still separate lists. For a feed with `edges` and `pageInfo`, `relayStylePagination` merges by cursor: with `args.after` it keeps the existing edges up to that cursor and appends the incoming ones, and with `args.before` it prepends. With neither, for example a pull-to-refresh that asks for the first page again, the incoming edges replace the existing ones. Its `read` also skips edges whose `node` can no longer be read, which keeps the list clean after an eviction. ## Related details in Apollo Client 4 - `fetchMore` defaults `errorPolicy` to `"none"` rather than inheriting the query's policy, so a page with GraphQL errors is not written as partial data unless you pass another `errorPolicy`. - `notifyOnNetworkStatusChange` defaults to `true`, so the component re-renders with `networkStatus` set to `fetchMore` while the page loads. - Calling `fetchMore` on a `cache-only` query throws. - Passing `updateQuery` to `fetchMore` still works: it merges the page into the original query's result by hand. A field policy is usually better because it applies to every query that reads the field. ## Checking the fix Open `cache.extract()` after scrolling. With `keyArgs: ["filter"]` there should be one `feed:{"filter":"FOLLOWING"}` entry, the format a `keyArgs` array produces, whose edges grow with each page, and no `feed(...)` entries containing `after`.

  • The same feed field is also used with a `filter` of `TRENDING`. What goes wrong if you set `keyArgs: false`?
    Both filters then share one storage key, so a TRENDING page is merged into the FOLLOWING list or replaces it, depending on the merge function. `keyArgs` should include every argument that changes which list you get, such as `filter`, and leave out only those that select a page within it, such as `after` and `first`.
  • After `relayStylePagination` is in place, a pull-to-refresh refetch of the first page shows only ten posts. Is that a bug?
    No, it is the helper's documented behaviour. A request with neither `after` nor `before` cannot be spliced into existing edges, so its edges replace them. The user starts from a fresh first page and further `fetchMore` calls append again. If you want to keep older pages, write a custom merge function that handles this case differently.

saying these in an interview costs you the question

  • fetchMore writes page two into the original query's entry, so no configuration is needed.
  • Setting keyArgs alone makes pages append; a merge function is optional.
  • keyArgs false is always right for paginated fields, even when a filter changes the list.
  • The fix is to switch the feed query to the no-cache fetch policy.
  • relayStylePagination is Relay's @connection directive and needs the Relay compiler.