Codex写React Hooks为什么总拿到旧状态?用依赖数组解决Stale Closure
2026/8/10 17:25:13 网站建设 项目流程

使用 Codex 修改 React 项目时,有一种 Bug 非常隐蔽:代码没有报错,页面也能正常运行,但事件回调、定时器或异步函数里拿到的却一直是“旧数据”。

常见表现包括:

  • setInterval里的 count 永远停在初始值;

  • 用户已经切换账号,回调里还是旧 user;

  • 搜索关键词更新了,请求函数却继续使用旧 keyword;

  • useCallback明明存在,却执行了之前的逻辑;

  • useEffect加了空依赖后,只运行一次,但数据再也不更新;

  • 为了解决重复执行,Codex不断删除依赖,最后状态越来越不可靠。

这种问题通常叫Stale Closure(过期闭包)

它并不是 React “没有更新状态”,而是某个函数仍然记住了它创建时的旧变量。

一、什么是Stale Closure?

先看一个经典例子:

function Counter() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(timer); }, []); return ( <button onClick={() => setCount(count + 1)}> {count} </button> ); }

用户点击按钮后,页面上的count会正常变成:

1 2 3 4

但定时器可能一直输出:

0 0 0 0

原因就在这里:

useEffect(() => { ... }, []);

这个 Effect 只在第一次渲染时创建。

此时它拿到的:

count = 0

后面组件虽然重新渲染,但旧定时器内部的函数仍然引用第一次渲染时的count

这就是过期闭包。

二、为什么Codex容易写出这种代码?

因为从局部逻辑看:

useEffect(() => { startTask(); }, []);

很合理。

尤其当开发者要求:

这个逻辑只执行一次,不要重复运行。

Codex最直接的处理就是使用空依赖数组。

但“Effect只创建一次”同时也意味着:

它内部捕获的状态可能只来自第一次渲染。

因此,不能把:

不要重复执行

简单理解成:

依赖数组全部删除

Effect 的依赖应该由它实际读取的数据决定。

三、最直接的方法:补齐依赖

前面的例子可以改成:

useEffect(() => { const timer = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(timer); }, [count]);

这样每次count改变:

旧定时器清理 → 创建新定时器 → 新函数拿到最新count

虽然能解决旧状态问题,但也要考虑:

这个副作用是否真的应该随着 count 重新创建?

如果定时器本身不应该反复重建,就需要其他方案。

四、更新状态时优先使用函数式写法

另一个经典错误:

useEffect(() => { const timer = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(timer); }, []);

由于闭包中的count一直是0,所以每次实际执行的都是:

setCount(1)

结果页面可能永远停在:

1

如果只是基于旧值计算新值,更推荐:

setCount(prev => prev + 1);

完整写法:

useEffect(() => { const timer = setInterval(() => { setCount(prev => prev + 1); }, 1000); return () => clearInterval(timer); }, []);

这里不再依赖闭包中的count

React 会把当前最新状态传给prev

这类场景包括:

  • 数字累加;

  • 数组追加;

  • 切换布尔状态;

  • 根据旧对象生成新对象。

五、不要为了消除ESLint警告删除依赖

项目中经常会看到:

React Hook useEffect has a missing dependency

Codex 有时为了让 lint 通过,会建议:

// eslint-disable-next-line react-hooks/exhaustive-deps

或者直接:

useEffect(() => { loadData(userId); }, []);

即使 Effect 实际使用了userId

这种处理很危险。

例如用户从:

/users/1001

切换到:

/users/1002

组件没有卸载,但userId已经变化。

Effect 因为空依赖不会重新执行,页面仍然加载1001的数据。

正确方向通常应该是:

useEffect(() => { loadData(userId); }, [userId]);

ESLint 的 Hooks 依赖提示很多时候是在告诉你:

代码正在读取一个会变化的值,但没有声明这个关系。

六、useCallback也会拿到旧状态

例如:

const handleSave = useCallback(() => { saveUser(user); }, []);

如果user后续发生变化,handleSave仍然可能保存创建回调时的旧用户数据。

应该根据真实依赖写成:

const handleSave = useCallback(() => { saveUser(user); }, [user]);

或者尽量缩小依赖:

const handleSave = useCallback(() => { saveUser(user.id, user.name); }, [user.id, user.name]);

需要注意:

useCallback的作用不是“让函数永远不变”。

它应该表达:

当这些依赖不变时,函数引用可以复用。

如果为了追求函数引用稳定而故意遗漏依赖,就可能把旧状态永久保存下来。

七、useMemo也存在同样问题

例如:

const filteredUsers = useMemo(() => { return users.filter( user => user.status === selectedStatus ); }, [users]);

这里漏掉了:

selectedStatus

当筛选状态变化时:

users没有变化 → useMemo不重新计算 → 页面继续显示旧筛选结果

正确依赖应为:

const filteredUsers = useMemo(() => { return users.filter( user => user.status === selectedStatus ); }, [users, selectedStatus]);

原则仍然一样:

Memo逻辑读取了什么可变值,就需要考虑把什么放进依赖。

八、什么时候适合用useRef保存最新值?

有些场景确实不希望重新创建副作用,但又需要访问最新状态。

例如一个 WebSocket 连接:

组件创建 → 建立一次连接 → 后续消息处理需要读取最新用户配置

如果每次配置变化都断开再重连,可能没有必要。

可以使用useRef

const configRef = useRef(config); useEffect(() => { configRef.current = config; }, [config]);

然后稳定的回调读取:

useEffect(() => { socket.onmessage = message => { handleMessage( message, configRef.current ); }; return () => { socket.close(); }; }, []);

这样:

连接只建立一次

但:

configRef.current

始终指向最新值。

适合:

  • WebSocket;

  • 定时器;

  • DOM事件监听;

  • 长生命周期第三方SDK;

  • 不希望频繁重建的订阅。

但不要为了逃避依赖数组,把所有 state 都塞进 ref。

九、事件监听特别容易出现旧闭包

例如:

useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if ( event.key === "Enter" && keyword ) { search(keyword); } } window.addEventListener( "keydown", handleKeyDown ); return () => { window.removeEventListener( "keydown", handleKeyDown ); }; }, []);

如果keyword第一次是空字符串,后面用户输入:

codex

按回车时,监听函数仍可能读到:

keyword = ""

可以选择:

}, [keyword]);

让监听随关键词更新。

或者用 ref 保持监听函数稳定。

关键不是哪种写法一定更好,而是:

你必须明确事件处理函数需要的是“创建时的数据”,还是“执行时的最新数据”。

十、异步函数也会保存旧参数

例如:

useEffect(() => { async function load() { const result = await search(keyword); setResults(result); } load(); }, []);

如果keyword变化,这个 Effect 不会重新运行。

通常应该:

useEffect(() => { async function load() { const result = await search(keyword); setResults(result); } load(); }, [keyword]);

如果再考虑前面讲过的竞态条件,还需要配合:

AbortController 请求序号

避免旧请求覆盖新请求。

也就是说:

依赖数组

解决的是“会不会因为状态变化重新发起任务”。

请求隔离

解决的是“旧任务返回后能不能覆盖新结果”。

这是两个不同问题。

十一、不要把Effect当成万能同步工具

一个常见写法:

const [fullName, setFullName] = useState(""); useEffect(() => { setFullName( `${firstName} ${lastName}` ); }, [firstName, lastName]);

实际上fullName完全可以直接计算:

const fullName = `${firstName} ${lastName}`;

如果为了同步状态不断增加 Effect,就会出现:

state A变化 → Effect → 更新state B → 又触发其他Effect → 更新state C

最后形成“Effect链”。

Codex 在重构状态管理时尤其容易出现这种情况。

能直接计算的派生数据,优先直接计算,而不是通过 Effect 再保存一份。

十二、先区分Effect到底在做什么

可以将 Effect 大致分成几类。

数据请求

userId变化 → 重新请求用户

外部订阅

组件挂载 → 监听WebSocket

DOM或浏览器事件

监听resize 监听keydown

第三方系统同步

播放器 地图SDK 编辑器

如果一个 Effect 只是:

state变化 → 设置另一个可以计算出来的state

通常值得重新考虑是否真的需要 Effect。

十三、让Codex先做Hooks依赖审查

遇到旧状态问题时,可以先要求:

请先不要修改代码。 检查当前组件中的: - useEffect - useCallback - useMemo 对每一个Hook输出: 1. 内部读取了哪些外部变量; 2. 当前依赖数组是什么; 3. 是否存在遗漏依赖; 4. 是否存在不必要依赖; 5. 是否可能产生stale closure; 6. 是否可以改为函数式更新; 7. 是否更适合使用ref。

这样比直接说:

修复React状态不同步。

更容易定位真正问题。

十四、测试必须模拟状态变化

只测试组件第一次渲染是不够的。

例如:

初始keyword = codex → 测试通过

还应该验证:

keyword = codex ↓ 用户修改为 chatgpt ↓ 触发事件 ↓ 回调必须使用 chatgpt

可以重点覆盖:

  • props变化;

  • state变化;

  • 定时器;

  • 键盘事件;

  • 路由参数变化;

  • 异步请求;

  • 组件卸载。

Stale Closure 通常只有在“第一次渲染之后”才会暴露。

十五、把Hooks规则写进AGENTS.md

可以加入:

# React Hooks规则 - 不允许为了消除lint警告随意删除依赖 - 禁止无理由关闭react-hooks/exhaustive-deps - Hook读取的动态值必须检查依赖关系 - 基于旧state更新时优先使用函数式更新 - 长生命周期事件需要评估stale closure - 不希望重新订阅时可评估useRef保存最新值 - 可直接计算的派生状态不要使用Effect同步 - 修复Hooks问题后必须测试状态变化场景

这样 Codex 在修改 Hooks 时,会更关注闭包和生命周期,而不是只追求代码“能跑”。

十六、Plus还是Pro?

如果主要是:

单组件Hooks 普通useEffect问题 小型React项目 少量状态Bug

Plus 通常已经足够。

如果项目中存在:

大型React仓库 大量自定义Hooks 复杂状态联动 长生命周期订阅 大量测试与重构

则可以根据实际开发强度评估 Pro。

不过无论使用哪个版本,Hooks 的核心规则都一样:

函数拿到的是创建那一刻的变量,还是执行那一刻的最新值?

这个问题必须在代码设计阶段明确。

总结

Codex 写 React Hooks 时总拿到旧状态,很多时候并不是 React 更新失败,而是 JavaScript 闭包保存了函数创建时的数据。

通过正确维护依赖数组、使用函数式更新、合理使用useRef,并减少不必要的 Effect 状态同步,可以大幅降低 Stale Closure 问题。

真正需要关注的不是:

这个Hook执行了吗?

而是:

它执行时拿到的,到底是不是当前最新的数据?

CSDN文章描述

本文介绍 Codex 编写 React Hooks 时常见的 Stale Closure 问题,通过 useEffect、useCallback、useMemo 依赖数组、函数式更新和 useRef,解决回调获取旧状态与数据不同步问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询