Frontend Development

Mastering Real-Time Data Sync: Integrating WebSockets with React Query

In modern frontend development, the ability to display live, up-to-the-second data is no longer a luxury but a standard expectation. From financial dashboards to collaborative editing tools, users demand instantaneous feedback. While polling is an outdated and inefficient approach, WebSockets provide a persistent, bidirectional communication channel that solves this problem elegantly. However, managing WebSocket state within a React application can become complex quickly. This is where React Query (now known as TanStack Query) shines, offering a robust solution for fetching and caching server state. In this post, we will explore how to bridge these two technologies to create a seamless real-time experience.

The Challenge of State Synchronization

React Query excels at handling request-based data fetching (HTTP/REST). It provides hooks like useQuery for caching and useMutation for side effects. However, WebSockets operate on an event-driven model, not a request-response cycle. This architectural mismatch often leads developers to bypass React Query entirely, managing WebSocket subscriptions manually in useEffect hooks. This approach quickly becomes unmanageable as the application scales, leading to race conditions, memory leaks, and inconsistent UI states.

The goal is to leverage React Query’s powerful caching and invalidation mechanisms while using WebSockets as the transport layer for updates. We need a strategy where the WebSocket acts as a notifier, triggering React Query to refetch data or update its cache directly.

Setting Up the WebSocket Client

First, let’s establish a simple WebSocket client. We will create a custom hook that manages the connection lifecycle. This hook should handle opening the connection, sending messages, and listening for incoming events.

import { useEffect, useRef } from 'react';

export const useWebSocket = (url, onMessage) => {
  const ws = useRef(null);

  useEffect(() => {
    ws.current = new WebSocket(url);

    ws.current.onopen = () => console.log('Connected');
    
    ws.current.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (onMessage) onMessage(data);
    };

    ws.current.onclose = () => console.log('Disconnected');

    return () => ws.current.close();
  }, [url, onMessage]);

  const sendMessage = (message) => {
    if (ws.current?.readyState === WebSocket.OPEN) {
      ws.current.send(JSON.stringify(message));
    }
  };

  return sendMessage;
};

Bridging WebSockets with React Query

The most effective pattern is to use the WebSocket to trigger cache invalidations. When a WebSocket message indicates that data has changed, we can call queryClient.invalidateQueries(). This tells React Query that the cached data is stale and needs to be refetched from the server via the standard HTTP endpoint.

Here is how you can integrate this into a component fetching real-time chat messages:

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

const fetchMessages = async () => {
  const response = await fetch('/api/messages');
  return response.json();
};

const RealTimeChat = () => {
  const queryClient = useQueryClient();

  const { data, isLoading } = useQuery({
    queryKey: ['messages'],
    queryFn: fetchMessages,
  });

  // Use WebSocket to listen for new messages
  const sendMessage = useWebSocket('wss://api.example.com/chat', (msg) => {
    if (msg.type === 'NEW_MESSAGE') {
      // Invalidate the query to refetch latest data
      queryClient.invalidateQueries({ queryKey: ['messages'] });
      
      // Alternatively, for better UX, you could optimistically update the cache:
      // queryClient.setQueryData(['messages'], old => [...old, msg.payload]);
    }
  });

  if (isLoading) return <p>Loading...</p>;

  return (
    <div>
      {data.map(msg => <p key={msg.id}>{msg.text}</p>)}
      <button onClick={() => sendMessage({ type: 'SEND', text: 'Hello' })}>
        Send
      </button>
    </div>
  );
};

Optimistic Updates for Better UX

While invalidation works, it introduces a brief flicker as the data refetches. For a smoother experience, consider using queryClient.setQueryData() for optimistic updates. When a message is received, you can manually update the cache with the new item. This provides immediate feedback to the user without waiting for a network request. Remember to always keep the server as the source of truth by occasionally refetching to resolve any potential conflicts.

Conclusion

Integrating WebSockets with React Query does not have to be painful. By treating WebSockets as a notification layer rather than a data-fetching layer, you can maintain the simplicity and power of React Query’s cache management. This hybrid approach ensures your applications are both performant and responsive, delivering the real-time capabilities users expect while keeping your codebase maintainable. As you adopt this pattern, always consider network reliability and implement reconnection logic to ensure a robust user experience.

Share: