Updating the Cache From WebSocket Events
A WebSocket delivers a stream of small state changes, but React Query’s cache was filled by an entirely separate useQuery fetch. The job of this recipe is to close that gap: subscribe to the socket, and on each message write the change straight into the cache with queryClient.setQueryData so subscribed components re-render instantly with no network round trip. This is the concrete implementation of the broader patterns in Realtime Cache Synchronization, and it pairs naturally with the interval-based approach in Optimizing SWR Revalidation Intervals — use a socket where you need sub-second freshness and fall back to timed revalidation where you do not.
The failure that makes this hard is not the happy path. Writing a fresh event into the cache is three lines. The hard parts are the three ways reality breaks it: events arrive out of order, events are missed entirely while the socket is disconnected, and setQueryData writes to a key that no component is reading. Get those right and the rest is glue.
Diagnostic Checklist
Confirm your symptoms match this pattern before writing code:
- A mutation in one browser tab updates that tab instantly but a second tab stays stale until reload — you have no push channel wired into the cache.
- The UI tracks the server perfectly while connected, then drifts after a sleep/wake or network blip and never self-corrects.
- Calling
setQueryDatain the socket handler appears to do nothing, andgetQueryDatafor the same key returnsundefined. - A field briefly shows an older value after a newer one, then flips back — a stale event overwrote fresh cache data.
- Every socket message re-renders an entire list even though only one row changed.
Step-by-Step Implementation
Step 1 — Subscribe to the socket inside a scoped effect
Open the socket in a useEffect scoped to the feature root, not per row. One connection feeds the whole cache; the effect’s cleanup closes it so a remount does not leak sockets.
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
interface Ticket {
id: string;
title: string;
status: 'open' | 'closed';
version: number;
}
type TicketEvent =
| { kind: 'updated'; ticket: Ticket }
| { kind: 'created'; ticket: Ticket };
export function useTicketSocket(url: string) {
const queryClient = useQueryClient();
useEffect(() => {
const socket = new WebSocket(url);
socket.onmessage = (frame) => {
const event = JSON.parse(frame.data) as TicketEvent;
handleTicketEvent(queryClient, event);
};
return () => socket.close();
}, [url, queryClient]);
}
Cache Behavior Analysis. The effect depends on queryClient, which is a stable singleton from the provider, so the socket opens once and stays open for the component’s lifetime. Routing every frame through a single handleTicketEvent keeps the cache-write logic testable in isolation and out of the render path — nothing here touches component state, so a burst of messages does not schedule React re-renders except through the cache writes the handler performs.
Step 2 — Patch the entity with an immutable updater
For an updated event, write the entity directly with setQueryData. The updater must be immutable and version-guarded.
import type { QueryClient } from '@tanstack/react-query';
function handleTicketEvent(queryClient: QueryClient, event: TicketEvent) {
if (event.kind === 'updated') {
queryClient.setQueryData<Ticket>(['ticket', event.ticket.id], (prev) => {
// Missing-key branch: nothing cached yet, adopt the event as the value
if (!prev) return event.ticket;
// Out-of-order guard: drop events older than what we already hold
if (event.ticket.version <= prev.version) return prev;
// Immutable patch: spread into a NEW object so the reference changes
return { ...prev, ...event.ticket };
});
}
if (event.kind === 'created') {
// Membership change — patch handled in Step 3
queryClient.invalidateQueries({ queryKey: ['tickets', 'list'] });
}
}
Cache Behavior Analysis. setQueryData writes synchronously and notifies every observer whose key is exactly ['ticket', id], so a mounted detail view re-renders from cache without a fetch or a loading flash. Returning prev unchanged in the guard branches yields the same reference React Query already holds, so it skips notification entirely — a stale or duplicate event is a free no-op. Returning { ...prev, ...event.ticket } produces a new top-level reference, which is what lets structuralSharing diff the result and preserve references for any nested field that did not actually change.
Step 3 — Invalidate lists when membership changes
A patch updates one entity, but it cannot express “a new row belongs in this list in server-ranked position.” For membership changes, invalidate the list and let React Query refetch its authoritative order. If your list is purely chronological you can instead setQueryData the list key and append, but invalidation is the safe default and connects to the tag-based invalidation approach for scoping exactly which lists to flush.
function invalidateAffectedLists(queryClient: QueryClient, ticket: Ticket) {
// Prefix match: flushes ['tickets','list'], ['tickets','list',{status}], etc.
queryClient.invalidateQueries({ queryKey: ['tickets', 'list'] });
// Also refresh any status-scoped board the ticket now belongs to
queryClient.invalidateQueries({
queryKey: ['tickets', 'board', ticket.status],
});
}
Cache Behavior Analysis. invalidateQueries matches by key prefix, so one call marks every list variant stale; those with an active observer refetch immediately while inactive ones refetch lazily on next mount. Because the refetch flows through the normal queryFn and then structuralSharing, a list whose contents did not actually change returns a referentially stable array and its subscribers do not re-render, so a spurious invalidation is cheap.
Step 4 — Invalidate on reconnect to backfill missed events
While the socket is closed, no patches arrive and the cache drifts. On reconnect, invalidate every key the socket feeds so React Query refetches the truth.
export function useTicketSocketWithReconnect(url: string) {
const queryClient = useQueryClient();
useEffect(() => {
let hadDropped = false;
function connect(): WebSocket {
const socket = new WebSocket(url);
socket.onopen = () => {
if (hadDropped) {
hadDropped = false;
// Backfill: we cannot know which events we missed, so refetch all
queryClient.invalidateQueries({ queryKey: ['ticket'] });
queryClient.invalidateQueries({ queryKey: ['tickets'] });
}
};
socket.onmessage = (frame) =>
handleTicketEvent(queryClient, JSON.parse(frame.data) as TicketEvent);
socket.onclose = () => {
hadDropped = true;
setTimeout(connect, 1_000); // naive backoff; use jittered backoff in prod
};
return socket;
}
const socket = connect();
return () => socket.close();
}, [url, queryClient]);
}
Cache Behavior Analysis. The hadDropped flag ensures the backfill only runs after a genuine reconnect, not on the first connection when the initial useQuery already loaded the data. Invalidating the broad ['ticket'] and ['tickets'] prefixes refetches every entity and list the stream feeds; React Query dedupes concurrent refetches of the same key and only refetches keys with active observers immediately, so the backfill cost scales with what is on screen, not with everything ever cached.
Edge Cases and Gotchas
Out-of-order events
Application events can race even though frames are ordered, especially when a reconnect replays a backlog against live traffic. The version <= prev.version guard in Step 2 makes every write idempotent and commutative: applying events in any order converges to the newest one. Without a monotonic field from the server you cannot solve this on the client — fall back to invalidation so the server decides the final state.
Missed while disconnected
The socket cannot deliver what happened while it was closed, so no amount of client logic recovers those events directly. The only correct recovery is the Step 4 reconnect invalidation, which asks the server for current truth. If your outage windows are long or your event volume is high, prefer invalidating a broad prefix over trying to reconstruct individual entities.
setQueryData on a missing key
If no useQuery has ever populated ['ticket', id], your updater receives undefined. Returning a value there creates the entry, but with no mounted observer it is retained silently and never rendered — and worse, it may be a partial object if the event was a delta rather than a full entity. Gate detail writes on existence when a partial patch would corrupt the slot:
const exists = queryClient.getQueryData(['ticket', event.ticket.id]) !== undefined;
if (exists) {
queryClient.setQueryData<Ticket>(['ticket', event.ticket.id], (prev) =>
prev ? { ...prev, ...event.ticket } : prev,
);
}
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
setQueryData runs but the screen never updates |
The event key does not exactly match the mounted query’s key, or the updater mutated prev and returned the same reference |
Compare against getQueryCache().findAll() keys for exact match; always return { ...prev }, never a mutated prev |
| A value flickers older then corrects | Out-of-order / replayed event overwrote fresher cache data | Add a monotonic version/updatedAt and return prev when the incoming version is not strictly newer |
| Data stays stale forever after a network blip | Reconnect fired but nothing invalidated, so the cache never resynced the missed gap | Set a hadDropped flag on onclose and call invalidateQueries for every stream-fed prefix in onopen |
| A partial delta event corrupts a detail view | setQueryData created a new entry from a delta with no prior full entity |
Gate the write with getQueryData(...) !== undefined, or invalidate to force a full refetch instead of patching |
Frequently Asked Questions
What happens if setQueryData targets a queryKey that is not in the cache?
React Query creates the entry and stores your returned value under it; your updater receives undefined as the previous value, so you must handle that branch — returning undefined leaves the entry empty. A freshly created entry has no active observer unless a component is already mounted with that exact key, so the write is retained but invisible until something reads it. To avoid writing detail entries no one will read, or corrupting a slot with a partial delta, gate the write on whether the key already exists via getQueryData.
Why should the setQueryData updater be immutable instead of mutating the cached object?
React Query compares the reference your updater returns against the previous one to decide whether to notify observers and how structuralSharing reuses references. If you mutate prev in place and return it, the reference is unchanged, so observers are not notified and the UI does not update — and structuralSharing has nothing to diff. Always return a new object built by spreading the previous value ({ ...prev, ...patch }) so both change detection and reference reuse work correctly.
How do I recover events that arrived while the WebSocket was disconnected?
You cannot recover them from the socket because they were never delivered to the client. On the onopen event following a drop, call invalidateQueries for every queryKey the socket feeds so React Query refetches the authoritative state from the server, backfilling the gap in one pass. Track a hadDropped flag so the backfill only runs after an actual reconnect, not on the initial connection where the data is already fresh from its first fetch.
Related
- Realtime Cache Synchronization — the parent reference covering WebSocket, SSE, and GraphQL subscription strategies, and the decision between
setQueryDataandinvalidateQueriesper event. - Optimizing SWR Revalidation Intervals — the interval-driven alternative for endpoints that do not warrant a live socket, and how to combine timed revalidation with event pushes.
- Tag-Based Invalidation Systems — how to structure query keys so a single WebSocket event invalidates exactly the dependent lists and nothing more.
- Cache Invalidation & Server Synchronization — the foundational section on keeping client cache consistent with the server across mutations, refetching, and reconnection.