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.
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.
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.
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.
Related #
- Sharing One WebSocket Across React Components — the module-singleton alternative.
- React WebSocket State with useSyncExternalStore — selector-level subscriptions.
- useWebSocket Cleanup and Teardown Patterns — StrictMode-safe lifetimes.
- Preventing Memory Leaks in React useEffect WebSockets — unsubscribe discipline.
Back to React WebSocket Custom Hooks.