Skip to content
曾亮懿 ZengLiangYi Blog

Stable React Event Subscriptions: useEffect to useEffectEvent

Prevent WebSocket, EventEmitter, and DOM listeners from resubscribing on every callback change, using a ref proxy or React 19.2 useEffectEvent.

曾亮懿

曾亮懿

4 min read
Table of contents Show Hide

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 useEffect dependencies, 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:

  1. An Effect Event reads the latest committed props and state when called.
  2. It should not appear in an Effect dependency array; current React Hooks lint rules flag that usage.
  3. Its function identity is not stable. React intentionally changes it on every render, so useEffectEvent is not a general substitute for useCallback.

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.

Source and further reading

Share:

From writing to code

Explore ChatCrystal, developer tools and daily learning notes on my GitHub. Questions about the implementation are welcome.