用一个 1 万条用户数据的筛选小项目,把
useState、批量更新、函数式更新和惰性初始化一次讲清。
很多人刚接触 React 状态时,都会写出这样的代码:
jsx
setCount(count + 1);
console.log(count);
然后困惑:为什么日志还是旧值?连续写三次 setCount(count + 1),为什么计数器只加了 1?
这篇文章以一个 React 19 + Vite 小项目为线索,先从一个看得见的用户筛选页开始,再回到计数器,把这些现象背后的统一模型讲透。文末还会解释项目里 Fragment 与 DocumentFragment 的关系,避免把两个名字相近的概念混在一起。
项目运行环境:React 19.1.0、React DOM 19.1.0、Vite 6.3.5。启动方式如下:
bash
npm install
npm run dev
先看项目:一万名用户的实时筛选
当前入口 src/main.jsx 挂载的是 src/App.jsx。组件在首次渲染时生成 10,000 个用户,输入关键字后,列表随输入实时变化:
jsx
const [users] = useState(() => heavyComputation());
const [filterText, setFilterText] = useState('');
const filteredUsers = useMemo(
() => users.filter((user) => user.name.includes(filterText)),
[users, filterText]
);
这里已经有两种状态来源:
| 数据 | 是否是 state | 原因 |
|---|---|---|
users |
是 | 它是组件需要持有的原始用户数据 |
filterText |
是 | 它来自用户输入,并驱动界面变化 |
filteredUsers |
否 | 它能由前两者推导,属于派生数据 |
这个区分很实用:能由已有 state 和 props 计算出来的数据,通常不要再单独塞进 state。否则原始数据变化时,还要额外维护一份派生数据同步,很容易出现不一致。

useMemo 在这里的角色也要摆正:它按 [users, filterText] 缓存筛选结果,避免无关渲染时重复执行 filter。它是性能优化,不是保存数据的地方。即使移除 useMemo,筛选结果也应该保持正确;只是组件每次渲染都会重新计算一次。
useState 返回的不是"可变变量"
useState 的基本形式很简单:
jsx
const [state, setState] = useState(initialState);
但理解它的关键是:每次渲染都会拿到当次渲染的一份状态快照。 事件处理函数是在某一次渲染中创建的,它读取到的 count 就是那次渲染中的值。
因此,下面的 console.log 读到旧值并不是 setCount 失效:
jsx
function addOne() {
setCount(count + 1);
console.log(count); // 当前这次渲染的 count
}
setCount 的含义是"请求 React 使用新状态进行下一次渲染",不会修改当前函数作用域里的 count。React 通常会在事件处理函数结束后统一处理这些更新;在 React 18 及之后,自动批处理的覆盖范围也包括许多异步回调。
所以与其笼统地说"setState 是异步的",不如记住更准确的一句话:state 在一次渲染中是固定的,setter 负责安排后续渲染。

为什么连续 +1 三次,结果不是 +3
项目的 src/App2.jsx 专门演示了这个问题。假设当前渲染中的 count 是 0:
jsx
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
这三行在同一个事件处理函数内读取到的都是 0,等价于连续提交三次"把状态设为 1"的请求。最终下一次渲染得到 1,并不是 3。
当下一次状态依赖前一次更新的结果时,应改用函数式更新:
jsx
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
React 会按队列依次把上一次计算结果传给下一个 updater,因此过程是 0 -> 1 -> 2 -> 3。这也是 App2.jsx 的 +3 按钮采用函数写法的原因。
可以用一个判断规则快速决定写法:
- 新状态只取决于当前渲染的值,例如
setOpen(!open),直接传值通常足够。 - 新状态依赖于此前排队的更新结果,例如计数、数组追加、对象累积修改,使用
setState((previous) => next)。
对象和数组 state 还要记得保持不可变更新。例如给用户列表追加一项,应返回新数组:
jsx
setUsers((previousUsers) => [
...previousUsers,
{ id: Date.now(), name: '新用户' },
]);
直接修改 previousUsers.push(...) 会破坏 React 依赖引用变化进行判断的约定,也会让后续调试变得困难。
重计算很重时:把初始值写成函数
项目中的 heavyComputation 会构造 10,000 条用户记录,并通过 performance.now() 记录耗时。下面两种写法只差一对箭头函数,执行时机却不同:
jsx
// 每次组件函数执行时,heavyComputation 都会先运行
const [users] = useState(heavyComputation());
// 惰性初始化:React 在初始化 state 时调用它
const [users] = useState(() => heavyComputation());
第一种写法里,JavaScript 必须先计算参数,才能调用 useState。因此只要输入框改变、组件重新渲染,heavyComputation() 就会再次执行,即使 React 不会用它重置已有的 state。
第二种把计算过程交给初始化函数。正常情况下,它只在组件初始化这份 state 时使用,非常适合读取本地缓存、构造大量静态数据或执行昂贵计算这类场景。
这里还有一个开发阶段常见现象:本项目用 <StrictMode> 包裹了根组件。在开发模式下,React 可能额外调用初始化函数来帮助发现不纯的逻辑,所以控制台中的 heavyComputation 日志可能出现两次。不要靠"只执行一次"来写有副作用的初始化逻辑;初始化函数应当保持纯粹,真正的请求、订阅等副作用放在 useEffect 中处理。
Fragment 和 DocumentFragment:名字像,职责不同
README 和 test.html 还引出了一个容易混淆的点。
React 的 Fragment,也就是 <>...</>,用于让组件返回多个并列 JSX 元素,而不会额外创建一个 DOM 包装节点:
jsx
return (
<>
<p>当前计数: {count}</p>
<button onClick={addCount}>+3</button>
</>
);
而 test.html 使用的是浏览器原生 document.createDocumentFragment()。它是一个内存中的 DOM 容器,适合先组织一批节点,再一次性插入页面:
js
const fragment = document.createDocumentFragment();
for (const task of data) {
const item = document.createElement('li');
item.innerText = task;
fragment.appendChild(item);
}
oList.appendChild(fragment);
它们的共同点是都能减少不必要的包裹节点或分散插入,但一个属于 React 的声明式 UI 描述,一个属于浏览器 DOM API,使用位置和生命周期完全不同。
写在最后
这个小项目不只是一个输入框筛选列表,它把 React 状态最重要的三个实践放在了一起:
- 用 state 保存源数据,用计算得到派生数据。
- 把 state 当作一次渲染的快照;依赖前一个结果时使用函数式更新。
- 对昂贵的初始计算使用惰性初始化,并保证初始化函数没有副作用。
掌握这三点后,console.log 为什么是旧值、连续更新为何不叠加、列表初始化为何反复耗时,都会变成同一套渲染模型下可以推导出的结果。React 状态并不神秘,关键是别把它当成会被 setter 当场改写的普通变量。