React WebSocket Integration: Real-Time Updates (2026)

WebSockets give React apps bidirectional, low-latency communication — live dashboards, collaborative editing, chat, notifications. This guide covers a robust useWebSocket hook with auto-reconnect, TanStack Query cache integration for hybrid REST+WS apps, Server-Sent Events for one-way streams, and production patterns for connection management.

useWebSocket Hook

// src/hooks/useWebSocket.ts
import { useState, useEffect, useRef, useCallback } from 'react'

type WSStatus = 'connecting' | 'open' | 'closing' | 'closed'

interface UseWebSocketOptions {
  onMessage?: (event: MessageEvent) => void
  onOpen?: (event: Event) => void
  onClose?: (event: CloseEvent) => void
  onError?: (event: Event) => void
  protocols?: string | string[]
  shouldReconnect?: (event: CloseEvent) => boolean
  reconnectInterval?: number
  reconnectAttempts?: number
}

export function useWebSocket(url: string, options: UseWebSocketOptions = {}) {
  const {
    onMessage, onOpen, onClose, onError,
    protocols,
    shouldReconnect = () => true,
    reconnectInterval = 2000,
    reconnectAttempts = 10,
  } = options

  const [status, setStatus] = useState<WSStatus>('closed')
  const [lastMessage, setLastMessage] = useState<MessageEvent | null>(null)
  const wsRef = useRef<WebSocket | null>(null)
  const attemptsRef = useRef(0)
  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>()

  const connect = useCallback(() => {
    const ws = new WebSocket(url, protocols)
    wsRef.current = ws
    setStatus('connecting')

    ws.onopen = (e) => {
      setStatus('open')
      attemptsRef.current = 0
      onOpen?.(e)
    }

    ws.onmessage = (e) => {
      setLastMessage(e)
      onMessage?.(e)
    }

    ws.onclose = (e) => {
      setStatus('closed')
      onClose?.(e)
      if (shouldReconnect(e) && attemptsRef.current < reconnectAttempts) {
        const delay = reconnectInterval * Math.pow(1.5, attemptsRef.current)  // Exponential backoff
        attemptsRef.current++
        reconnectTimer.current = setTimeout(connect, delay)
      }
    }

    ws.onerror = (e) => {
      onError?.(e)
    }
  }, [url])

  useEffect(() => {
    connect()
    return () => {
      clearTimeout(reconnectTimer.current)
      wsRef.current?.close(1000, 'Component unmounted')
    }
  }, [url])

  const send = useCallback((data: string | ArrayBuffer | Blob) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(data)
    }
  }, [])

  const sendJSON = useCallback((data: unknown) => {
    send(JSON.stringify(data))
  }, [send])

  return { status, lastMessage, send, sendJSON, ws: wsRef.current }
}

Reconnection Logic

// Usage with typed messages
interface WSMessage {
  type: 'price_update' | 'order_filled' | 'notification'
  payload: unknown
}

function StockTicker({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null)

  const { status, sendJSON } = useWebSocket(
    `wss://api.example.com/ws?token=${getAuthToken()}`,
    {
      onOpen: () => {
        // Subscribe to symbol on connect (and reconnect)
        sendJSON({ type: 'subscribe', symbol })
      },
      onMessage: (event) => {
        const msg: WSMessage = JSON.parse(event.data)
        if (msg.type === 'price_update') {
          setPrice((msg.payload as { price: number }).price)
        }
      },
      shouldReconnect: (e) => e.code !== 1000,  // Don't reconnect on clean close
      reconnectAttempts: 15,
      reconnectInterval: 1000,
    }
  )

  return (
    <div>
      <span className={status === 'open' ? 'text-green' : 'text-amber'}>
        ● {status}
      </span>
      <strong>{symbol}: ${price ?? '...'}</strong>
    </div>
  )
}

WebSocket Context Provider

// Share one WebSocket connection across many components
const WSContext = createContext<{
  sendJSON: (data: unknown) => void
  status: WSStatus
  subscribe: (type: string, handler: (payload: unknown) => void) => () => void
} | null>(null)

export function WSProvider({ children }: { children: React.ReactNode }) {
  const handlersRef = useRef<Map<string, Set<(p: unknown) => void>>>(new Map())

  const { status, sendJSON } = useWebSocket('wss://api.example.com/ws', {
    onMessage: (event) => {
      const { type, payload } = JSON.parse(event.data)
      handlersRef.current.get(type)?.forEach(h => h(payload))
    },
  })

  const subscribe = useCallback((type: string, handler: (p: unknown) => void) => {
    if (!handlersRef.current.has(type)) {
      handlersRef.current.set(type, new Set())
    }
    handlersRef.current.get(type)!.add(handler)
    return () => handlersRef.current.get(type)?.delete(handler)
  }, [])

  return (
    <WSContext.Provider value={{ sendJSON, status, subscribe }}>
      {children}
    </WSContext.Provider>
  )
}

// Subscribe in any component without creating a new WebSocket
function NotificationBell() {
  const { subscribe } = useContext(WSContext)!
  const [count, setCount] = useState(0)

  useEffect(() => {
    return subscribe('notification', (payload: any) => {
      setCount(c => c + 1)
    })
  }, [subscribe])

  return <button>🔔 {count}</button>
}

TanStack Query Integration

// Use REST for initial load, WebSocket for live updates
function OrderBook({ market }: { market: string }) {
  const queryClient = useQueryClient()

  // Initial data from REST
  const { data: orders } = useQuery({
    queryKey: ['orders', market],
    queryFn: () => fetch(`/api/orders/${market}`).then(r => r.json()),
  })

  // Live updates via WebSocket — mutate the query cache directly
  useWebSocket(`wss://api.example.com/market/${market}`, {
    onMessage: (event) => {
      const update = JSON.parse(event.data)

      if (update.type === 'order_added') {
        queryClient.setQueryData(['orders', market], (old: Order[]) =>
          [update.order, ...(old ?? [])].slice(0, 100)
        )
      }

      if (update.type === 'order_removed') {
        queryClient.setQueryData(['orders', market], (old: Order[]) =>
          (old ?? []).filter(o => o.id !== update.orderId)
        )
      }
    },
  })

  return <OrderList orders={orders} />
}

Server-Sent Events

// SSE — one-way server→client stream, simpler than WS for notifications/feeds
function useSSE<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const es = new EventSource(url, { withCredentials: true })

    es.onmessage = (e) => setData(JSON.parse(e.data))

    // Named events
    es.addEventListener('notification', (e) => {
      setData(JSON.parse(e.data))
    })

    es.onerror = () => {
      setError('Connection lost')
      // EventSource auto-reconnects after 3s by default
    }

    return () => es.close()
  }, [url])

  return { data, error }
}

// Usage — live AI chat streaming (common pattern)
function ChatStream({ prompt }: { prompt: string }) {
  const [output, setOutput] = useState('')

  useEffect(() => {
    const es = new EventSource(`/api/chat/stream?prompt=${encodeURIComponent(prompt)}`)
    es.addEventListener('token', (e) => setOutput(prev => prev + e.data))
    es.addEventListener('done', () => es.close())
    return () => es.close()
  }, [prompt])

  return <pre>{output}</pre>
}

Collaborative Patterns

// Presence — who is online and what they're looking at
function usePresence(roomId: string) {
  const [users, setUsers] = useState<PresenceUser[]>([])
  const { sendJSON } = useWebSocket(`wss://api.example.com/rooms/${roomId}`, {
    onMessage: (e) => {
      const msg = JSON.parse(e.data)
      if (msg.type === 'presence') setUsers(msg.users)
    },
    onOpen: () => sendJSON({ type: 'join', userId: getCurrentUserId() }),
  })

  useEffect(() => {
    return () => sendJSON({ type: 'leave', userId: getCurrentUserId() })
  }, [])

  return users
}

// Optimistic updates with WS confirmation
function useOptimisticWS<T extends { id: string }>(initialItems: T[]) {
  const [items, setItems] = useState(initialItems)
  const { sendJSON } = useContext(WSContext)!

  function addItem(item: T) {
    setItems(prev => [...prev, item])  // Optimistic
    sendJSON({ type: 'add_item', item })
  }

  // WS sends back confirmed item — merge or revert
  function onServerConfirm(serverItem: T) {
    setItems(prev => prev.map(i => i.id === serverItem.id ? serverItem : i))
  }

  return { items, addItem, onServerConfirm }
}

Production Tips

  • Heartbeat / ping-pong: Send a ping every 25s and close if no pong within 5s — prevents silent dead connections from load balancers that kill idle sockets.
  • Token refresh: Pass short-lived JWTs in the WS URL or first message. Handle 4001 Unauthorized close codes by refreshing the token and reconnecting.
  • Message queue: Buffer messages sent while disconnected and flush them on reconnect to avoid lost mutations.
  • Tab visibility: Pause expensive subscriptions when document.visibilityState === 'hidden' using the Page Visibility API.
  • One socket per app: Use a shared context or a library like Socket.io — not one WebSocket per component — to avoid connection limits.
  • Sticky sessions: If you have multiple backend instances, configure your load balancer (NGINX, ALB) for sticky sessions or use a pub/sub broker (Redis, NATS) to broadcast to all nodes.