Table of contents Show Hide
- The problem: removal requires the same function
- A useful mental model: a permanent proxy
- Manual solution: split the responsibilities
- Why not update the ref during render?
- The common trap: subscribe must be stable too
- React 19.2: useEffectEvent
- Practical examples
- Socket.IO messages
- Native resize events
- Choosing an approach
- Source and further reading
TL;DR: When a React Effect subscribes to WebSocket, EventEmitter, or DOM events, putting a changing handler in its dependencies causes repeated subscribe/unsubscribe cycles. A fixed proxy that calls handlerRef.current separates the subscription lifecycle from access to current state. React 19.2’s useEffectEvent provides an official vocabulary for non-reactive Effect logic, but it is not a general stable-callback API.
This article assumes familiarity with
useEffectdependencies, closures, and function identity.
The problem: removal requires the same function
The following component registers an inline listener but cannot recover the same function during cleanup:
function ChatPanel() {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
socket.on('message:new', (message) => {
setMessages((previous) => [...previous, message]);
});
return () => {
socket.off('message:new' /* needs the exact registered function */);
};
}, []);
}
Both socket.off and removeEventListener require the exact function object used for registration. A new arrow function with identical code is still a different reference.
Extracting the handler fixes cleanup:
function useSocketEvent(event: string, onMessage: (message: Message) => void) {
useEffect(() => {
socket.on(event, onMessage);
return () => socket.off(event, onMessage);
}, [event, onMessage]);
}
If the parent creates a new onMessage on every render, however, the Effect unsubscribes and subscribes again. Under frequent updates that creates avoidable I/O and can leave a small gap in which an event is missed.
A useful mental model: a permanent proxy
The business handler does not need a stable identity. The function handed to the external system does:
WebSocket
↓
stableHandler (same reference for the subscription)
↓
handlerRef.current (the latest business handler)
The WebSocket always talks to one proxy. Whenever an event arrives, that proxy forwards it to the current business logic.
Manual solution: split the responsibilities
import { useEffect, useRef } from 'react';
export function useStableHandler<T>(
subscribe: (handler: (payload: T) => void) => () => void,
handler: (payload: T) => void
): void {
const handlerRef = useRef(handler);
// Synchronize current business logic without external I/O.
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
// Manage only the external subscription lifecycle.
useEffect(() => {
const stableHandler = (payload: T) => {
handlerRef.current(payload);
};
return subscribe(stableHandler);
}, [subscribe]);
}
The two Effects have different jobs:
| Effect | Responsibility | Dependency | Cost |
|---|---|---|---|
| Synchronization Effect | Update handlerRef.current |
handler |
One memory assignment |
| Subscription Effect | Subscribe and unsubscribe | subscribe |
External-system I/O |
The handler may change every render. As long as subscribe is stable, the external subscription remains intact.
Why not update the ref during render?
A common version assigns directly in the component body:
const handlerRef = useRef(handler);
handlerRef.current = handler;
It often appears to work, but React’s documentation says not to read or write ref.current during rendering except for initialization. Rendering is expected to stay pure, and Strict Mode calls component functions an extra time in development to expose accidental impurities.
Moving the assignment into an Effect means only committed renders update the ref. The trade-off is a short interval between commit and passive Effect execution in which an external event could still see the previous committed handler. A case that must synchronize before browser paint can evaluate useLayoutEffect, although most network and DOM subscriptions do not need to become layout Effects for this reason.
The common trap: subscribe must be stable too
This Hook moves the identity requirement to subscribe. An inline subscription function still makes the subscription Effect run after every render:
// ❌ subscribe is a new function on every render
useStableHandler(
(eventHandler) => {
socket.on('message:new', eventHandler);
return () => socket.off('message:new', eventHandler);
},
(message) => setMessages((previous) => [...previous, message])
);
Stabilize it with useCallback:
const subscribe = useCallback(
(eventHandler: (message: Message) => void) => {
socket.on('message:new', eventHandler);
return () => socket.off('message:new', eventHandler);
},
[socket]
);
useStableHandler(subscribe, (message) => {
setMessages((previous) => [...previous, message]);
});
The business handler does not need useCallback. That is the value of the proxy.
React 19.2: useEffectEvent
React 19.2 provides useEffectEvent for Effect logic that should read the latest values without causing the Effect to synchronize again:
import { useEffect, useEffectEvent } from 'react';
export function useStableHandler<T>(
subscribe: (handler: (payload: T) => void) => () => void,
handler: (payload: T) => void
): void {
const onEvent = useEffectEvent(handler);
useEffect(() => {
return subscribe(onEvent);
}, [subscribe]);
}
Three rules are easy to confuse:
- An Effect Event reads the latest committed props and state when called.
- It should not appear in an Effect dependency array; current React Hooks lint rules flag that usage.
- Its function identity is not stable. React intentionally changes it on every render, so
useEffectEventis not a general substitute foruseCallback.
Effect Events may be called from Effects, Layout Effects, Insertion Effects, or other Effect Events in the same component. Do not call them during render or pass them to child components. The example stays inside that boundary: the external subscription is established by the Effect, and the Effect Event serves as that Effect’s event logic.
Practical examples
Socket.IO messages
function ChatPanel({ conversationId }: { conversationId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const subscribe = useCallback((eventHandler: (message: Message) => void) => {
socket.on('message:new', eventHandler);
return () => socket.off('message:new', eventHandler);
}, []);
useStableHandler(subscribe, (message) => {
if (message.conversationId === conversationId) {
setMessages((previous) => [...previous, message]);
}
});
return <MessageList messages={messages} />;
}
When conversationId changes, the handler sees the new value without rebuilding the Socket.IO listener.
Native resize events
function useWindowWidth() {
const [width, setWidth] = useState(() => window.innerWidth);
const subscribe = useCallback((eventHandler: () => void) => {
window.addEventListener('resize', eventHandler);
return () => window.removeEventListener('resize', eventHandler);
}, []);
useStableHandler(subscribe, () => {
setWidth(window.innerWidth);
});
return width;
}
Choosing an approach
| Approach | Good fit | Caveat |
|---|---|---|
| Put the handler in dependencies | Resubscription is cheap and a dependency change should reconnect | The most direct implementation |
useRef proxy |
Supporting earlier React versions or packaging a generic subscription Hook | subscribe must be stable |
useEffectEvent |
React 19.2+ and the logic is genuinely an Effect Event | Neither a dependency escape hatch nor a stable callback |
Do not use an Effect Event merely to remove dependencies. A value that should reconnect an external system still belongs in the dependency array.