In a React chat component, a useEffect opens a WebSocket and its dependency array contains an options object built inline in the component body. The socket disconnects and reconnects on every render. How would you restructure the subscription so it reconnects only when the room actually changes?
answer
- what identifies this connection
- a fresh object each render
- move construction inside the effect
- fast-changing callbacks need another home
- one effect, one reason to re-run
basics
~20 sBuild the options object inside the effect and depend only on the primitive values that identify the connection, such as the room id and server URL. Keep frequently changing callbacks out of that effect by reading them from a ref instead.
solid answer
~50 sThe effect is doing the right thing — it just thinks the connection changed when it did not. An object created during render is a different object on every render, so React re-runs the effect, the cleanup closes the socket, and a new one opens: a disconnect/reconnect on every keystroke that touches state. The fix is to make the effect's dependencies describe the *connection identity* and nothing else: pass `roomId` and `serverUrl` as strings and construct the options object inside the effect body, where its lifetime matches the socket's. Then keep everything volatile out of that effect. A message callback that changes each render should not be a dependency; store the latest one in a ref that a separate tiny effect updates, and have the socket handler call `ref.current`. The connection effect then owns exactly one thing — open, listen, close — and re-runs only when the room genuinely differs.
code
javascript · 20 linesimport { useEffect, useRef } from 'react';
export function useChatRoom(serverUrl, roomId, onMessage) {
const onMessageRef = useRef(onMessage);
useEffect(() => {
onMessageRef.current = onMessage;
});
useEffect(() => {
const socket = new WebSocket(`${serverUrl}/rooms/${roomId}`);
const handle = (event) => onMessageRef.current(event.data);
socket.addEventListener('message', handle);
return () => {
socket.removeEventListener('message', handle);
socket.close();
};
}, [serverUrl, roomId]);
}go deeper
Know that an effect re-runs when its dependencies differ and that the cleanup runs first, so a churning dependency means a repeated teardown and setup of whatever the effect owns.
Explain why an inline object is a new dependency each render, and show the restructuring: primitives in the dependency list, construction of the connection and its options inside the effect body.
Talk about the production damage — reconnect storms, replayed handshakes, dropped messages — and about isolating fast-changing callbacks behind a ref so a handler change can never take the connection down.
Set the hook API so this bug is unrepresentable: subscription hooks accept the primitive identity of a connection rather than an options bag, so no consumer can trigger churn by passing a literal, and review the boundary rather than the call sites.
## What the symptom actually tells you A socket that closes and reopens on every render is not a WebSocket problem. It is a statement about what the effect claims to depend on. React re-runs an effect whenever any dependency differs from last render, and it runs the cleanup first — so the sequence is: close the old socket, open a new one, on every commit. On a chat screen where typing updates state, that means a reconnect per keystroke: dropped messages, a server flooded with connection churn, and an authentication handshake replayed dozens of times a second. The object literal in the component body is the whole cause. Each render evaluates it and produces a distinct object, and the effect faithfully treats a distinct object as a distinct connection. ## Depend on identity, not on shape The design rule for a subscription effect is that its dependency list should name the things that, when different, genuinely require a different subscription. For a chat socket that is the room and the server — both primitives: ```js useEffect(() => { const options = { protocol: 'wss', reconnect: false }; // built here, lives here const socket = new WebSocket(`${serverUrl}/rooms/${roomId}`); // ... return () => socket.close(); }, [roomId, serverUrl]); ``` Moving the options construction inside the effect is not a trick to satisfy the linter; it is the correct home for it. The options describe *this* connection, so their lifetime should be the connection's lifetime. Anything created in the component body outlives nothing and belongs to no particular subscription. The same logic says the socket itself must be created inside the effect. Constructing it in the component body would open one connection per render and orphan all but the last, with no cleanup able to reach them. ## The volatile-callback problem The second half of the churn is subtler. A component usually wants to do something when a message arrives, and that handler often closes over props or state that change constantly. Listing it as a dependency reconnects the socket every time the handler identity changes — the same bug wearing different clothes. The structural answer is to separate what changes fast from what changes slowly. Keep the latest handler in a ref, refresh the ref in its own effect, and have the socket's listener read through it: ```js const onMessageRef = useRef(onMessage); useEffect(() => { onMessageRef.current = onMessage; }); useEffect(() => { const socket = new WebSocket(url); const handle = (event) => onMessageRef.current(event.data); socket.addEventListener('message', handle); return () => { socket.removeEventListener('message', handle); socket.close(); }; }, [url]); ``` Now the connection effect has one job and one reason to re-run. The handler can change every render without touching the socket, because the socket never captured the handler — it captured a stable box that always holds the newest one. ## The anti-fixes to reject - **Deleting the dependency array.** Now the effect runs after *every* render with no dependency check at all, which is the same reconnect loop with the diagnosis removed. - **Suppressing the lint warning and listing nothing.** The socket then keeps serving a room the user has left, because a genuine room change no longer tears it down. You have traded a loud bug for a silent one. - **Wrapping the component in React.memo.** That changes when the *parent* re-renders this component; it does nothing about an object recreated inside it. - **Putting the socket in state.** Setting state during the effect triggers another render, which re-runs the effect, which sets state — a genuine loop. ## Cleanup completeness Whichever restructuring you choose, the cleanup must return the outside world to where it was: remove the listeners you added *and* close the socket. Closing alone usually suffices to stop delivery, but leaving listeners attached to an object you still reference keeps closures alive, and a cleanup that only detaches listeners leaves an open connection burning a server socket. Symmetry is the check: for each acquire in the body, one release in the cleanup. ## How to talk about it A strong answer treats this as effect design rather than as a dependency-array trivia question: name the subscription's identity, put everything that belongs to one subscription inside the effect, and give anything that changes on a different clock its own home. The reconnect loop then cannot recur, because there is nothing volatile left in the connection effect to trigger it.
- Why not create the WebSocket in the component body and keep it in a variable?Because the component body runs on every render, so you would open a connection per render and have no way to close the earlier ones — the cleanup can only reach what the effect that scheduled it created. Constructing it inside the effect ties the socket's lifetime to the subscription's lifetime exactly.
- Is passing the whole options object as a prop ever acceptable here?Only if the caller guarantees a stable reference, which is fragile to rely on. A more robust hook API takes the primitives it actually needs — server URL, room id — and builds any object internally, so no consumer can accidentally cause reconnect churn by inlining a literal.
- What breaks if the cleanup closes the socket but leaves the 'message' listener attached?Delivery stops, so the visible behaviour looks fine, but the listener keeps the socket object and its closure reachable. Over many mounts that retains dead components. Symmetric cleanup — remove every listener the effect added, then close — costs one line and removes the question entirely.
saying these in an interview costs you the question
- Says the object looks the same so the effect should not re-run
- Removes the dependency array to stop the reconnect loop
- Wraps the component in React.memo to stop the reconnecting
- Keeps the socket in state so each message recreates it
- Deletes the cleanup so the old socket is never closed