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 useState and 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:

Two ways an event can update the query cache Two panels comparing writing event data directly into the cache with setQueryData and marking queries stale with invalidateQueries. Two ways an event can update the query cache Write: setQueryData event carries full new value no network request instant, exact needs a version check Invalidate: invalidateQueries event says "X changed" triggers a refetch if active simple, always correct storms at high rates
Write when the event carries the new data; invalidate when it only says something changed. Mixing them up causes either wrong data or refetch storms.

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.

Invalidation with and without batching Timeline of two seconds during a burst of forty change events, comparing one refetch per event with batched invalidation producing a refetch every 250 milliseconds. Invalidation with and without batching Change events Refetch per event Batched 250 ms 40 events 40 requests 0 400 800 1200 1600 2000 milliseconds
Forty events, forty refetches without batching; eight with it. The data shown at the end is identical.

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.

API requests per minute on a busy dashboard Bar chart comparing API requests per minute for a dashboard with polling refetches, invalidation per event, batched invalidation, and direct cache writes. API requests per minute on a busy dashboard refetchInterval 5 s 180 Invalidate per event 420 Batched invalidation 60 setQueryData from events 4 API requests per minute per open dashboard
Writing event data into the cache removes most requests entirely; batching keeps the remainder bounded.

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.