WebSockets and Real-Time UI

Learn how to open and manage a WebSocket connection from inside React, handle its lifecycle correctly, keep the UI in sync with a live stream of messages, and choose between WebSocket, Server-Sent Events, and BroadcastChannel for different real-time needs.

Most data in a React app arrives through a request-response cycle: the component asks for data, and a promise eventually resolves with it. Real-time features — a chat window, a live stock ticker, a collaborative cursor, a notification bell that pops the instant something happens on the server — need the opposite shape: the server pushes data to the client whenever it wants, with no request in between. The WebSocket API is the primary browser tool for this, and wiring it into React correctly means respecting two different lifecycles at once: the connection's own open/message/close states, and the component's mount/update/unmount cycle.

The connection lifecycle

A WebSocket does not simply exist or not exist — it moves through distinct states over its lifetime: CONNECTING while the initial handshake is in flight, OPEN once the server has accepted the upgrade and both sides can send and receive freely, CLOSING once either side has initiated a graceful shutdown, and CLOSED once the underlying TCP connection is gone. A component that displays live data almost always needs to reflect this in the UI — showing a spinner or 'connecting…' label while CONNECTING, live content while OPEN, and a 'disconnected, retrying…' banner once it drops — which means this status belongs in React state, not just as an internal detail of the socket object.

The four events that drive these transitions are onopen, onmessage, onerror, and onclose. onopen fires once, when the handshake completes; onmessage fires once per incoming frame and is where nearly all of the interesting logic lives; onerror fires when something goes wrong at the transport level (it carries almost no useful detail — the meaningful information usually arrives moments later via onclose's code and reason); and onclose fires exactly once, whether the socket closed cleanly, was closed by the server, or died from a network failure. Because onclose is the one event guaranteed to fire in every case, it — not onerror — is the right place to decide whether to reconnect.

Because the whole point of a WebSocket is that it outlives any single render, the connection itself must not live inside a variable that gets recreated every render. It is created once per 'logical connection' inside a useEffect, stored where the effect's callbacks can reach it, and explicitly closed in that same effect's cleanup function. Recreating the socket on every render would open a new TCP connection dozens of times a second; failing to close it in cleanup would leak a connection every time the component unmounts or its dependencies change.

Messages, heartbeats, and reconnection

A WebSocket's payload arrives as a plain string (or, for binary frames, a Blob or ArrayBuffer) — TypeScript has no idea what shape that string decodes to. Treat every incoming message the same way you'd treat the body of a fetch response: parse it with JSON.parse, then narrow or validate it into a known type before touching its fields, rather than casting blindly with as. A malformed or unexpected message from a buggy server or a man-in-the-middle proxy should not be allowed to crash the render.

Many WebSocket servers and the infrastructure between client and server (load balancers, corporate proxies) will silently kill an idle connection after some timeout, and neither side gets a clean close event when that happens — the connection just goes dark. The standard fix is a heartbeat: the client (or server) sends a small ping message on a fixed interval, and if no pong comes back within a grace period, the client treats the connection as dead and forces a reconnect rather than waiting indefinitely for a close event that may never arrive.

Reconnection itself should never be immediate and unconditional — if the server is down or rejecting connections, a client that retries instantly in a loop turns one outage into a self-inflicted denial-of-service. The standard pattern is exponential backoff: wait 1 second before the first retry, 2 before the next, 4, 8, and so on up to some cap (often with a little random jitter added so many clients don't all retry in lockstep), resetting the delay back to its base value the moment a connection succeeds. The retry counter and any pending timeout id belong in a ref, not state, since updating them should never by itself trigger a re-render.

A reusable useWebSocket hook

The active flag prevents a reconnect timer from firing after the effect has already been cleaned up. retryDelay lives in a ref because changing it should not re-render the component.

function useWebSocket<T>(url: string, onMessage: (data: T) => void) {
  const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");
  const retryDelay = useRef(1000);

  useEffect(() => {
    let active = true;
    let ws: WebSocket;
    let retryTimer: ReturnType<typeof setTimeout>;

    function connect() {
      ws = new WebSocket(url);
      ws.onopen = () => {
        setStatus("open");
        retryDelay.current = 1000; // reset backoff on success
      };
      ws.onmessage = (event) => {
        try {
          onMessage(JSON.parse(event.data) as T);
        } catch {
          // ignore a malformed frame rather than crashing the app
        }
      };
      ws.onclose = () => {
        if (!active) return;
        setStatus("closed");
        retryTimer = setTimeout(connect, retryDelay.current);
        retryDelay.current = Math.min(retryDelay.current * 2, 30000);
      };
    }

    connect();
    return () => {
      active = false;
      clearTimeout(retryTimer);
      ws.close();
    };
  }, [url]);

  return status;
}

Code Examples

The hook owns the raw socket, retries, and JSON parsing; the component only ever sees a connection status string and validated, typed messages. Expected result: Renders 'Connecting…' until the socket opens, then a live-updating price line as messages arrive, reconnecting automatically if the link drops.

EventSource is built for the common case of a one-way, server-to-client text stream (like a live activity feed) over plain HTTP, and it reconnects on its own — no manual backoff logic needed, unlike raw WebSocket. SSE is simpler than WebSocket whenever the client never needs to send anything back over the same channel.

type PriceUpdate = { symbol: string; price: number };

function isPriceUpdate(value: unknown): value is PriceUpdate {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as PriceUpdate).symbol === "string" &&
    typeof (value as PriceUpdate).price === "number"
  );
}

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

  const status = useWebSocket<unknown>("wss://example.com/prices", (msg) => {
    if (isPriceUpdate(msg) && msg.symbol === symbol) {
      setPrice(msg.price);
    }
  });

  if (status !== "open") return <p>Connecting…</p>;
  return <p>{symbol}: {price !== null ? `$${price.toFixed(2)}` : "waiting for data…"}</p>;
}
function useServerSentEvents(url: string) {
  const [events, setEvents] = useState<string[]>([]);

  useEffect(() => {
    const source = new EventSource(url);
    source.onmessage = (e) => setEvents((prev) => [...prev.slice(-49), e.data]);
    source.onerror = () => {
      // EventSource reconnects automatically by default; nothing to do here
    };
    return () => source.close();
  }, [url]);

  return events;
}

Real-time data flow

The hook is the single place that owns the raw connection; every component downstream only ever touches typed React state.

Server
  ↓
WebSocket
  ↓
Hook / Service
  ↓
State
  ↓
React UI

Important Rules

Create the WebSocket inside useEffect and always close it in that effect's cleanup function.

Treat incoming messages as unknown/untyped data — parse and validate before using their fields.

Decide whether to reconnect in onclose, since it is the only event guaranteed to fire in every failure case.

Use exponential backoff (with a cap) for reconnection attempts, and reset it on a successful open.

Prefer Server-Sent Events over WebSocket when the client only needs to receive, never send.

Use BroadcastChannel, not a WebSocket, to synchronize state across browser tabs of the same origin.

Where this shows up

Chat applications, live sports scores and price tickers, collaborative editors showing other users' cursors, multiplayer game state, and live operational dashboards all rely on a hook exactly like this one — owning the messy connection lifecycle so the rest of the app only deals in clean, typed, reactive state. BroadcastChannel complements this for cross-tab cases, such as keeping a shopping cart or logged-in state in sync across every open tab of the same site without a server round trip at all.

useEffect(() => { const ws = new WebSocket(url); ws.onmessage = (e) => setData(JSON.parse(e.data)); }, [url]); // no cleanup useEffect(() => { const ws = new WebSocket(url); ws.onmessage = (e) => setData(JSON.parse(e.data)); return () => ws.close(); }, [url]);

Without closing the socket in cleanup, every unmount (or every change to url) leaves the previous connection open, so the number of live sockets grows without bound.

ws.onclose = () => { connect(); // immediate, unconditional retry };

ws.onclose = () => { setTimeout(connect, retryDelay.current); retryDelay.current = Math.min(retryDelay.current * 2, 30000); };

Reconnecting instantly and unconditionally turns a brief server hiccup into a retry storm; backoff spaces attempts out and gives the server room to recover.

[true-false] A WebSocket connection is automatically closed when a component unmounts, with no extra code needed. A. True B. False

B. False — A WebSocket must be explicitly closed with ws.close() inside the effect's cleanup function; React does not close network connections for you.

Why should reconnection logic live in the onclose handler rather than onerror? A. onerror never fires in modern browsers B. onclose is guaranteed to fire exactly once in every disconnection scenario, while onerror carries little detail and doesn't always precede a usable close C. onerror can only be attached after the connection is already open D. onclose fires before onopen, so it must be used for setup logic

B. onclose is guaranteed to fire exactly once in every disconnection scenario, while onerror carries little detail and doesn't always precede a usable close — onclose is the reliable signal that the connection is gone, whether it closed cleanly, was rejected, or failed at the network level, making it the right single place to trigger a reconnect.

When is EventSource (Server-Sent Events) preferable to a raw WebSocket? A. When the client needs to send frequent binary data to the server B. When only server-to-client, one-way text updates are needed, and built-in auto-reconnect is desirable C. When the connection must use a custom binary protocol D. When communication needs to happen between two tabs on the same origin

B. When only server-to-client, one-way text updates are needed, and built-in auto-reconnect is desirable — SSE is simpler to use than WebSocket for one-directional server push over plain HTTP and reconnects automatically, but it cannot send data from client to server over the same channel.

Extend useWebSocket with a heartbeat: every 15 seconds while the connection is open, send { type: 'ping' }. If no message of type 'pong' arrives within 5 seconds of a ping, close the socket manually so the existing onclose/reconnect logic takes over.

useEffect(() => { // ...inside connect(), after ws.onopen sets status to "open": const heartbeat = setInterval(() => { ws.send(JSON.stringify({ type: "ping" })); pongTimeout.current = setTimeout(() => ws.close(), 5000); }, 15000); // in onmessage, when msg.type === "pong": clearTimeout(pongTimeout.current); // in cleanup: clearInterval(heartbeat); clearTimeout(pongTimeout.current); }, [url]);

Track the pending-pong timeout in a ref alongside the heartbeat interval, and clear both in the effect's cleanup along with the existing retry timer.