WebSockets in React: Real-Time Chat, Notifications, Live Data
Ship real-time chat, live notifications, and streaming data in React using raw WebSockets and Socket.io — with code you can actually use today.
Why WebSockets and Not Just Polling
HTTP polling is the duct tape of real-time. You hit the server every 2 seconds, get a mostly-empty response, and call it 'live'. It works until it doesn't — and at any reasonable scale, it doesn't.
WebSockets flip the model entirely. One TCP connection, kept open, messages flowing both ways whenever there's something to say. No headers re-sent on every round trip. No waiting for the next polling interval. Your 2026 chat app doesn't need to feel like a 2009 AJAX widget.
That said, WebSockets aren't magic. They add state to your backend, which complicates horizontal scaling. If you're running three Node instances behind a load balancer, a message arriving at instance A won't reach a client connected to instance C — not without a pub/sub layer like Redis in the middle. Know that going in.
Worth noting: the native WebSocket API is genuinely fine for straightforward cases. Socket.io adds reconnection logic, rooms, namespaces, and a fallback to long-polling for corporate firewalls. Whether you need that overhead depends on your product. This guide covers both.
Raw WebSocket API in React — The Hook You Actually Need
React doesn't ship a WebSocket hook. You write one. Here's the one I keep reusing — it handles open/close/message events, gives you a typed message history, and exposes a sendMessage function that guards against sending on a closed socket.
import { useEffect, useRef, useState, useCallback } from 'react';
type WsMessage = { type: string; payload: unknown };
export function useWebSocket(url: string) {
const ws = useRef<WebSocket | null>(null);
const [messages, setMessages] = useState<WsMessage[]>([]);
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
useEffect(() => {
ws.current = new WebSocket(url);
ws.current.onopen = () => setStatus('open');
ws.current.onclose = () => setStatus('closed');
ws.current.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as WsMessage;
setMessages((prev) => [...prev, data]);
} catch {
// non-JSON frame — ignore or handle as plain text
}
};
return () => ws.current?.close();
}, [url]);
const sendMessage = useCallback((msg: WsMessage) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(msg));
}
}, []);
return { messages, status, sendMessage };
}A few things worth paying attention to here. The url is in the dependency array of useEffect, so if you pass a dynamic URL (say, with a room ID), the socket reconnects automatically. That's usually what you want. The cleanup function calls .close(), which prevents the ghost-socket problem where stale connections pile up during development hot-reloads.
Honestly, the readyState check in sendMessage is the line most tutorials skip. Without it, calling sendMessage before the handshake completes silently drops your message. Check WebSocket.OPEN (which is the number 1) before you send anything.
Building a Real-Time Chat Component
Now that you have the hook, a chat component is about 40 lines. The tricky part isn't the WebSocket — it's keeping the scroll pinned to the bottom as messages arrive, and not re-rendering the entire list on every keystroke.
import { useRef, useEffect, useState } from 'react';
import { useWebSocket } from './useWebSocket';
type ChatMessage = { user: string; text: string; ts: number };
export function ChatRoom({ roomId, username }: { roomId: string; username: string }) {
const { messages, status, sendMessage } = useWebSocket(
`wss://your-api.com/chat?room=${roomId}`
);
const [draft, setDraft] = useState('');
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to newest message
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = () => {
if (!draft.trim()) return;
sendMessage({ type: 'chat', payload: { user: username, text: draft, ts: Date.now() } });
setDraft('');
};
const chatMessages = messages
.filter((m) => m.type === 'chat')
.map((m) => m.payload as ChatMessage);
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{chatMessages.map((msg) => (
<div key={msg.ts} className="flex gap-2">
<span className="font-semibold">{msg.user}</span>
<span>{msg.text}</span>
</div>
))}
<div ref={bottomRef} />
</div>
<div className="flex gap-2 p-4 border-t">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={status === 'open' ? 'Type a message...' : 'Reconnecting...'}
className="flex-1 rounded px-3 py-2 border"
/>
<button onClick={handleSend} disabled={status !== 'open'}>
Send
</button>
</div>
</div>
);
}The status check on the placeholder text is a small UX detail that saves a lot of confusion. When the socket drops and you're reconnecting, users see it immediately instead of wondering why their messages vanish.
If you want this component to look sharp rather than styled like a 1998 form, browse components for pre-built chat UI shells. Pair it with something from the glassmorphism components section if you're going for a modern dark-mode aesthetic — the frosted panel look works really well for chat bubbles.
Socket.io for Rooms, Namespaces, and Reconnection
The native WebSocket API won't reconnect on its own. Your connection drops — mobile switches from wifi to 4G, server restarts, whatever — and you're just... disconnected. Socket.io handles reconnection with exponential backoff out of the box. For anything user-facing, that alone is worth the 13kB gzipped.
Here's a Socket.io setup with a custom hook, typed with the Socket.io v4 generics (introduced in Socket.io 4.0.0, 2021):
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
interface ServerToClientEvents {
message: (data: { user: string; text: string }) => void;
notification: (data: { title: string; body: string }) => void;
'user-count': (count: number) => void;
}
interface ClientToServerEvents {
'join-room': (room: string) => void;
'send-message': (data: { user: string; text: string }) => void;
}
export function useSocketIo(serverUrl: string, room: string) {
const socketRef = useRef<Socket<ServerToClientEvents, ClientToServerEvents> | null>(null);
const [connected, setConnected] = useState(false);
const [messages, setMessages] = useState<{ user: string; text: string }[]>([]);
useEffect(() => {
socketRef.current = io(serverUrl, {
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
const socket = socketRef.current;
socket.on('connect', () => {
setConnected(true);
socket.emit('join-room', room);
});
socket.on('disconnect', () => setConnected(false));
socket.on('message', (data) => {
setMessages((prev) => [...prev, data]);
});
return () => { socket.disconnect(); };
}, [serverUrl, room]);
const send = (user: string, text: string) => {
socketRef.current?.emit('send-message', { user, text });
};
return { connected, messages, send };
}The typed generics on Socket<ServerToClientEvents, ClientToServerEvents> are something most tutorials skip. They're worth the extra lines — you get autocomplete on event names and payload shapes, and TypeScript will yell at you the moment you rename an event on one side and forget the other.
Quick aside: Socket.io rooms are server-side. Calling socket.emit('join-room', room) on the client only works if your server has a listener that calls socket.join(room). The server wires up the room membership, not the client.
Live Notifications — Beyond Chat
Chat is the obvious use case, but WebSockets are just as useful for any push event. Order status updates. Live auction bids. Sports scores. Server health alerts. Anywhere you'd otherwise make the user reload the page.
A notification hook is simpler than a chat hook because you're mostly listening, not bidding:
import { useEffect, useState } from 'react';
type Notification = { id: string; title: string; body: string; read: boolean };
export function useNotifications(userId: string) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const ws = new WebSocket(`wss://your-api.com/notifications?userId=${userId}`);
ws.onmessage = (e) => {
const notification = JSON.parse(e.data) as Notification;
setNotifications((prev) => [notification, ...prev]);
setUnreadCount((prev) => prev + 1);
};
return () => ws.close();
}, [userId]);
const markRead = (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
setUnreadCount((prev) => Math.max(0, prev - 1));
};
return { notifications, unreadCount, markRead };
}In practice, you'd also want to load existing unread notifications from a REST endpoint when the component mounts, then use the WebSocket only for new ones arriving after page load. Don't make the WebSocket carry historical data — that's what your database is for.
Look, the notification bell with an unread count is one of those UI details that makes an app feel polished. If you're building this, pair it with a toast system — see the react-toast-notifications article for the full pattern. And for the visual layer, the box shadow generator is handy when you want to give the notification dropdown that layered depth without writing shadows by hand.
Streaming Live Data — Charts and Dashboards
Real-time charts are where WebSocket performance actually matters. You might be receiving 10-20 data points per second, and naively calling setState on every message will thrash your render tree. You need to throttle or batch updates.
import { useEffect, useRef, useState } from 'react';
type DataPoint = { ts: number; value: number };
export function useLiveMetric(url: string, maxPoints = 60) {
const [data, setData] = useState<DataPoint[]>([]);
const buffer = useRef<DataPoint[]>([]);
const flushTimer = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (e) => {
const point = JSON.parse(e.data) as DataPoint;
buffer.current.push(point);
};
// Flush buffer to state at 250ms intervals — ~4 renders/sec
flushTimer.current = setInterval(() => {
if (buffer.current.length === 0) return;
setData((prev) => {
const combined = [...prev, ...buffer.current];
buffer.current = [];
return combined.slice(-maxPoints);
});
}, 250);
return () => {
ws.close();
if (flushTimer.current) clearInterval(flushTimer.current);
};
}, [url, maxPoints]);
return data;
}The 250ms flush interval gives you a chart that feels live while capping renders at 4 per second. You can tune that number. 100ms if you need snappier feedback, 500ms if your chart library is heavy. The maxPoints cap prevents unbounded memory growth on long-running dashboards — 60 points is fine for a rolling 60-second window.
Why store points in a ref buffer instead of calling setState directly in onmessage? Because React 18's batching doesn't help here — onmessage fires outside React's scheduler, so every call queues a separate render. The buffer + interval pattern is the right tool for high-frequency data.
For the visual side of live dashboards, the aurora background style works surprisingly well — that slow animated gradient gives dashboards a 'living' feel without distracting from the data. Worth pairing with a dark color scheme and subtle glow effects on the chart lines.
Authentication, Reconnection, and Scaling Gotchas
The WebSocket handshake is an HTTP Upgrade request, which means you can pass auth tokens as query params (wss://api.example.com/ws?token=...) or as cookies if you're on the same domain. Query params are fine for short-lived JWTs. Don't put long-lived secrets in the URL — they'll end up in server logs.
Reconnection logic is where a lot of DIY hooks break down. If you're rolling your own, implement exponential backoff — start at 1 second, double on each failure, cap at 30 seconds. Don't hammer your server with reconnects if it's down.
const reconnect = useCallback(() => {
let delay = 1000;
const maxDelay = 30_000;
const attempt = () => {
const ws = new WebSocket(url);
ws.onopen = () => { /* reset delay, set state */ delay = 1000; };
ws.onclose = () => {
setTimeout(attempt, delay);
delay = Math.min(delay * 2, maxDelay);
};
};
attempt();
}, [url]);Scaling is the real gotcha. WebSocket connections are stateful — they pin to a specific server process. If you're running multiple instances (which you should be in production), you need sticky sessions at the load balancer, or you need a pub/sub broker so any instance can relay messages to any client. Redis with Socket.io's @socket.io/redis-adapter is the standard answer here. Set that up before you launch, not after you have 500 concurrent connections and messages are mysteriously disappearing.
One more thing — test your WebSocket behavior in Chrome DevTools Network tab with the WS filter. You can see every frame sent and received, which is invaluable for debugging. If you're seeing your messages go out but no response comes back, that's a server-side issue. If the connection never opens, check CORS headers and the Upgrade header is being passed through your reverse proxy. Nginx needs proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; — don't forget those lines.
FAQ
Raw WebSockets if your use case is simple and you control the server. Socket.io if you need rooms, namespaces, guaranteed reconnection, or a fallback for environments that block WebSocket upgrades. The 13kB bundle cost is worth it for most production apps.
Pass a short-lived JWT as a query param on the WebSocket URL during connection — wss://api.example.com/ws?token=.... Validate it server-side on the initial handshake. Don't rely on cookies unless your client and server share the same origin.
Most likely a proxy or load balancer is closing idle connections with a timeout (60-90 seconds is common). Send a ping/pong heartbeat every 30 seconds from the client to keep the connection alive. Both the native API and Socket.io support this.
You can't run a WebSocket server inside a Next.js route handler — those are stateless HTTP. Run your WebSocket server as a separate Node process or use a service like Ably, Pusher, or Supabase Realtime, then connect to it from your React components as normal.