Real-Time React: When the Server Has Something to Say

Everything in this series so far has had the same conversation shape: the client asks a question, the server answers. You call useQuery, the request goes out, the response comes back. The client always speaks first. It's a comfortable model, and it's most of what a web app does.
Real-time flips the direction. Now the server speaks first — a new message lands, a price ticks, another user drags a card across the board — and your UI has to already be listening, with no request that triggered it. That inversion is the whole thing that makes real-time feel different to build, and it's where people reach for a WebSocket and immediately make the mistake that poisons the rest of the feature.
The mistake is treating the live connection as a second source of truth. You already have server state, cached and managed. Then you open a socket, start catching messages, and stuff them into a separate piece of React state. Now you have two stores that are supposed to represent the same reality, updated through two different channels, and they drift. This article is about not doing that — how I wire a live connection into the state I already have, so the app stays live and consistent.
First: Do You Even Need a WebSocket?
Before the socket, a fork in the road, because "real-time" gets reached for reflexively and WebSockets are the heaviest option.
There are really three tools, and they're not interchangeable. Polling — just refetch on an interval — is the humble one everyone forgets. If "real-time" means "within thirty seconds," a refetchInterval on an existing query is the entire feature, no new infrastructure, and you should feel no shame about it. Server-Sent Events (SSE) are a one-way stream from server to client over plain HTTP: perfect when the server needs to push but the client never pushes back — notifications, a live feed, status updates. WebSockets are the full bidirectional pipe, and you want them when both sides talk continuously: chat, collaborative editing, multiplayer anything.
The honest default is to start at the top of that list and only move down when the requirement forces it. A surprising number of "we need WebSockets" features are actually a polling interval or an SSE stream wearing a costume. Reach for the bidirectional socket when you genuinely have bidirectional, continuous communication — not before.
The Core Idea: One Source of Truth
Here's the principle that makes the rest of this easy, and it's worth stating before any code:
The live connection is a delivery mechanism, not a place to store state. Messages arrive on the socket and are handed to your existing cache. They don't get their own store.
You spent the data fetching article setting up TanStack Query as the manager of server state. Real-time doesn't replace that — it feeds it. When a WebSocket message arrives, its job isn't to become a new piece of state you render directly. Its job is to update the cache that's already the source of truth. The socket is a courier. It delivers news to the store; it isn't the store.
Get this one idea right and the two-sources-of-truth bug simply can't happen, because there's still only one source of truth. The socket just has a new way of writing to it.
Wiring a Socket Into the Cache
Concretely, there are two moves when a message arrives, and choosing between them is most of the skill.
The blunt move is invalidate: a message says "something changed over here," and you tell TanStack Query to refetch that query. You don't trust the message to carry the new data — you use it purely as a signal, and the refetch pulls fresh truth from the server.
import { useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; function useTicketUpdates(socket: WebSocket) { const queryClient = useQueryClient(); useEffect(() => { function handleMessage(event: MessageEvent) { const msg = JSON.parse(event.data); if (msg.type === 'ticket.updated') { // The socket is just a signal. Refetch the real data. queryClient.invalidateQueries({ queryKey: ['tickets', msg.ticketId] }); } } socket.addEventListener('message', handleMessage); return () => socket.removeEventListener('message', handleMessage); }, [socket, queryClient]); }
Invalidation is safe and simple: the server stays authoritative, and you never render data the socket made up. The cost is an extra round trip per message, which is fine for low-frequency events and wrong for a firehose.
The sharp move is write the message straight into the cache with setQueryData, when the message already carries the full new value and refetching would be wasteful — a chat message, a price tick:
function handleMessage(event: MessageEvent) { const msg = JSON.parse(event.data); if (msg.type === 'chat.message') { queryClient.setQueryData(['chat', msg.roomId], (old: Message[] = []) => [ ...old, msg.message, ]); } }
Same principle either way — the socket updates the one cache — but setQueryData skips the round trip by trusting the payload. My rule of thumb: invalidate for "something changed," write directly for "here is exactly what changed." High-frequency streams where a refetch-per-message would melt your server are the clear case for writing directly; everything else, start with invalidate because it's harder to get wrong.
The Part Everyone Underestimates: The Connection Lifecycle
Opening a socket is the easy 20% again. The other 80% is that connections are not reliable, and a real-time feature is only as good as how it handles a connection that isn't there.
The network drops. The laptop sleeps and wakes. A deploy restarts the server and every socket dies at once. If your feature assumes a persistent, healthy connection, it silently stops updating and the user has no idea — which is worse than a visible error, because they're now looking at stale data they believe is live. So the lifecycle work is not optional polish; it's the feature. You need reconnection with backoff so a blip heals itself instead of ending the session. You need to resync on reconnect — when the socket comes back, invalidate the relevant queries and refetch, because you almost certainly missed messages while it was down, and the socket can't replay history it never delivered. And you owe the user connection status in the UI, even something quiet, so "live" and "trying to reconnect" don't look identical.
This is exactly why not hand-rolling the raw WebSocket for anything serious is the right call. A library like Socket.IO exists to handle reconnection, fallbacks, and connection state for you — the same trade as every other article in this series, where the value isn't the raw capability but the library that manages its messy edges. Raw WebSocket is fine to learn on and fine for a toy. For a feature people depend on, let something else own the reconnection logic, and spend your attention on what the messages mean.
How I'd Approach It
Strip it to decisions:
- Don't reach for WebSockets reflexively. Polling and SSE cover more cases than people admit. Use the bidirectional socket only for genuinely bidirectional, continuous features.
- The connection is a courier, not a store. One source of truth stays your TanStack Query cache; the socket writes to it.
- Invalidate for "something changed,"
setQueryDatafor "here's exactly what changed." Start with invalidate; reach for direct writes when the frequency demands it. - The lifecycle is the feature. Reconnect with backoff, resync on reconnect, show connection status. A silently dead socket is worse than an error.
- Let a library own the connection. Hand-rolled raw sockets are for learning, not for things people rely on.
The thing that used to make real-time feel hard, for me, was imagining it as a whole separate system bolted onto the app — its own state, its own rules, its own bugs. It isn't. It's one new way for data to arrive into the store you already have. Once the socket stops being a source of truth and becomes a courier delivering to your cache, the "two states that disagree" class of bug disappears, and what's left is just careful handling of a connection that can drop. That part takes discipline, but it's a known problem with known answers.
Next up, a different kind of "the same app, but harder": internationalization in React — which turns out to be far less about translating strings and far more about formatting, loading, and everything that quietly breaks when your app leaves one language.
If you've got a real-time feature where the UI occasionally shows stale data or two views disagree, that's almost always a two-sources-of-truth problem or a missed-resync-on-reconnect problem. Tell me the symptom and I'll tell you which.