skip to content

In Apollo Client 4, how does subscribeToMore add a newly pushed support-chat message to a room's already-loaded message list?

level: middleimportance: should knowfreq 30%

answer

  1. a subscription riding on a query
  2. the entity lands, the list does not
  3. updateQuery returns the next result
  4. previousData and complete, not argument one
  5. the effect returns the unsubscribe

basics

~20 s

subscribeToMore, returned by useQuery, starts a subscription tied to that query. For each event its updateQuery callback gets the query's cached result and the pushed message, returns a new result with the message appended, and Apollo writes that back to the cache.

solid answer

~50 s

The room's history comes from `useQuery(ROOM_MESSAGES, { variables: { roomId } })`, whose result includes `subscribeToMore`. Calling `subscribeToMore({ document: MESSAGE_ADDED, variables: { roomId }, updateQuery })` starts the subscription and returns an unsubscribe function. For each event, `updateQuery(prev, { previousData, complete, subscriptionData, variables })` returns the query's next result, here the room with the new message appended, and Apollo writes it with `writeQuery`, so every component watching that query re-renders. The callback is needed because the pushed `Message` is normalized into the cache, but nothing adds its reference to the room's `messages` list. Read `previousData` and check `complete` instead of the deprecated first argument, return nothing to skip an event, and skip a message whose `id` is already listed. Call it inside a `useEffect` that returns the unsubscribe function, and pass the subscription's own `variables`, because it does not inherit the query's.

code

tsx · 27 lines
tsx
import { useEffect } from "react";
import { useQuery } from "@apollo/client/react";

export function ChatRoom({ roomId }: { roomId: string }) {
  const { data, subscribeToMore } = useQuery(ROOM_MESSAGES, {
    variables: { roomId },
  });

  useEffect(() => {
    return subscribeToMore({
      document: MESSAGE_ADDED,
      variables: { roomId },
      updateQuery: (_unsafePrev, { previousData, complete, subscriptionData }) => {
        if (!complete) return;
        const incoming = subscriptionData.data.messageAdded;
        const messages = previousData.room.messages;
        if (messages.some((m) => m.id === incoming.id)) return;
        return {
          ...previousData,
          room: { ...previousData.room, messages: [...messages, incoming] },
        };
      },
    });
  }, [roomId, subscribeToMore]);

  return <MessageList messages={data?.room.messages ?? []} />;
}

go deeper

for a junior

Recall that subscribeToMore comes from useQuery's result and that updateQuery returns the query's next data with the new message added.

for a middle

Explain why a new message needs updateQuery while an edited one does not, the previousData and complete options, and why the call belongs in an effect that returns its unsubscribe.

for a senior

Show merge hygiene under real traffic: deduplicating by id, events that arrive before the query loads, frozen cached results, and error events that skip the merge.

for a principal

Decide when events should extend one query's result versus being written once into the cache for many views, and what that choice costs the team in maintenance.

## The problem subscribeToMore solves A support chat room needs two things: the messages already sent, and every message sent from now on. The first is a query, `ROOM_MESSAGES($roomId)`, that returns the room with its `messages` list. The second is a subscription, `MESSAGE_ADDED($roomId)`, that pushes one new message per event. **`subscribeToMore`** joins them. It is a function on the result of `useQuery` (and on the underlying `ObservableQuery`) that starts a subscription whose events are merged into **that query's** cached result. The merge rule is yours: a callback named `updateQuery`. ## What happens to a pushed message without it Apollo Client writes each subscription event into the normalized cache by default. The new message, with `__typename: "Message"` and an `id`, becomes its own cache record. That is enough when an event **edits** a message already on screen: the list already points at that record, so the edit shows at once. A **new** message is different. The room's `messages` field is a list of references stored under the room, and nothing in the event says "append me to that list". The record exists, but the list does not change, so the chat window does not show it. Changing list membership is exactly what `updateQuery` is for. ## The updateQuery contract `updateQuery` is called once for each event that arrives without errors, with two arguments: | Argument | What it is | |---|---| | first (`unsafePreviousData`) | the query's current cached result; deprecated since 3.13 because it may be partial despite its type | | `options.previousData` | the same data, typed honestly | | `options.complete` | `true` when `previousData` is a complete result | | `options.subscriptionData` | `{ data }`, the event the server pushed | | `options.variables` | the **query's** current variables | Whatever it returns is written back with `writeQuery` as the query's new result, and every watcher of the query re-renders. Returning `undefined` skips the write. ## Wiring it into a component 1. Run the query with `useQuery` and take `subscribeToMore` from its result. 2. In a `useEffect` keyed on `roomId`, call `subscribeToMore` with the subscription `document`, its own `variables`, and `updateQuery`. 3. Return the function `subscribeToMore` gives back, so React unsubscribes when the room changes or the component unmounts. 4. In `updateQuery`, bail out when `complete` is `false`, build a new object with the message appended, and return it. The subscription does **not** inherit the query's variables; pass them explicitly, or it runs with none beyond the defaults declared in its own document. ## Getting the merge right - **Never mutate.** Results read from the cache are deep-frozen in development builds, so pushing onto `previousData.room.messages` throws there. Spread into new objects instead. - **Deduplicate by `id`.** The sender's own mutation, through its cache update, or a refetch may already have added the message. - **Respect `complete`.** An event can arrive before the query has loaded; with no complete previous result, return nothing and let the query fetch the full list. - **Keep order explicit.** Append for a chat, prepend for a newest-first feed. - **Handle errors with `onError`.** Without it, an error event is logged as an unhandled subscription error and `updateQuery` is skipped for that event. ## subscribeToMore or useSubscription? | | `subscribeToMore` | `useSubscription` + `onData` | |---|---|---| | Owner | the query's `ObservableQuery` | the component | | Merge target | that query's result, via `updateQuery` | whatever you write in `onData` | | Teardown | your returned unsubscribe, or the query's own teardown | automatic on unmount or variables change | `subscribeToMore` fits when the events only ever extend one query's result, as with one room's message list. When several unrelated views must react to the same events, a single `useSubscription` that writes into the cache fits better; writing to the cache directly is a cache API topic of its own. ## Common mistakes - Expecting new messages to appear in the list with no `updateQuery`. - Calling `subscribeToMore` during render, which starts a new subscription on every render. - Using the first argument as if it were always complete, then crashing on `prev.room.messages` when the room is not cached yet. - Returning `prev` unchanged to skip an event. That still writes the same data back and broadcasts to every watcher; returning nothing skips the write entirely. - Appending without checking which room the message belongs to, which matters as soon as the same component can show different rooms.

  • In Apollo Client 4, why does a pushed event that edits an existing message's text update the room with no updateQuery at all?
    Subscription events are written to the normalized cache unless the subscription uses `fetchPolicy: "no-cache"`. An edited message carries `__typename` and `id`, so it merges into the record the room's list already references, and the list re-renders. `updateQuery` is only needed when membership changes: a message added to or removed from the list.
  • What does returning undefined from subscribeToMore's updateQuery do in Apollo Client 4?
    It skips the write: the cached query result stays as it was and nothing re-renders. Use it to ignore an event, for example when `complete` is `false` because the query has not loaded yet, or when the message `id` is already in the list.
  • How does subscribeToMore report an error event in Apollo Client 4?
    Through its `onError` option, which receives the error. If `onError` is not given, Apollo logs "Unhandled GraphQL subscription error". Either way, `updateQuery` is not called for that event, so the list is left unchanged.

saying these in an interview costs you the question

  • A pushed message is appended to every cached list of messages automatically.
  • updateQuery can push onto the cached messages array and return it.
  • subscribeToMore needs a separate useSubscription call to open the connection.
  • The first updateQuery argument is always the complete, fully typed previous result.
  • subscribeToMore's subscription inherits the query's variables when none are passed.
  • Calling subscribeToMore in the component body is fine because Apollo deduplicates it.