WebSocket context provider in React #

You wrap your app in a <SocketProvider> so every component can reach the connection. It works, and then performance falls apart: every incoming message re-renders the entire tree below the provider, including the sidebar, the settings page and a chart that does not use the socket at all. The React Profiler shows the provider’s context value changing on each message, and every useContext consumer re-renders because of it. Context is the right tool for handing a connection to a subtree; it is the wrong tool for broadcasting its data. The fix is to put a stable object in context — the connection and a subscription API — and let components subscribe to exactly the messages they need.

Root cause #

React re-renders every component that calls useContext(Ctx) whenever the provider’s value changes identity. The typical provider stores the latest message in state and passes down a value object built from socket, lastMessage and status on every render. Every message produces a new lastMessage, a new value object, and a re-render of every consumer, whether or not it cares about that message. Wrapping consumers in React.memo does not help, because context changes bypass memoization.

A second problem hides in the same pattern: creating the socket inside the provider’s render or effect ties the connection’s lifetime to the provider’s mount. Under StrictMode’s development double-mount, or when a layout change remounts the provider, the socket is torn down and reopened. That is sometimes desirable — a provider per workspace that should reconnect when the workspace changes — but it should be a decision, not an accident.

Unstable vs stable context value With an unstable context value, a message re-renders both the chart that uses it and the sidebar that does not; with a stable value, the message reaches only the chart's subscriber callback. Unstable vs stable context value Socket Provider Chart (uses it) Sidebar (doesn't) message value = new object re-render re-render (wasted) message (stable value) subscriber callback only Context should carry the connection, not the data flowing through it
Put the pipe in context and let components choose what flows out of it.

Resolution #

The provider below creates a connection object once per url, memoizes a context value that never changes while the connection exists, and exposes subscribe(type, handler) and send. Components use a useSocketEvent hook that subscribes in an effect and keeps its own local state, so only components receiving a given message type re-render when it arrives. Connection status is exposed through a separate hook built on useSyncExternalStore, so status changes do not re-render message consumers either.

import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';

type Handler = (data: unknown) => void;
type Status = 'connecting' | 'open' | 'closed';

class Connection {
private ws: WebSocket;
private handlers = new Map<string, Set<Handler>>();
private statusListeners = new Set<() => void>();
status: Status = 'connecting';

constructor(url: string) {
this.ws = new WebSocket(url);
this.ws.onopen = () => this.setStatus('open');
this.ws.onclose = () => this.setStatus('closed');
this.ws.onmessage = (e) => {
const { type, data } = JSON.parse(e.data);
this.handlers.get(type)?.forEach((h) => h(data)); // only this type's subscribers
};
}

subscribe(type: string, h: Handler) {
let set = this.handlers.get(type);
if (!set) this.handlers.set(type, (set = new Set()));
set.add(h);
return () => { set!.delete(h); };
}
send(type: string, data: unknown) { this.ws.send(JSON.stringify({ type, data })); }
close() { this.ws.close(1000, 'provider unmounted'); }

onStatus = (l: () => void) => { this.statusListeners.add(l); return () => { this.statusListeners.delete(l); }; };
getStatus = () => this.status;
private setStatus(s: Status) { this.status = s; this.statusListeners.forEach((l) => l()); }
}

const SocketContext = createContext<Connection | null>(null);

export function SocketProvider({ url, children }: { url: string; children: ReactNode }) {
const [conn, setConn] = useState<Connection | null>(null);
useEffect(() => {
const c = new Connection(url); // one connection per url, per provider
setConn(c);
return () => c.close(); // StrictMode: first instance closes cleanly
}, [url]);
// The value is the connection object itself: its identity changes only when url does.
return <SocketContext.Provider value={conn}>{children}</SocketContext.Provider>;
}

function useConnection() {
return useContext(SocketContext);
}

// Subscribe to one message type; re-render only this component, only for this type.
export function useSocketEvent<T>(type: string, initial: T): T {
const conn = useConnection();
const [value, setValue] = useState<T>(initial);
useEffect(() => conn?.subscribe(type, (d) => setValue(d as T)), [conn, type]);
return value;
}

export function useSocketStatus(): Status {
const conn = useConnection();
return useSyncExternalStore(
conn ? conn.onStatus : () => () => {},
conn ? conn.getStatus : () => 'connecting',
() => 'connecting',
);
}

export function useSocketSend() {
const conn = useConnection();
const ref = useRef(conn);
ref.current = conn;
// A stable function, safe in dependency arrays and memoized children.
return useMemo(() => (type: string, data: unknown) => ref.current?.send(type, data), []);
}

The value passed to the provider is the connection object, which is created once per url. Context consumers therefore re-render only when the connection is replaced — on a URL change or remount — never on messages. Message delivery happens through subscriptions, which call setValue in exactly the components that asked for that type.

When should the socket live in a provider at all, rather than in a module-level singleton like the one in sharing one WebSocket across React components? When the connection is scoped to part of the UI: one socket per open workspace, per document tab, or per tenant in an admin console. A provider per scope gives each subtree its own connection with a lifetime tied to that scope, and tests can mount a provider with a fake URL without touching globals.

What goes where Layered guidance: the context carries the stable connection object, components use per-type subscriptions and a separate status hook, shared derived data goes in a store, and per-message data never goes in context. What goes where Context value the Connection object — changes only when the scope changes stable Per-type subscriptions useSocketEvent: local state in each consuming component fine-grained Connection status useSyncExternalStore on the connection's status separate Shared derived data a store (Zustand, useSyncExternalStore) fed by subscriptions optional Not in context lastMessage, message arrays, anything that changes per message never Anything that changes per message in the context value re-renders the whole subtree
Context for identity, subscriptions for data.

Edge cases #

StrictMode double-mount. In development, the effect runs, cleans up and runs again, so the first Connection is created and closed immediately. The cleanup above closes it properly; servers will see a brief extra connection in development only. If that is noisy, delay opening the socket by a microtask and cancel if the effect is cleaned up first.

Subscribing before the connection exists. On the first render conn is null, so useSocketEvent subscribes on the next render when the provider sets it. Messages that arrive in between are delivered to no one. For data that must not be missed, have the server send a snapshot on subscribe rather than relying on timing.

Many providers. Nesting providers for different scopes works, but each one is a connection. If users open ten workspaces, prefer a single connection multiplexing streams, with a provider that exposes a scoped view of it.

Verification #

With the React DevTools Profiler recording, let a few messages arrive. Components that do not subscribe to those types — the sidebar, static pages — must not appear in the flame graph at all. Then change the provider’s url prop and confirm in the Network panel’s WS view that exactly one socket closes and one opens.

Write a test that mounts two consumers of different types under one provider, emits a message of one type from a mock server, and asserts that only the matching consumer’s render count increased; see mocking WebSockets in Vitest for the mock server.

Components re-rendered per message When the context value carries the last message every consumer re-renders on each message; with a stable value and per-type subscriptions only the two interested components re-render. Components re-rendered per message two components care about each message type value holds lastMessage stable value + subscriptions 0 100 200 300 20 consumers 80 80 consumers 300 consumers
The work per message stops scaling with the size of the tree.

Operational checklist #

FAQ #

Why does my whole app re-render on every WebSocket message? #

Because the provider’s value includes something that changes per message, typically the latest message or a messages array. Every useContext consumer re-renders when the value’s identity changes. Put only the stable connection in context and deliver messages through subscriptions.

Is a context provider better than a global singleton? #

Neither is universally better. A singleton suits one app-wide connection. A provider suits connections scoped to part of the UI and makes testing easier because each test can provide its own connection.

Can I use useMemo to stabilize the context value? #

Yes, if the memoized value excludes per-message data. useMemo(() => ({ subscribe, send }), [conn]) is stable; useMemo(() => ({ conn, lastMessage }), [conn, lastMessage]) still changes with every message.

How do I share derived state between several consumers? #

Feed subscriptions into a small external store and read it with selectors, as described in React WebSocket state with useSyncExternalStore.

Back to React WebSocket Custom Hooks.