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.
Table of Contents
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 Unauthorizedclose 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.