1. 从 useState 说起:它并不“简单”
1.1 useState 的基本用法与心智模型
先说一个我自己的经历。带团队那几年,每次面试候选人我都会问一个看起来特别基础的问题:“useState 返回的第二个数组元素,为什么叫 set 而不是叫 update?”十个人里有八个能答上来“那是更新状态的函数”,但再追问一句“组件重新渲染之后,这个 set 函数还是原来那个吗”,能说清楚的人就少了一半。
useState 是 React 函数组件里最基础的状态管理 API。它的用法简单到一句话就能说完:调用 useState 传入初始值,得到当前状态和更新函数,调用更新函数传入新值,组件重新渲染。但真正重要的是背后的心智模型——函数组件本身就是一次渲染的快照,每一次渲染都有自己的 props 和 state,而 useState 做的事情,是让 React 在组件实例上保存一份“跨渲染”的记忆。这份记忆由 React 内部的 Fiber 节点维护,顺序靠调用顺序保证,这就是为什么官方文档一直强调 Hooks 不能写在条件语句里。
const [count, setCount] = useState(0) function handleClick() { setCount(count + 1) }就这么几行代码,在项目里可能被写成各种离谱的变体。比如有人会在 useEffect 里同步修改 state,有人会在循环里调用 setState,还有人会把多个不相关的状态硬塞进一个对象里。这些写法都不一定会报错,但都会让代码变得难维护。我通常建议团队遵循一条原则:状态之间没有联动关系时,就拆成多个 useState;有关系、有联动、有成组更新的需求时,才考虑合并。
1.2 为什么 setCount 之后立刻拿不到新值
这个问题的出现频率高到离谱,几乎每个初学者都踩过。代码长这样:
function handleClick() { setCount(count + 1) console.log(count) // 打印出来的还是旧值 }原因其实不复杂。React 的 setState 不是同步的,它只是把这次更新放进了一个队列。在这个事件处理函数执行完毕、React 开始重新渲染之前,你在任何位置读到的 count 都是旧渲染闭包里的值。React 之所以要这么设计,是为了把多次更新批量合并,减少不必要的渲染次数。从 React 18 开始,自动批处理的覆盖范围又被扩大了,Promise、setTimeout、原生事件回调里的更新也会被合并。
那怎么拿到新值?两个办法。一是把读取操作放到渲染阶段,也就是直接使用新一次渲染传入的 props 和 state;二是在 useEffect 里监听这个状态的变化。如果你需要在一次更新里基于旧值做连续计算,就得用函数式更新:
setCount(prev => prev + 1) setCount(prev => prev + 1)这样写,两次更新都基于同一个旧值推导,最终结果是加 2。如果写成 setCount(count + 1) 连续调用两次,因为 count 在本次渲染中是同一个,最终只会加 1。这个细节很基础,但在实际项目里见过不少因为这里理解偏差导致的 bug,比如快速点击按钮时数量不对、分页数据少了一页,诸如此类。
提示:只要你需要“基于当前最新值计算新值”,就优先用函数式更新。这不只是习惯问题,在多处并发更新时它能保证推导链路正确。
1.3 函数式更新:一种容易被忽略的正确写法
函数式更新还有另一个价值:它可以绕过闭包过期的问题。看这个典型场景——一个倒计时定时器:
function Timer() { const [seconds, setSeconds] = useState(0) useEffect(() => { const timer = setInterval(() => { setSeconds(prev => prev + 1) }, 1000) return () => clearInterval(timer) }, []) return <div>{seconds}s</div> }如果在这里写 setSeconds(seconds + 1),因为 effect 只挂载一次,闭包里的 seconds 永远是初始值 0,倒计时会一直卡在 1。改成函数式更新就完全没问题,因为 React 在调用这一层回调时,传入的 prev 一定是最新值。这个 trick 在面试里经常作为考察点出现,本质上也是在测试你对“渲染快照与闭包”这个 React 核心模型的理解。
2. useReducer:把状态逻辑“提出来”,而不是“堆进去”
2.1 useReducer 到底解决了什么问题
useState 能覆盖项目中大概八成以上的状态管理需求。剩下那两成,是状态转移逻辑复杂、多个子状态之间存在联动、或者同一份状态逻辑需要在多个组件里复用的场景。这时候再把逻辑写在组件内部,useState 的写法就会显得很勉强。
useReducer 把它换了一种组织方式:状态更新的规则从“散落在组件各处的事件回调”变成“集中在一个 reducer 函数里通过 action 触发的状态转移”。这个思路在 Redux 时代大家就很熟悉了,useReducer 相当于把 Redux 的核心模式搬进了 React 内置 API,省掉了引入外部依赖的成本。
function cartReducer(state, action) { switch (action.type) { case 'add': return { ...state, items: [...state.items, action.payload], } case 'remove': return { ...state, items: state.items.filter(item => item.id !== action.payload), } case 'clear': return { items: [] } default: return state } }每次看到 action.type 我都想强调一件事:这里的字符串常量在项目里一定要抽出去统一管理。手写字符串最大的问题是容易打错字,而且错误往往藏在运行时才暴露。个人习惯是维护一个 actionTypes 对象,或者在 TypeScript 项目里直接用 const 断言 + 联合类型,把枚举值和 payload 类型都收口。
2.2 一个真实可用的购物车案例
空谈概念没意思,写一个我在内部培训时经常用的例子:购物车。需求很简单——添加商品、移除商品、清空购物车,但订单页还需要同时计算总价。如果用 useState 硬写,add 逻辑、remove 逻辑、总价计算逻辑会全部混在组件顶层,一个函数里塞十行以上的 setState 操作,读起来非常痛苦。
用 useReducer 之后,逻辑被拆成了两层。reducer 只负责“根据 action 计算下一个 state”,组件只负责“把用户行为翻译成 dispatch(action)”。这两个职责是分离的,reducer 可以完全脱离 React 独立测试,组件代码也被压缩到很薄的层次。
const initialState = { items: [], totalCount: 0 } function cartReducer(state, action) { switch (action.type) { case 'add': { const existing = state.items.find(item => item.id === action.payload.id) if (existing) { return { ...state, items: state.items.map(item => item.id === existing.id ? { ...item, quantity: item.quantity + 1 } : item ), totalCount: state.totalCount + 1, } } return { ...state, items: [...state.items, { ...action.payload, quantity: 1 }], totalCount: state.totalCount + 1, } } case 'remove': return { ...state, items: state.items.filter(item => item.id !== action.payload), totalCount: Math.max(0, state.totalCount - 1), } default: return state } } function Cart() { const [state, dispatch] = useReducer(cartReducer, initialState) return ( <ul> {state.items.map(item => ( <li key={item.id}> {item.name} x{item.quantity} <button onClick={() => dispatch({ type: 'remove', payload: item.id })}> 删除 </button> </li> ))} <li>总计 {state.totalCount} 件</li> <button onClick={() => dispatch({ type: 'clear' })}>清空</button> </ul> ) }这个案例里有几个关键点。一是 reducer 里永远不直接改原对象,而是返回新对象,这是不可变更新的核心约束;二是 totalCount 也被放在 reducer 里计算,而不是在组件里用 map 和 reduce 再推一次。因为 totalCount 和 items 本质上是一份状态的两种投影,放在一起同步更新比每次渲染时计算更直接,尤其在列表特别长的时候能省下不必要的计算开销。
2.3 惰性初始化与 reducer 的纯函数约定
useReducer 的第三个参数很多人都没用过。useReducer(reducer, initialArg, init) 里的 init 函数可以让你延迟计算初始状态。它的用武之地是:初始值需要经过比较昂贵的计算,或者初始值来自外部数据需要做一层转换。
function createInitialCart(userId) { return { items: loadCartFromCache(userId), totalCount: 0, } } const [state, dispatch] = useReducer(cartReducer, userId, createInitialCart)这里的 init 函数只在首次渲染时执行一次,后续渲染不会重复调用,所以可以在里面放心干一些 IO 操作或者复杂运算,而不用考虑重复执行的性能损耗。
关于 reducer 的纯函数约束,我想多说几句。reducer 必须是一个纯函数——同样的输入一定返回同样的输出,不能有副作用,不能修改传入的 state。这个约束不是 React 强加的教条,而是保证调试和可预测性的基础。如果 reducer 里写了 localStorage、Math.random、Date.now 这类非纯逻辑,在 StrictMode 下 React 会故意调用两次 reducer 来暴露问题,到时候你会看到状态被推进了两次。
注意:StrictMode 下 reducer 可能被调用两次,这要求 action.payload 里不要带随机值、时间戳等不稳定数据。如果需要这些数据,请在 dispatch 之前生成好再放进 action,或者放到组件里的 useMemo/useEffect 中处理。
3. 出了组件:Context、全局状态库与框架选型
3.1 useReducer + Context:一个轻量级的全局状态方案
到这里,useState 和 useReducer 都局限在单个组件内部。跨组件共享状态怎么办?一个经典方案是 useReducer + Context。把 reducer 放到顶层组件里,通过 Context 把 state 和 dispatch 一起传下去,任何子组件都可以读取状态、发出 action,而不需要一层层 props 转发。
const CartContext = createContext(null) function CartProvider({ children }) { const [state, dispatch] = useReducer(cartReducer, initialState) return ( <CartContext.Provider value={{ state, dispatch }}> {children} </CartContext.Provider> ) } function useCart() { const ctx = useContext(CartContext) if (!ctx) { throw new Error('useCart must be used within CartProvider') } return ctx }这个模式的优点很明显:不引入任何第三方库,就能实现全局状态共享,而且因为走的是 React 内置的 Context + Hooks,调试和排查心智负担低。缺点也一样明显:Context 的 value 一旦变化,所有消费这个 Context 的组件都会重新渲染,如果 state 更新的频率很高、消费组件又多,性能会兜不住。
那什么时候该用这个方案?我的经验是:中后台管理系统、表单步骤条、全局用户信息这种“读多写少”或者“更新低频”的场景。高频更新、实时协作、数据模型复杂的应用,直接考虑下一节的重型方案。
3.2 Redux、Zustand、Jotai:怎么选
状态管理选型已经算是 React 社区的月经话题了。Redux 的时代确实过去了,但它的思想深深影响了整个 React 生态。redux-saga 这类中间件在复杂异步流程里依然有不可替代的位置,比如多个请求之间的编排、竞态控制、失败重试。如果你维护的是老项目,大概率还在用 Redux Toolkit 或者 redux-saga,完全没必要为了追新而重写,稳定压倒一切。
新项目的话,我个人的排序是:能用 React 内置方案解决的,不引库;确实需要全局状态的,优先 Zustand;团队里有函数式编程偏好、喜欢原子模型的,考虑 Jotai。
Zustand 最大的特点是心智负担低。不需要 Provider,不需要 reducer,store 是一个普通对象,用 set 函数更新状态,用 selector 订阅片段,配合 immer 中间件之后写起来非常顺手。而且它自带浅比较优化,selector 只返回你需要的字段,值没变就不会触发渲染。
import { create } from 'zustand' const useCartStore = create(set => ({ items: [], addItem: item => set(state => ({ items: [...state.items, item] })), })) function CartBadge() { const count = useCartStore(state => state.items.length) return <span>{count}</span> }选型的本质是权衡。团队规模小、状态简单,就别给自己找事;状态确实复杂,也别硬扛。一个可以落地的心法是:局部状态用 useState,复杂的局部状态用 useReducer,共享低频状态用 Context + useReducer,共享高频或复杂业务状态上 Zustand 或 Redux Toolkit。这个分层思路可以覆盖项目里九成以上的场景。
3.3 Next.js 和 Vite + React 场景下的状态管理差别
热词里同时出现了 Next.js 和 Vite + React,这俩确实是当下新项目最常见的两条技术路线,状态管理在这两种架构下的侧重点很不一样。
Vite + React 是纯客户端渲染(CSR),状态管理可以完全按 SPA 的思路来,挂载之后所有状态都存在内存里。Vite 本身负责开发服务器的热更新和生产构建,对状态管理没有额外的约束,唯一需要注意的是 React 18 的 StrictMode 在开发环境下会让 effect 和 reducer 重复执行,本地调试时如果发现状态被加了两次,先别怀疑代码,大概率是这个原因。
Next.js 的情况要复杂得多。Page Router 和 App Router 都涉及服务端渲染(SSR),组件会在服务端和客户端各跑一遍。如果状态依赖 window、localStorage 这类浏览器 API,在不存在对应能力的环境里就会出错。更隐蔽的是 hydration mismatch——服务端渲染的 HTML 和客户端首次渲染的 DOM 不一致,React 会警告甚至导致 UI 闪烁。解决思路是在状态初始化时把“是否已在客户端挂载”作为一个条件,或者用 useEffect 在挂载后再读取浏览器数据写入状态。
如果你用 Next.js 的 App Router,还要注意 Client Component 和 Server Component 的边界。所有用到 useState、useReducer、useContext 的组件必须显式标 'use client',服务端组件里拿不到任何 Hooks 状态。状态管理的代码尽量收敛到少数几个 Client 边界内部,避免把整棵组件树都拖成客户端组件。
4. 面试题、实战坑与开发工具实录
4.1 高频面试题:会做是不行的,要讲清原理
搜“react 面试题”刷出来最多的就是状态管理相关问题。我面试别人时几乎必问三连:useState 和 useReducer 有什么区别?什么时候用哪个?useReducer 的 reducer 为什么必须是纯函数?
第一问的标准答案是“都能管理状态,useState 适合简单独立的状态,useReducer 适合复杂状态和状态转移规则集中的场景”。但面试官想听的其实是更深的理解——useReducer 把状态更新的逻辑从组件里抽离出来,让组件更薄、逻辑更可测;当多个 state 需要联动更新时,用一个 reducer 统一处理比多个 useState 分散处理更容易保证一致性。
第二问要看场景。一个计数器用 useReducer 确实是过度设计;但一个多步骤表单,每一步都有校验、有回退、有草稿保存,useState 写起来会非常痛。判断标准不是状态数量,而是状态转移的复杂度。如果组件里出现三个以上 setState 互相影响,就可以考虑重构为 useReducer。
第三问的答案前面提到过:纯函数保证可预测性、可测试性,在 Concurrent 模式下 React 可能会丢弃或重放某些更新,非纯逻辑会导致状态不一致。能答到这一层的候选人,我一般会直接放行。
面试里还有一个高频但容易翻车的问题:“为什么不推荐直接用 Context 做全局状态管理?”很多人只记得“会导致不必要的重渲染”,但再往下问——那 Zustand 为什么可以做到精准更新?核心在于 Context 的 value 是一个整体引用,只要 value 变了,所有消费者都会重渲染;而 Zustand 通过 selector + 浅比较维护了一套订阅机制,只有被选择的那部分数据变化,订阅者才会重渲染。这两者的机制差异就是面试官想要的答案。
4.2 实战专项:React Native 启动白屏和持久化初始化
热词里有一条“react native 启动白屏”,这问题和状态管理也有关系,值得单独说。白屏的常见原因之一,是启动时状态还没有准备好,界面就已经渲染了。比如一个 App 需要从 AsyncStorage 读取登录态,读取是异步的,初始状态却是 null,于是首帧渲染了空白或者未登录占位,等异步数据回来才重新渲染。如果加载时间稍长,用户看到的就是一片白屏。
解决办法通常是把应用拆成“启动阶段”和“业务阶段”。启动阶段渲染一个 Splash 或者 loading 骨架,在 useEffect 里完成持久化数据的读取和状态初始化,数据就绪之后再渲染业务组件。如果用的是 Redux,这个流程可以交给 redux-persist 处理;如果用的是 Zustand,可以直接用 persist 中间件,把持久化数据在 store 创建时就恢复好。
import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import AsyncStorage from '@react-native-async-storage/async-storage' const useAuthStore = create( persist( set => ({ token: null, setToken: token => set({ token }), }), { name: 'auth-storage', storage: createJSONStorage(() => AsyncStorage), } ) )这里有个我踩过的坑:Zustand 的 persist 在同步读取本地存储时可能还没恢复完,导致首帧渲染时 token 确实是 null。解决方式是加一个 onRehydrateStorage 回调,或者在 App 根组件里用一个 rehydrated 状态控制业务组件的挂载时机。React Native 的热更新和调试环境有时候还会掩盖这类问题,在真机冷启动时才暴露,所以测试的时候一定要用 release 包跑一遍冷启动流程。
4.3 VSCode 里写 React 的几个提效插件
热词里有一条关于 VSCode 插件的——“什么插件支持 react 标签怎么闭合”。这问题多半是刚接触 JSX 的人问的。JSX 的标签闭合规则跟 HTML 有差异,尤其是嵌套层数多了以后,光靠手写确实容易漏。这里直接给出一套我常用的插件组合,基本可以告别这类烦恼。
- ES7+ React/Redux/React-Native snippets:最老牌也最有用的 React 插件。支持 rafce、rfce 这类前缀快速生成函数组件,同时自带 JSX 标签自动闭合补全。
- Auto Rename Tag:修改起始标签时自动同步修改闭合标签,配合 JSX 特别好用。
- Prettier - Code formatter:配合 .prettierrc 里的 jsxSingleQuote 和 trailingComma 配置,格式化之后 JSX 结构清晰很多,闭合问题肉眼可见地减少。
- TypeScript + TSLint 或 ESLint:配置 jsx-a11y 规则可以提示标签可访问性问题,很多隐藏的闭合错误也会被静态检查捞出来。
插件装完之后,再推荐一个配置技巧:在 settings.json 里给 JSX 设置快速的 Emmet 补全。在用户配置里加一行 “emmet.includeLanguages”: { “javascript”: “javascriptreact” },这样在 .js 文件里写 JSX 也能用 Emmet 语法快速生成标签,效率提升非常明显。
总有人说插件装得越多越卡,实际体验下来这四个组合对性能几乎没有影响,属于低投入高回报的那类工具。开发体验这件事,动动手就能好上一个台阶。
我做 React 项目这些年,最深的体会是:状态管理没有银弹,只有“这个场景下最合适的选择”。useState 简单直接,但别把它用成一根万能稻草;useReducer 多一点仪式感,但在复杂逻辑面前那点仪式感是值得的;全局状态库能救你于水深火热,也能把简单项目变成一个过度设计的标本。真正重要的是想清楚状态从哪里来、要到哪里去、哪些状态需要被共享、哪些状态只属于单个组件。想明白了,用什么工具都是顺手的事情。