Skip to main content

Syncing Live Data Into React Query Cache

React Query manages server state; WebSockets push real-time updates. To keep them in sync, listen for WebSocket messages and update the query cache using queryClient.setQueryData() or queryClient.invalidateQueries(). This ensures your UI always reflects the latest data without re-fetching from the server, saving bandwidth and keeping the UX responsive (Crater Labs, 2026).

The Problem: Real-Time Data and Stale Caches

Imagine a stock ticker app using React Query:

const { data: prices } = useQuery({
queryKey: ['prices'],
queryFn: async () => {
const res = await fetch('/api/prices');
return res.json();
},
});

The query runs once, caches the response, and serves cached data for 5 minutes (default). If a price changes on the server, the user sees stale data until the cache expires. WebSocket solves this: when the server sends a price update, instantly update React Query's cache.

Approach 1: setQueryData for Fine-Grained Updates

queryClient.setQueryData(key, updater) updates a specific cached query without fetching:

import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useWebSocket } from './useWebSocket'; // From article 4

export function LiveStockTicker() {
const queryClient = useQueryClient();

const { data: prices } = useQuery({
queryKey: ['prices'],
queryFn: async () => {
const res = await fetch('/api/prices');
return res.json();
},
});

// Listen for WebSocket price updates
useWebSocket('wss://your-server.example.com/prices', {
onMessage: (message) => {
if (message.type === 'price-update') {
// Update the cached data in-place
queryClient.setQueryData(['prices'], (oldPrices) => {
return {
...oldPrices,
[message.ticker]: message.price,
};
});
}
},
});

return (
`<div>`
{prices && Object.entries(prices).map(([ticker, price]) => (
`<div key={ticker}>`
{ticker}: ${price}
`</div>`
))}
`</div>`
);
}

Advantages: Instant updates, surgical precision, no re-fetch overhead. Disadvantages: You must manually shape the update; it's easy to corrupt the cache if the structure doesn't match.

Approach 2: invalidateQueries for Full Refresh

queryClient.invalidateQueries(key) marks a query as stale; React Query refetches on next access:

import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useWebSocket } from './useWebSocket';

export function LiveChatMessages() {
const queryClient = useQueryClient();

const { data: messages } = useQuery({
queryKey: ['messages', 'room-123'],
queryFn: async () => {
const res = await fetch('/api/messages/room-123');
return res.json();
},
});

useWebSocket('wss://your-server.example.com/chat', {
onMessage: (message) => {
if (message.type === 'new-message') {
// Invalidate the query; React Query will refetch
queryClient.invalidateQueries({
queryKey: ['messages', 'room-123'],
});
}
},
});

return (
`<div>`
{messages?.map((msg) => (
`<div key={msg.id}>`{msg.text}`</div>`
))}
`</div>`
);
}

Advantages: Simple, safe (server is source of truth on refetch). Disadvantages: Causes a network request, visible loading state, latency.

Approach 3: Optimistic Updates with Rollback

When the user sends a message, immediately add it to the cache (optimistic), then rollback if the server rejects:

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useWebSocket } from './useWebSocket';

export function ChatWithOptimisticUpdates() {
const queryClient = useQueryClient();
const [input, setInput] = useState('');
const { connected, send } = useWebSocket('wss://your-server.example.com/chat');

const { data: messages } = useQuery({
queryKey: ['messages', 'room-123'],
queryFn: async () => {
const res = await fetch('/api/messages/room-123');
return res.json();
},
});

const sendMessageMutation = useMutation({
mutationFn: async (text) => {
// Send to server
send({ type: 'message', text });
// Wait for confirmation (simplified; real apps use ACKs)
return new Promise((resolve) => {
setTimeout(() => resolve({ id: Date.now(), text, username: 'You' }), 100);
});
},
onMutate: (text) => {
// Optimistic update: add to cache immediately
const newMessage = {
id: Date.now(),
text,
username: 'You',
pending: true,
};

queryClient.setQueryData(['messages', 'room-123'], (oldMessages) => [
...(oldMessages || []),
newMessage,
]);

return { newMessage }; // Return context for rollback
},
onError: (error, variables, context) => {
// Rollback if mutation fails
queryClient.setQueryData(
['messages', 'room-123'],
(oldMessages) =>
oldMessages?.filter((m) => m.id !== context.newMessage.id) || []
);
},
});

const handleSend = () => {
sendMessageMutation.mutate(input);
setInput('');
};

return (
`<div>`
{messages?.map((msg) => (
`<div key={msg.id} style={{ opacity: msg.pending ? 0.5 : 1 }}>`
{msg.text}
`</div>`
))}
`<input value={input} onChange={(e) => setInput(e.target.value)} />`
`<button onClick={handleSend} disabled={!connected}>`
Send
`</button>`
`</div>`
);
}

This pattern is powerful: the user sees their message instantly, but if the server rejects it (rate limit, validation error), the UI rolls back. React Query calls onSuccess and onError callbacks for this.

Comparison Table: Update Strategies

StrategyLatencyNetworkSafetyUse Case
setQueryData<10msNoneMediumStock prices, live metrics
invalidateQueries200–1000ms1 fetchHighChat, activity feeds
Optimistic Updates<10ms1 mutationHighUser-initiated changes

Advanced: Batch Updates and Debouncing

High-frequency streams (100+ messages per second) can overwhelm React. Batch updates:

import { useRef, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useWebSocket } from './useWebSocket';

export function HighFrequencyUpdates() {
const queryClient = useQueryClient();
const batchRef = useRef([]);
const flushTimerRef = useRef(null);

const flushBatch = () => {
if (batchRef.current.length === 0) return;

// Process all batched updates at once
queryClient.setQueryData(['live-data'], (oldData) => {
let updated = oldData;
for (const update of batchRef.current) {
updated = { ...updated, [update.key]: update.value };
}
return updated;
});

batchRef.current = [];
};

useWebSocket('wss://your-server.example.com/stream', {
onMessage: (message) => {
batchRef.current.push({
key: message.ticker,
value: message.price,
});

// Flush batch every 100ms or when batch size exceeds 50
if (batchRef.current.length >= 50) {
flushBatch();
} else if (!flushTimerRef.current) {
flushTimerRef.current = setTimeout(() => {
flushBatch();
flushTimerRef.current = null;
}, 100);
}
},
});

return null; // Cache is updated; component using useQuery sees changes
}

Key Takeaways

  • Use setQueryData() for surgical, low-latency updates of specific cached data.
  • Use invalidateQueries() when the update is complex or you need server-side validation.
  • Implement optimistic updates for user-initiated actions: update immediately, rollback on error.
  • Batch high-frequency updates to prevent React rendering thrash; flush every 50–100ms.
  • Always keep server state as the source of truth; React Query is the client-side cache.

Frequently Asked Questions

What if the WebSocket message arrives before the initial query finishes?

React Query queues updates. When the initial fetch completes, use onSuccess to merge WebSocket updates. Or wait for the query to be in a settled state before subscribing to WebSocket.

Can I use React Query's subscriptions instead of WebSocket?

React Query 5.x has useSubscription() for exactly this. It's a wrapper around WebSocket/SSE that integrates with the cache automatically. Recommended for new projects.

How do I handle concurrent mutations and WebSocket updates?

Use queryClient.getQueryData() to inspect current state before updating. If there's a conflict (both a mutation and a WebSocket update), let the mutation's onSuccess win since it's most recent.

Should I set cache time to 0 for real-time data?

No, cache time controls how long unused data is kept in memory. Stale time (default 0) controls when React Query refetches. Real-time data usually has staleTime: 0 (always stale, refetch on mount) and cacheTime: 5 * 60 * 1000 (keep for 5 min).

Further Reading