In Apollo Client 4, closeIssue returns { id status } and sets refetchQueries: ["OpenIssues"], yet the open-issues page opened next still lists the closed issue; why, and how do you fix it?
answer
- two separate problems stacked
- the entity changed, the list did not
- refetch reaches only watched queries
- an unknown-query warning in development
- edit the cached list inside update
basics
~20 sThe payload updates Issue:42's fields, not the cached issues(status: OPEN) list that references it, and refetchQueries by name refetches only watched queries, so the unmounted OpenIssues is skipped. Remove the issue from that cached list in an update function.
solid answer
~50 sTwo things combine. Normalization merges `{ id status }` into `Issue:42`, so any row showing it would say `CLOSED`. The open list, though, is a separate cached field, `issues({"status":"OPEN"})`, that still references `Issue:42`, and Apollo never re-runs the server's filter. The intended safety net, `refetchQueries: ["OpenIssues"]`, only reaches queries with an active subscriber. The open-issues page was not mounted, so Apollo logged an unknown-query warning in development and refetched nothing. When the page mounts, `cache-first` serves the stale list. Fix it in a way that does not depend on what is mounted. In `update`, call `cache.updateQuery` for `OPEN_ISSUES` with `{ status: "OPEN" }` and filter the issue out; if that list was never cached, nothing is written. Alternatives are evicting the list field so the next mount fetches, or `cache-and-network` on that page. Add `optimisticResponse` so the row leaves at once.
code
tsx · 32 linesimport { gql, type TypedDocumentNode } from "@apollo/client";
import { useMutation } from "@apollo/client/react";
type Status = "OPEN" | "CLOSED";
type Issue = { __typename: "Issue"; id: string; title: string; status: Status };
const OPEN_ISSUES: TypedDocumentNode<{ issues: Issue[] }, { status: Status }> = gql`
query OpenIssues($status: IssueStatus!) {
issues(status: $status) { id title status }
}
`;
const CLOSE_ISSUE: TypedDocumentNode<
{ closeIssue: Pick<Issue, "__typename" | "id" | "status"> },
{ id: string }
> = gql`
mutation CloseIssue($id: ID!) {
closeIssue(id: $id) { id status }
}
`;
export function useCloseIssue() {
return useMutation(CLOSE_ISSUE, {
update(cache, { data }) {
const closed = data?.closeIssue;
if (!closed) return;
cache.updateQuery({ query: OPEN_ISSUES, variables: { status: "OPEN" } }, (list) =>
list ? { issues: list.issues.filter((issue) => issue.id !== closed.id) } : null
);
},
});
}go deeper
Recognise that a changed field on an issue does not change which lists contain it, and that the list must be edited or refetched.
Explain why refetchQueries by name skipped an unmounted query, and write an update function that filters the issue out of the cached open list.
Diagnose the two stacked causes from the symptom and the development warning, fix both lists that the mutation affects, and choose between editing, evicting and refetching per list.
Decide where list-correctness rules live when many screens read the same entities, and whether filtered lists should always be refetched instead of patched in client code.
## Two failures, one symptom The team did two reasonable things: the mutation returns the fields it changes, and it lists the affected query in `refetchQueries`. The closed issue still shows on the open-issues page because each measure covers a different gap, and in this navigation flow neither one fires where it is needed. Separating the two is the senior part of the answer. ## Why the entity changed and the list did not Apollo Client's `InMemoryCache` stores entities and list fields separately: - `closeIssue` returns `{ __typename: "Issue", id: "42", status: "CLOSED" }`, which is merged into the `Issue:42` record. Any component rendering that issue now shows `CLOSED`. - The `OpenIssues` query stored its result under a root field keyed by its arguments, `issues({"status":"OPEN"})`, as a list of references such as `{ __ref: "Issue:42" }`. - Apollo does not know that `status: CLOSED` removes an issue from `issues(status: OPEN)`. That rule lives in the server's resolver, and the client never re-evaluates it. - Nothing in the mutation result mentions the list field, so the reference stays. Rendering the list would show issue 42, now labelled `CLOSED`, inside "Open issues". ## Why refetchQueries did nothing `refetchQueries` does not look queries up in the cache. It looks them up among the client's **tracked queries**: - In Apollo Client 4, only queries with **at least one subscriber**, meaning a mounted `useQuery` or a `watchQuery` someone subscribed to, are tracked. A query whose component has unmounted cannot be refetched. - **Active** queries are tracked queries that are not skipped and not in `standby`. The `"active"` shorthand refetches those, and `"all"` adds the inactive ones, but neither reaches an unmounted page. - A name like `"OpenIssues"` that matches no tracked query is ignored with a development warning: `Unknown query named "OpenIssues" requested in refetchQueries options.include array`. The user closed the issue from its detail page, so `OpenIssues` was not mounted. When they navigated to the list, its `useQuery` mounted with the default `cache-first` policy, found a complete cached result, and rendered it without a request. ## Fixes compared | Fix | Works when the list is unmounted | Extra request | Cost | |---|---|---|---| | `update` edits the cached list | yes | none | re-states the server's filter rule in client code | | `update` evicts the list field | yes, fetched on next mount | one, later | uses the cache API, may over-fetch | | `refetchQueries` by name | no | one, now | only reaches mounted queries | | `cache-and-network` on the list page | yes | one per mount | a brief stale flash before the response | The first fix fits this case: the rule "a closed issue is not open" is simple and stable. ## Writing the update function 1. Read the `closeIssue` result from the second argument of `update`, and return early if there is no data. 2. Call `cache.updateQuery({ query: OPEN_ISSUES, variables: { status: "OPEN" } }, updater)`. It reads that exact cached result and writes back whatever the updater returns. 3. In the updater, return `null` when the list was never cached, which writes nothing, or a copy with issue 42 filtered out. 4. Apollo broadcasts the change. If the list is mounted anywhere, for example in a sidebar, it re-renders at once, and if it mounts later it reads the corrected list. 5. If a closed-issues list is cached too, update or evict it in the same function, or the issue will be missing there instead. Adding an `optimisticResponse` makes the removal instant. The `update` function runs against the optimistic layer first, then again with the real result. ## Where awaitRefetchQueries fits `awaitRefetchQueries: true` does not help an unmounted list, but it matters when the list is mounted, say in a split view, and the code navigates after `await closeIssue(...)`. Without it, the `mutate` promise resolves before the refetch completes, and the next screen can read the pre-refetch list. With it, the promise and the hook's `loading` wait until the refetched queries have returned. A workable rule: fix what the client can compute inside `update`, and refetch only what it cannot.
- Both the open and the closed lists are cached. What should closeIssue's update function do?Correct both, or the issue moves from one stale list to another. Filter it out of `issues(status: OPEN)`. Then either insert it into `issues(status: CLOSED)` at the right position, if the order is simple, or evict that list field so its page fetches on the next mount. Evicting and field keys belong to the cache API; the decision to touch both lists belongs to the mutation.
- The open list is mounted in a sidebar. When does awaitRefetchQueries matter?When code runs after `await closeIssue(...)`, such as navigating or closing a dialog, and must see the refreshed list. Without `awaitRefetchQueries`, the `mutate` promise resolves before the `OpenIssues` refetch returns. With it, the promise and the hook's `loading` wait for the refetch. It still only reaches mounted queries.
saying these in an interview costs you the question
- Returning the issue's new status removes it from filtered lists automatically.
- refetchQueries by operation name also refetches pages that are not mounted.
- refetchQueries: "active" includes cached queries of pages the user has left.
- An edit to the cached list in update only takes effect after a refetch.
- Switching every list to no-cache is the robust fix for stale lists.