TanStack Query is a cache with a fetch function attached. A WebSocket is another way of finding out that the cache is stale — so the right integration writes incoming messages into the cache with setQueryData or invalidates the key, rather than keeping a parallel copy of the data in component state.
The pattern that goes wrong is easy to fall into: fetch the initial data with a query, then hold live updates in a useState and merge the two at render time. Now the same data exists in two places with different lifecycles, and every component reading it needs to know about both.
There is a much simpler shape.
Table of contents
- One socket for the application, not one per component
- setQueryData or invalidateQueries
- The reconnection gap, and how to close it
- Sending, and where optimistic updates fit
- How this fits the rest of the stack
- FAQ
One socket for the application, not one per component
A socket per component means a socket per mount, reconnection storms during navigation, and no clear owner of the connection. Open it once, high up, and let the handler write into the cache.
import { createContext, useContext, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import useWebSocket, { ReadyState } from 'react-use-websocket';
const SocketContext = createContext(null);
export function SocketProvider({ children }) {
const queryClient = useQueryClient();
const { sendMessage, lastMessage, readyState } = useWebSocket(SOCKET_URL, {
shouldReconnect: () => true,
reconnectAttempts: 10,
reconnectInterval: (n) => Math.min(1000 * 2 ** n, 30000)
});
useEffect(() => {
if (!lastMessage?.data) return;
const { type, payload } = JSON.parse(lastMessage.data);
switch (type) {
case 'MESSAGE_CREATED':
queryClient.setQueryData(['messages', payload.roomId], (old = []) =>
old.some(m => m.id === payload.id) ? old : [...old, payload]
);
break;
case 'ORDER_UPDATED':
// Small, self-contained update: write it directly
queryClient.setQueryData(['order', payload.id], payload);
break;
case 'INVENTORY_CHANGED':
// Wide-reaching change: let the query refetch
queryClient.invalidateQueries({ queryKey: ['inventory'] });
break;
}
}, [lastMessage, queryClient]);
return (
<SocketContext.Provider value={{ sendMessage, connected: readyState === ReadyState.OPEN }}>
{children}
</SocketContext.Provider>
);
}
The components below this know nothing about sockets. They call useQuery(['messages', roomId]) as usual and re-render when the cache changes.
The duplicate check in the first case matters. Sockets deliver the same message twice more often than you would like — a reconnect that replays, a server that fans out to a sender as well as to receivers — and a chat window that shows every message twice is the classic symptom.
setQueryData or invalidateQueries
Both are correct in different situations, and choosing badly is the main source of either stale data or unnecessary load.
setQueryData writes the value you already have. No network request, instant update. Right when the message contains the complete new state of a single, self-contained entity.
invalidateQueries marks the data stale so the query refetches. One network request per affected query. Right when the message is a notification that something changed rather than a description of the new state, when the change affects several queries, or when the socket payload is a partial that you cannot safely merge.
The trap with setQueryData is trusting a payload that is not the whole truth. If a socket message contains three fields and your query returns twelve, writing the message into the cache silently drops nine of them. Merge explicitly:
queryClient.setQueryData(['order', id], (old) =>
old ? { ...old, ...payload } : undefined
);
Returning undefined when there is no existing entry is deliberate — it avoids creating a partial cache entry that later reads will treat as complete.
For a high-frequency stream — a price ticker, a cursor position — neither is right. Invalidating on every tick means a request per tick; writing on every tick means a render per tick. Buffer and flush on an interval:
const buffer = useRef([]);
useEffect(() => {
const id = setInterval(() => {
if (buffer.current.length === 0) return;
const batch = buffer.current;
buffer.current = [];
queryClient.setQueryData(['ticks'], (old = []) => [...old, ...batch].slice(-500));
}, 250);
return () => clearInterval(id);
}, [queryClient]);
The reconnection gap, and how to close it
This is the correctness problem that most implementations have and few notice, because it only appears when the network hiccups.
While the socket is disconnected, events happen. When it reconnects, you resume receiving new events — but the ones that occurred during the gap were never delivered, and the cache still holds pre-disconnect data. The UI looks fine and is wrong, which is the worst combination.
The fix is to treat a reconnect as a signal that everything is stale:
const { readyState } = useWebSocket(SOCKET_URL, { shouldReconnect: () => true });
const wasConnected = useRef(false);
useEffect(() => {
if (readyState === ReadyState.OPEN) {
if (wasConnected.current) {
// We were connected, dropped, and are back: refetch everything live
queryClient.invalidateQueries({ type: 'active' });
}
wasConnected.current = true;
}
}, [readyState, queryClient]);
Using type: 'active' limits the refetch to queries currently mounted, so you refresh what the user is looking at rather than the entire cache.
Two related defaults are worth keeping on. refetchOnWindowFocus catches the case where a laptop was asleep, and refetchOnReconnect handles the browser’s own network events. Between them and the reconnect handler above, the gaps are covered.
Show connection state in the interface, too. A small indicator when the socket is down is far better than silently showing stale data as though it were live — the user can decide whether to trust what they are seeing.
Sending, and where optimistic updates fit
Reads come through the socket; writes should usually still go through a mutation, because mutations give you the retry, error and optimistic handling that a fire-and-forget socket send does not.
const sendMessage = useMutation({
mutationFn: (text) => api.post('/messages', { text, roomId }),
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['messages', roomId] });
const previous = queryClient.getQueryData(['messages', roomId]);
queryClient.setQueryData(['messages', roomId], (old = []) => [
...old,
{ id: `temp-${Date.now()}`, text, pending: true }
]);
return { previous };
},
onError: (err, text, context) => {
queryClient.setQueryData(['messages', roomId], context.previous);
}
// No onSettled refetch: the socket will deliver the real message
});
The temporary identifier is what lets the socket handler recognise its own echo. When the server broadcasts the real message back, replace the pending entry rather than appending beside it — otherwise the sender sees their message twice, once optimistically and once for real.
The cancelQueries call in onMutate is not optional. Without it, an in-flight refetch can land after your optimistic write and overwrite it with data that predates the change.
How this fits the rest of the stack
The whole integration comes down to one decision: the query cache is the single source of truth, and the socket is one of the things that updates it. Everything else — deduplication, reconnect invalidation, buffering, optimistic echoes — follows from taking that seriously.
What the client cannot solve is the server side. A WebSocket needs a process that stays running, holds connections open, and survives a deploy without dropping every client at once — which is exactly the shape serverless functions cannot take. On RunxBuild, a web service in Node, Next.js, Python, Go, Ruby, Java, .NET or Docker runs as a persistent process with a live route, build and runtime logs in one place, autoscaling between a floor and ceiling plan you choose, and rollback to the previous deploy. Managed MySQL and Postgres sit behind it on private networking with real connection limits. To see what a service, its database and storage add up to, the RunxBuild hosting calculator lists them as separate line items.
Useful related references:
- FastAPI WebSocket: Build a Connection That Survives Production
- WebSockets Behind Nginx: A Config That Survives Production
- What Is a Query in a Database? The Answer That Actually Helps You Write One
- Services on RunxBuild
FAQ
Should I use TanStack Query with WebSockets or replace it?
Use both. The query cache stays the single source of truth and the socket becomes another way of updating it, through setQueryData or invalidateQueries. Keeping socket data in separate component state creates two copies of the same information with different lifecycles, which every consuming component then has to reconcile.
When should I use setQueryData instead of invalidateQueries?
Use setQueryData when the message contains the complete new state of one self-contained entity — no network request needed. Use invalidateQueries when the message only signals that something changed, when several queries are affected, or when the payload is partial and cannot be merged safely.
Why does my chat show duplicate messages?
Either the socket delivered the same event twice — reconnects replay, and some servers echo to the sender as well as to other clients — or an optimistic entry was never replaced by the real one. Deduplicate by ID when writing into the cache, and give optimistic entries a temporary ID the handler can recognise and replace.
What happens to data that changed while the socket was disconnected?
Nothing delivers it, so the cache silently holds pre-disconnect state. Detect the reconnect and invalidate active queries so everything currently on screen refetches. Leaving refetchOnWindowFocus and refetchOnReconnect enabled covers the related cases where the machine was asleep.
How do I handle a very high-frequency stream?
Do not touch the cache on every message. Buffer incoming messages in a ref and flush them into the cache on an interval, capping the retained history. Writing per message causes a render per message, and invalidating per message causes a request per message — both fall over at a few hundred events a second.