Using SSE with TanStack Query Permalink to this section
Part of React EventSource Hooks, under Frontend Consumption & Client Patterns.
Many React applications already fetch server state with TanStack Query (formerly React Query): queries, caching, background refetching and loading states are handled for them. Adding Server-Sent Events should not create a second, parallel state system. The clean approach treats the stream as a source of cache updates: events either write new data straight into the query cache or tell the cache that some data is stale. Components keep using useQuery exactly as before and simply see fresher data. This guide builds that integration and avoids its two classic failures — refetch storms and cache fights.
Symptom & Developer Intent Permalink to this section
- The app holds stream data in
useStateand query data in the cache, and the two disagree. - Every event triggers
invalidateQueries, and a busy stream causes dozens of refetches per second. - A background refetch overwrites data that a newer event had just written, so values jump backwards.
- Each component that needs live updates opens its own
EventSource. - After reconnecting, the cache is stale for data that changed during the disconnection.
The intent is one stream per app feeding one cache, with events applied precisely, refetches used only when needed, and no regressions from stale responses.
Root Cause Analysis Permalink to this section
There are two ways to connect an event to the cache, and each suits a different kind of event:
Refetch storms come from invalidating on every event of a busy stream. Stale overwrites come from ordering: a refetch started before an event can return after it, carrying older data, and TanStack Query stores whatever arrives last. Both are solved by batching invalidations and versioning data.
Step-by-Step Resolution Permalink to this section
Step 1 — Open one stream at the app root Permalink to this section
// LiveUpdates.jsx — mounted once, inside QueryClientProvider.
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
export function LiveUpdates() {
const qc = useQueryClient();
useEffect(() => {
const es = new EventSource('/api/stream', { withCredentials: true });
es.addEventListener('order.updated', (e) => applyOrder(qc, JSON.parse(e.data)));
es.addEventListener('orders.changed', (e) => scheduleInvalidate(qc, JSON.parse(e.data).keys));
es.addEventListener('open', () => qc.invalidateQueries({ refetchType: 'active' })); // see step 5
return () => es.close();
}, [qc]);
return null;
}
Mounting the stream once at the root keeps it independent of which components are on screen and guarantees a single connection. The generic hook in building a useEventSource React hook can replace the inline effect.
Step 2 — Write events that carry data, with a version check Permalink to this section
function applyOrder(qc, order) {
// Detail query: replace only if the event is newer than what is cached.
qc.setQueryData(['order', order.id], (old) => (old && old.version >= order.version ? old : order));
// List queries: patch the item in place if present.
qc.setQueriesData({ queryKey: ['orders'] }, (old) => {
if (!old) return old;
return { ...old, items: old.items.map((o) => (o.id === order.id && o.version < order.version ? order : o)) };
});
}
The server includes a monotonically increasing version (or updatedAt) in each entity. Comparing it stops an older refetch result — or an out-of-order event — from overwriting newer data.
Step 3 — Batch invalidations for change signals Permalink to this section
let pending = new Set();
let timer = null;
function scheduleInvalidate(qc, keys) {
keys.forEach((k) => pending.add(JSON.stringify(k)));
timer ??= setTimeout(() => {
for (const k of pending) qc.invalidateQueries({ queryKey: JSON.parse(k), refetchType: 'active' });
pending = new Set();
timer = null;
}, 250); // at most 4 invalidation rounds per second
}
refetchType: 'active' refetches only queries that are currently mounted; inactive ones are marked stale and refetch when next used.
Step 4 — Tune staleTime so the stream is trusted Permalink to this section
With live updates, data in the cache stays current, so there is no need for aggressive background refetching:
const qc = new QueryClient({
defaultOptions: { queries: { staleTime: 60_000, refetchOnWindowFocus: false } },
});
Leaving refetchOnWindowFocus on with a stream means every tab switch refetches data the stream already keeps current.
Step 5 — Resync after a reconnect Permalink to this section
Events published while the stream was disconnected are only recovered if the server replays them. If it does (via Last-Event-ID), no extra work is needed. If it does not, invalidate active queries on every open after the first, as in step 1, so the cache catches up with one round of refetches.
Step 6 — Design the events around query keys Permalink to this section
The integration is easiest when events map cleanly onto query keys. Agree with the backend on event payloads that identify what changed in the same terms the frontend caches: an order.updated event carries the order’s id and its full representation, matching the ['order', id] detail query; an orders.changed event carries the filter or list keys that are affected. Events that only say “something changed somewhere” force broad invalidation, which is correct but wasteful.
It is also worth deciding which representation is canonical. If list queries return a summary shape and detail queries a full shape, a single order.updated event cannot fill both without either sending the full shape (and deriving the summary) or sending both. Sending the full entity and deriving summaries on the client is usually simplest, and keeps the event count down.
Validation & Monitoring Permalink to this section
In React Query Devtools, watch the query list while publishing events: data-carrying events should update entries without a fetch indicator; change signals should cause at most a few refetches per second. Test the ordering guard by delaying the API response for a query in DevTools, publishing a newer event meanwhile, and confirming the older response does not overwrite it.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Should I use useQuery with a streaming queryFn?
Streaming query functions exist for incremental results of a single request, such as an AI answer. For long-lived app-wide updates, a separate stream that writes into the cache fits TanStack Query's model better.
Write or invalidate?
Write when the event contains the complete new value for a cached query. Invalidate when it only indicates a change, or when the cached shape is derived from data the event does not include.
What about infinite queries?
Patch the relevant page with setQueryData on the infinite query's pages array, or invalidate only the first page when new items are prepended.
Does this work with SWR or RTK Query?
Yes. SWR's mutate and RTK Query's updateQueryData play the role of setQueryData; the same write-versus-invalidate rules apply.