---
title: React 事件订阅的稳定引用问题：从 useEffect 到 useEffectEvent
description: 解决 WebSocket、EventEmitter 和 DOM 监听器因回调变化而反复订阅的问题，并厘清 useRef 代理与 React 19.2 useEffectEvent 的边界。
image: "https://zengliangyi.com/og/blog/react-stable-event-subscriptions.png"
---

**TL;DR：** 在 React 中订阅 WebSocket、EventEmitter 或 DOM 事件时，把不断变化的 handler 放进 Effect 依赖会造成反复 subscribe/unsubscribe。可以用一个固定的代理函数调用 `handlerRef.current`，把“订阅生命周期”和“读取最新状态”分开。React 19.2 的 `useEffectEvent` 为 Effect 内的非响应式逻辑提供了官方表达方式，但它不是通用的稳定回调 API。

> 本文假设你理解 `useEffect` 的依赖数组、闭包和函数引用。

## 问题：监听器需要同一个函数引用

下面这段代码注册了一个内联监听器，却无法在清理阶段拿回相同的函数：



```tsx
function   ChatPanel  () {  
    const   [  messages  ,   setMessages  ]   =   useState  <  Message  []>([]);  
  
    useEffect  (()   =>   {  
      socket.  on  (  'message:new'  , (  message  )   =>   {  
        setMessages  ((  previous  )   =>   [  ...  previous, message]);  
      });  
  
      return   ()   =>   {  
        socket.  off  (  'message:new'   /* 这里需要注册时的同一个函数 */  );  
      };  
    }, []);  
  }
```



`socket.off` 和 `removeEventListener` 都要求传入注册时的同一个函数对象。重新写一个内容相同的箭头函数也没有用，因为引用不同。

把 handler 提出来能解决清理问题：



```tsx
function   useSocketEvent  (  event  :   string  ,   onMessage  :   (  message  :   Message  )   =>   void  ) {  
    useEffect  (()   =>   {  
      socket.  on  (event, onMessage);  
      return   ()   =>   socket.  off  (event, onMessage);  
    }, [event, onMessage]);  
  }
```



但如果父组件每次渲染都创建新的 `onMessage`，Effect 就会先取消订阅再重新订阅。高频更新时，这会带来不必要的 I/O，还可能在切换窗口中漏掉事件。

## 心理模型：固定的代理人

需要稳定的不是业务 handler，而是交给外部系统的代理函数：



```text
WebSocket  
      ↓  
  stableHandler（订阅期间引用不变）  
      ↓  
  handlerRef.current（指向最新 handler）
```



WebSocket 始终认识同一个代理人；代理人每次被调用时，再去找当前最新的业务逻辑。

## 手写方案：用两个 Effect 分离职责



```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);  
  
    // 只同步最新业务逻辑，不执行外部 I/O。  
    useEffect  (()   =>   {  
      handlerRef.current   =   handler;  
    }, [handler]);  
  
    // 只管理订阅生命周期。  
    useEffect  (()   =>   {  
      const   stableHandler   =   (  payload  :   T  )   =>   {  
        handlerRef.  current  (payload);  
      };  
  
      return   subscribe  (stableHandler);  
    }, [subscribe]);  
  }
```



两个 Effect 的职责不同：

| Effect | 职责 | 依赖 | 成本 |

| --- | --- | --- | --- |

| 同步 Effect | 更新 `handlerRef.current` | `handler` | 一次内存赋值 |

| 订阅 Effect | subscribe/unsubscribe | `subscribe` | 外部系统 I/O |

handler 可以在每次渲染时变化；只要 `subscribe` 稳定，外部订阅就不会重建。

## 为什么不在渲染期间更新 ref

常见写法会直接在组件函数体中赋值：



```typescript
const   handlerRef   =   useRef  (handler);  
  handlerRef.current   =   handler;
```



它通常看起来可以工作，但 React 文档明确建议：除初始化外，不要在渲染期间读写 `ref.current`。渲染应当保持纯净，Strict Mode 还会在开发环境中额外调用组件函数，以暴露意外的副作用。

把赋值放进 Effect，意味着只有已经提交的渲染才会更新 ref。代价是 commit 完成到被动 Effect 执行之间存在一个很短的窗口；如果外部事件恰好在这个窗口触发，代理可能看到上一次提交的 handler。对必须在浏览器绘制前完成同步的场景，可以评估 `useLayoutEffect`，但大多数网络和 DOM 订阅并不需要为此升级为布局 Effect。

## 最容易踩的坑：subscribe 也必须稳定

这个 Hook 把稳定性的责任移到了 `subscribe`。如果调用处传入内联函数，订阅 Effect 仍会每次重跑：



```tsx
// ❌ subscribe 每次渲染都是新函数  
  useStableHandler  (  
    (  eventHandler  )   =>   {  
      socket.  on  (  'message:new'  , eventHandler);  
      return   ()   =>   socket.  off  (  'message:new'  , eventHandler);  
    },  
    (  message  )   =>   setMessages  ((  previous  )   =>   [  ...  previous, message])  
  );
```



用 `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]);  
  });
```



业务 handler 不需要 `useCallback`；这正是代理模式的价值。

## React 19.2：useEffectEvent

React 19.2 提供了 `useEffectEvent`，用于把 Effect 中“不应触发重新同步、但需要读取最新值”的逻辑提取为 Effect Event：



```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]);  
  }
```



这里有三个容易混淆的规则：

- Effect Event 调用时能读取最新已提交的 props 和 state。

- 它不应放进 Effect 的依赖数组；当前 React Hooks lint 规则会对此给出提示。

- 它的函数 identity  并不稳定 ，React 甚至会有意在每次渲染时改变它。因此不要把 `useEffectEvent` 当作通用的 `useCallback` 替代品。

Effect Event 只能从 Effect、Layout Effect、Insertion Effect或同一组件内的其他 Effect Event 调用。不要在渲染期间调用，也不要传给子组件。上面的代码符合这个边界：外部订阅在 Effect 中建立，Effect Event 只作为这个 Effect 的事件逻辑使用。

## 实际用例

### Socket.IO 消息监听



```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} />;  
  }
```



`conversationId` 更新时，handler 会看到新值，但 Socket.IO 监听器不需要重建。

### 原生 resize 监听



```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;  
  }
```



## 如何选择

| 方案 | 适合情况 | 注意点 |

| --- | --- | --- |

| 直接把 handler 放进依赖 | 重订阅成本低，且依赖变化本就应重建连接 | 实现最直接 |

| `useRef` 代理 | 需要兼容较早 React，或封装通用订阅 Hook | `subscribe` 必须稳定 |

| `useEffectEvent` | React 19.2+，逻辑确实属于 Effect Event | 不是依赖逃生舱，也不是稳定回调 |

不要为了消除依赖而使用 Effect Event。如果某个值变化后理应重新连接外部系统，它仍然应该留在依赖数组中。

## 相关代码与资料

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

- [完整 Hook 实现](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)

分享：

##  相关文章

[##  TanStack Query 数据流：缓存键、失效与后台任务

对照 ChatCrystal 的查询钩子和 API，用离线缓存实验验证参数键与前缀失效，再区分请求成功、后台完成和页面刷新。          曾亮懿       2026年10月6日      阅读约 4 分钟

#ChatCrystal

#React

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

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 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": "React 事件订阅的稳定引用问题：从 useEffect 到 useEffectEvent",
  "description": "解决 WebSocket、EventEmitter 和 DOM 监听器因回调变化而反复订阅的问题，并厘清 useRef 代理与 React 19.2 useEffectEvent 的边界。",
  "url": "https://zengliangyi.com/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-03-16T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/react-stable-event-subscriptions/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "React 事件订阅的稳定引用问题：从 useEffect 到 useEffectEvent",
      "item": "https://zengliangyi.com/blog/react-stable-event-subscriptions/"
    }
  ]
}
```
