---
title: "Stable React Event Subscriptions: useEffect to useEffectEvent"
description: Prevent WebSocket, EventEmitter, and DOM listeners from resubscribing on every callback change, using a ref proxy or React 19.2 useEffectEvent.
image: "https://zengliangyi.com/og/blog/en/react-stable-event-subscriptions.png"
---

**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:



```tsx
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:



```tsx
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:



```text
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



```typescript
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:



```typescript
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:



```tsx
// ❌ 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`:



```tsx
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:



```typescript
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 `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



```tsx
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



```tsx
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

- [Original TIL issue](https://github.com/ZengLiangYi/til/issues/2)

- [Complete Hook implementation](https://github.com/ZengLiangYi/til/blob/main/react/use-stable-handler.ts)

- [React: useEffectEvent](https://react.dev/reference/react/useEffectEvent)

- [React: useRef](https://react.dev/reference/react/useRef)

- [React: Separating Events from Effects](https://react.dev/learn/separating-events-from-effects)

Share:

##  Related Posts

[##  TanStack Query Data Flow: Keys, Invalidation, and Queries

Trace ChatCrystal hooks and API, verify parameterized query keys and prefix invalidation, and distinguish accepted requests from background refreshes.          曾亮懿       October 6, 2026      4 min read

#ChatCrystal

#React

#TypeScript](/en/blog/tanstack-query-data-flow)

##  From writing to code

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

[Visit my GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Stable React Event Subscriptions: useEffect to useEffectEvent",
  "description": "Prevent WebSocket, EventEmitter, and DOM listeners from resubscribing on every callback change, using a ref proxy or React 19.2 useEffectEvent.",
  "url": "https://zengliangyi.com/en/blog/react-stable-event-subscriptions/",
  "image": "https://zengliangyi.com/images/blog/react-stable-event-subscriptions.svg",
  "datePublished": "2026-03-16T00:00:00.000Z",
  "dateModified": "2026-10-23T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/en/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/en/blog/react-stable-event-subscriptions/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "https://zengliangyi.com/en"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Blog",
      "item": "https://zengliangyi.com/en/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "Stable React Event Subscriptions: useEffect to useEffectEvent",
      "item": "https://zengliangyi.com/en/blog/react-stable-event-subscriptions/"
    }
  ]
}
```
