skip to content

In Apollo Client 4, after a user deletes a feed post, when do you remove it with cache.evict and cache.gc, cache.modify, or readQuery and writeQuery?

level: middleimportance: must knowfreq 50%

answer

  1. entity, field or whole query
  2. dangling references in lists
  3. modifiers skip merge functions
  4. exact query and variables

basics

~10 s

cache.evict removes the Post entity everywhere, and cache.gc then drops what became unreachable. cache.modify edits specific fields in place, bypassing merge functions. readQuery and writeQuery rewrite the result of one exact query and variables.

solid answer

~40 s

`cache.evict({ id: cache.identify(post) })` deletes the `Post` entity itself. List fields that still reference it drop the dangling reference automatically when read, and `cache.gc()` then removes objects that are no longer reachable, such as comments that only the post referenced. `cache.modify` edits fields of one entity, or of `ROOT_QUERY` when no `id` is given, through modifier functions: filter the post out of `feed`, decrement `Author.postCount`, or return `DELETE` or `INVALIDATE`. It bypasses merge functions and reaches every argument variant of the field. `readQuery` and `writeQuery` work on one query with exact variables: `readQuery` returns `null` if any field is missing, and `writeQuery` runs merge functions, so an appending pagination merge will re-add pages unless you pass `overwrite: true`. Evict for deletions, modify for targeted edits, and read/write for whole-shape rewrites.

code

tsx · 26 lines
tsx
import { gql } from "@apollo/client";
import { useMutation } from "@apollo/client/react";

const DELETE_POST = gql`
  mutation DeletePost($id: ID!) {
    deletePost(id: $id) {
      id
      author { id }
    }
  }
`;

export function useDeletePost() {
  return useMutation(DELETE_POST, {
    update(cache, { data }) {
      const post = data?.deletePost;
      if (!post) return;
      cache.evict({ id: cache.identify({ __typename: "Post", id: post.id }) });
      cache.modify({
        id: cache.identify({ __typename: "Author", id: post.author.id }),
        fields: { postCount: (count: number) => count - 1 },
      });
      cache.gc();
    },
  });
}

go deeper

for a junior

Recall the three tools: evict removes an entity, modify edits fields in place, and readQuery with writeQuery rewrite one query's cached result.

for a middle

Explain dangling references, why lists filter them but single references do not, what gc collects, and why modify bypasses merge functions while writeQuery does not.

for a senior

Pick the tool from the data shape, such as connection edges versus plain reference lists, and anticipate the refetches and duplicates each choice can cause in other views.

for a principal

Encourage shared update helpers keyed by entity type so deletions and counters are handled once, instead of each mutation hand-editing whichever queries its author remembered.

## Three tools at three scopes Apollo Client's `InMemoryCache` offers several ways to change cached data after a mutation, usually inside a mutation's `update(cache, result)` callback. They work at different scopes, and choosing the wrong one is behind many "the deleted post is still on screen" bugs. | API | Scope | Runs merge functions | Typical use after a delete | |---|---|---|---| | `cache.evict` + `cache.gc` | one entity, or one field of it | no | remove the post everywhere | | `cache.modify` | chosen fields of one entity | no | filter a list, adjust a counter | | `readQuery` / `writeQuery` | one query with exact variables | yes, on write | rewrite a small, fixed-shape result | ## cache.evict and cache.gc `cache.evict({ id })` removes an entity from the normalized store. Pass the ID from `cache.identify(post)` rather than formatting it yourself, so custom `keyFields` are respected. `evict` returns `true` when something was removed. After the eviction, other objects may still hold references to the removed post: - **List fields** clean themselves up. When a list of references is read, the cache filters out references to entities that no longer exist, so the `feed` list simply shows one fewer post. - **Single-reference fields**, such as `Notification.post`, keep the dangling reference. The read then finds the field missing, the result becomes incomplete, and the query may go back to the network. A custom `read` function can use the `canRead` helper to return `null` instead. - **Wrapper objects** are not filtered. If the feed stores `edges` objects that each hold a `node` reference, the edge survives with a dangling `node`. The `relayStylePagination` helper's `read` function filters those edges; a hand-written connection policy has to do the same. `cache.evict` does not remove the objects the evicted post pointed to. Its comments, for example, stay cached; if nothing else references them, they are now unreachable. `cache.gc()` walks from the root objects, removes every normalized object that is no longer reachable, and returns the list of removed IDs. `cache.retain(id)` protects an object from collection. `evict` also takes `fieldName` and optional `args`, which removes a single field instead of an entity: `cache.evict({ fieldName: "feed" })` drops every cached variant of the feed from `ROOT_QUERY`, so queries watching it find the field missing and typically fetch it again. ## cache.modify `cache.modify({ id, fields })` changes fields of one cached object. Without `id` it targets `ROOT_QUERY`. Each entry in `fields` is a **modifier function** that receives the current value and a details object with `readField`, `canRead`, `toReference`, `DELETE` and `INVALIDATE`. Facts that decide when to use it: 1. It **bypasses merge functions**: whatever the modifier returns is written as is. 2. A modifier keyed by field name runs for **every stored variant** of that field, so `feed` cached under several argument sets is filtered in one call. 3. Returning `DELETE` removes the field; returning `INVALIDATE` invalidates it, so watched queries that consumed it are re-read, without changing the value. 4. If the object or field is not in the cache, the modifier does not run and nothing breaks, so a view that never loaded the feed needs no special case. For a delete, a typical call filters the post's reference out of `feed` using `readField("id", ref)` and decrements the author's `postCount` on the `Author` entity. ## readQuery and writeQuery `cache.readQuery({ query, variables })` executes a query against the cache and returns the data, or `null` if any requested field is missing. It never goes to the network. `cache.writeQuery({ query, variables, data })` writes data in the shape of that query. Both are bound to the exact variables you pass, because each argument set of a field can be stored under its own key. The trap for feeds: `writeQuery` goes through the normal write path, so the field's `merge` function runs. If `feed` has an appending merge function, writing back a filtered list appends it to the existing list and duplicates posts. Passing `overwrite: true` makes the merge function receive no existing value. `cache.updateQuery` wraps the read-modify-write cycle in one call and has the same semantics. ## A decision rule for a deleted post - The post should vanish everywhere, and lists hold plain references: `cache.evict` plus `cache.gc`. - A counter or a single list needs a targeted edit and merge functions must not interfere: `cache.modify`. - A small query with fixed variables must be rewritten in its own shape: `readQuery` and `writeQuery`, remembering `overwrite: true` when a merge function would combine the values. Often the best update function combines them: evict the post, then modify the author's counter.

  • After `cache.evict`, a notification screen that shows `notification.post` starts refetching. Why?
    Lists drop dangling references automatically, but a single-reference field does not. `Notification.post` still points at the evicted `Post`, so reading it finds the field missing and the cached result is incomplete, which sends the query back to the network. A `read` function on that field that returns `null` when `canRead(existing)` is false keeps the screen on cached data.
  • Why does `cache.gc()` keep a `Post` you wrote with `cache.writeFragment({ id: "Post:9", ... })`, even though no query references it?
    The cache retains the ID an explicit write targets, treating it as a root for garbage collection, just as `cache.retain(id)` does. `cache.gc()` removes only normalized objects that are neither reachable from a root such as `ROOT_QUERY` nor retained. Calling `cache.release("Post:9")` lowers the retain count so a later `gc` can collect it.

saying these in an interview costs you the question

  • cache.evict also deletes every object the evicted post referenced.
  • After cache.evict, every field that referenced the post returns null automatically.
  • cache.modify runs the field's merge function on the value you return.
  • readQuery fetches missing fields from the server before returning.
  • writeQuery replaces a paginated list outright, whatever merge function the field defines.