React 状态管理:从局部 State 到可扩展应用
当 React 应用开始变复杂,真正决定代码质量的,往往不是组件写法,而是状态设计
状态管理是 React 真正开始变难的地方
在前两篇里,我们已经知道:
- 组件会根据 props 和 state 渲染 UI
- 用户操作会触发事件
- 调用
setState 会让 React 重新渲染
但学到这里,很多人会开始遇到一个新的问题:代码明明能跑,为什么一复杂就开始乱?
真实项目里更常见的问题是:
- 一个界面到底该定义哪些 state?
- 多个组件需要同步时,状态放哪一层?
- 为什么切换页面后,有的输入框内容还在,有的又被重置了?
- 状态逻辑越来越复杂时,什么时候该用
useReducer?
- 跨很多层传数据时,什么时候该用 Context?
这些问题表面上看是在问 API,实际上问的是另一件事:
你的状态是不是设计清楚了。
React 官方文档把这一章叫做“状态管理”,我觉得非常准确。因为从这一章开始,重点已经不是“怎么写一个交互”,而是“怎么让交互在复杂度上升后仍然清晰、可维护”。
本文会覆盖什么
本文对应 React 官方文档“状态管理”章节及其子章节,按学习顺序讲解:
- 用 State 响应输入
- 选择 State 结构
- 在组件间共享状态
- 对 state 进行保留和重置
- 迁移状态逻辑至 Reducer 中
- 使用 Context 深层传递参数
- 使用 Reducer 和 Context 拓展你的应用
学完之后你应该能做到什么
如果你认真跟着本文走完,应该能掌握这些能力:
- 能把一个交互拆成几个明确的界面状态
- 能判断哪些数据应该放进 state,哪些不该放
- 能在兄弟组件之间正确共享状态
- 能控制组件 state 什么时候保留、什么时候重置
- 能把复杂的更新逻辑迁移到 reducer
- 能用 context 避免层层传 props
这篇文章的目标不是让你记住几个 API 名字,而是让你建立一套更稳定的状态思维。
Part 1: 用 State 响应输入
不要一上来就写事件处理函数
React 官方文档在这一节强调的重点是:
先把界面看成一组状态,再去写组件。
很多初学者写交互时,习惯直接想:
- 点击后把按钮禁用
- 请求回来后显示成功提示
- 出错时显示错误文案
这种写法的问题是,你是在“命令式地改界面”。界面稍微复杂一点,就会越来越乱。
React 更适合用另一种方式思考:
组件会处于哪些状态?每个状态应该显示什么 UI?哪些事件会让它切换到下一个状态?
这听起来有点抽象,但一旦你习惯了这种思路,很多交互代码会自然变简单。
第一步:列出组件的所有状态
以一个问答表单为例,你可以先列出这些状态:
这一步非常重要。很多 bug 的源头,不是代码写错,而是你一开始就漏掉了某个状态。
第二步:为状态选择合适的数据结构
来看一个典型实现:
import { useState } from 'react';
export default function QuizForm() {
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing');
async function handleSubmit(e) {
e.preventDefault();
setStatus('submitting');
setError(null);
try {
await submitForm(answer);
setStatus('success');
} catch (err) {
setError(err);
setStatus('typing');
}
}
if (status === 'success') {
return <h1>答对了!</h1>;
}
return (
<form onSubmit={handleSubmit}>
<textarea
value={answer}
onChange={e => setAnswer(e.target.value)}
disabled={status === 'submitting'}
/>
<br />
<button disabled={answer.length === 0 || status === 'submitting'}>
提交
</button>
{error !== null && <p className="error">{error.message}</p>}
</form>
);
}
这里有三个 state:
-
answer:用户输入的答案
-
error:提交失败后的错误信息
-
status:当前界面所处状态
这三个值已经足够描述整个交互,而且没有多余信息。
为什么 status 比多个布尔值更好
很多人一开始会这样写:
const [isTyping, setIsTyping] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
这样的问题是,很容易出现互相矛盾的状态,比如:
isSubmitting === true
isSuccess === true
这通常不是你想要的结果。
更好的方式是用一个 status 表示互斥状态:
const [status, setStatus] = useState('typing');
这样状态更清晰,也更不容易进入“不可能状态”。
第三步:找出触发状态变化的事件
状态变化通常来自两类来源:
- 用户事件:输入、点击、提交
- 异步结果:请求成功、请求失败
以上面那个表单为例:
-
onChange 会更新 answer
-
onSubmit 会把 status 改成 submitting
- 请求成功后,把
status 改成 success
- 请求失败后,更新
error,并把 status 改回 typing
这样一来,交互的流转路径就很清楚了。你不是在零散地改 UI,而是在管理状态切换。
这一节最应该记住什么
写交互时,先做这 3 件事:
- 列出所有可见状态
- 选出最少的 state 来表示它们
- 明确每个事件会把状态切换到哪里
如果你把这套顺序养成习惯,后面设计复杂界面会轻松很多。
Part 2: 选择 State 结构
State 结构设计得好,后面的代码会轻松很多;设计得差,后面每加一个功能都像在补漏洞。
React 官方文档在这一节给了几条非常重要的原则。这里我按教程方式,一条一条讲清楚。
如果你只记一句话,那就是:
state 应该尽可能少,但又足够表达当前界面。
原则 1:如果总是一起变化,就考虑合并
比如鼠标位置:
const [x, setX] = useState(0);
const [y, setY] = useState(0);
这能工作,但如果你每次更新时都要同时改 x 和 y,更自然的写法是:
const [position, setPosition] = useState({ x: 0, y: 0 });
这样更适合表达“它们本来就是同一件事”。
不过也别为了“整洁”把所有状态都塞进一个对象。没有关系的 state,拆开反而更清楚。React 没有要求你一定用对象或一定拆开,关键看它们是不是同一个概念。
原则 2:避免矛盾的 state
看这个例子:
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);
这两个 state 如果维护不好,就可能互相矛盾。
更好的写法通常是:
const [status, setStatus] = useState('typing');
用一个字段表达互斥状态,通常比多个布尔值更稳定。这是状态建模里非常常用的技巧。
原则 3:能计算出来的值,不要放进 state
这是最常见的错误之一。
// 不推荐
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
这里的 fullName 完全可以由前两个值计算出来,不需要再单独存一份。
// 推荐
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = firstName + ' ' + lastName;
为什么不建议把 fullName 再存一份?
因为一旦存了两份数据,你就要保证它们一直同步。同步一旦漏掉,UI 就会出错。
这个问题在表单里尤其常见。我的建议是:能算出来的值,优先算,不要存。
原则 4:不要重复存同一份数据
假设你有一个商品列表,并且支持选中某一项:
const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(items[0]);
这看起来很方便,但会产生同步问题。比如 items 更新了,selectedItem 可能还是旧对象。
更推荐的写法是只存 selectedId:
const [items, setItems] = useState(initialItems);
const [selectedId, setSelectedId] = useState(0);
const selectedItem = items.find(item => item.id === selectedId);
这是一个很实用的经验,也是很多项目里常见的重构方向:
尽量存 ID,而不是存对象副本。
这样做的好处是,真正的数据源只有一份。后面列表更新、排序、过滤时,也不容易出现“选中的还是旧对象”这种问题。
原则 5:避免深度嵌套 state
如果 state 嵌套太深,更新会变得非常麻烦:
setPlan({
...plan,
childPlaces: {
...plan.childPlaces,
42: {
...plan.childPlaces[42],
title: 'New title',
},
},
});
遇到这种情况时,优先考虑:
- 扁平化数据结构
- 用 ID 建立关系
- 把局部状态拆到更小的组件里
一个简单的判断方法
写完一个 state 结构后,你可以问自己:
- 这个值真的需要“记住”吗?
- 它能不能从别的值算出来?
- 它会不会和别的 state 打架?
- 同一份数据是不是存了两次?
只要这里面有一两项答“是”,就说明 state 结构值得重审。
我在项目里看过很多“状态管理很乱”的组件,问题通常不在 React,而在这里一开始就没设计好。
Part 3: 在组件间共享状态
问题:兄弟组件各自有 state,但业务要求同步
这是 React 里非常经典的一类问题。
先看一个常见场景:手风琴组件。
如果每个 Panel 都自己维护是否展开:
function Panel({ title, children }) {
const [isActive, setIsActive] = useState(false);
return (
<section>
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={() => setIsActive(true)}>显示</button>
)}
</section>
);
}
这没问题,但它只能保证“每个面板自己能展开”。
如果需求变成:
同一时间只允许展开一个面板
那就不能让它们各自管理自己的 isActive 了。
解法:状态提升
React 的标准解法非常直接:把共享状态提升到最近的共同父组件。
import { useState } from 'react';
function Panel({ title, children, isActive, onShow }) {
return (
<section>
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={onShow}>显示</button>
)}
</section>
);
}
export default function Accordion() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<>
<Panel
title="关于"
isActive={activeIndex === 0}
onShow={() => setActiveIndex(0)}
>
React 是一个用于构建用户界面的库。
</Panel>
<Panel
title="词源"
isActive={activeIndex === 1}
onShow={() => setActiveIndex(1)}
>
“React” 这个名字强调界面对状态变化的响应。
</Panel>
</>
);
}
这里发生了三件事:
-
Panel 自己不再保存 isActive
- 父组件保存共享状态
activeIndex
- 父组件通过 props 把值和事件处理函数传给子组件
这就是 React 文档里反复强调的单向数据流。状态放在父组件,值往下传,事件往上传。
什么时候该提升状态
当你发现两个或多个组件:
- 需要显示同一份数据
- 需要对同一件事达成一致
- 一个组件更新后,另一个组件也要跟着变
那就应该考虑把状态提到它们最近的共同父级。
这个“最近”很重要。放得太低,没法共享;放得太高,顶层组件会被很多无关状态塞满。
所以“状态提升”不是把所有状态都往上丢,而是找到一个刚好能覆盖使用范围的父组件。
受控组件和非受控组件
在上面的例子里,Panel 的显示与否完全由父组件控制,这种组件叫受控组件。
如果一个组件把状态完全保存在自己内部,父组件无法控制它,它就更接近非受控组件。
这两个概念你不用死记定义,记住下面这个判断就够了:
- 状态由父组件传入并控制:更偏受控
- 状态封装在组件内部:更偏非受控
实际开发里,只要状态需要多个组件协作,通常就要往受控方向走。
Part 4: 对 State 进行保留和重置
这一节是 React 状态管理里最容易让人困惑的一节,但也非常重要。
React 什么时候会保留 state
React 会根据组件在渲染树中的位置来判断要不要保留 state。
你可以先记住一个最实用的结论:
同一种组件,出现在同一个位置,React 通常会保留它的 state。
看这个例子:
function App() {
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => setIsFancy(e.target.checked)}
/>
使用 fancy 样式
</label>
</div>
);
}
虽然 JSX 写了两个分支,但对 React 来说,这个位置渲染的始终都是 Counter。所以它会保留 Counter 的 state。
这也是为什么很多人会觉得 React 的 state “有点反直觉”。你看到的是两段 JSX,React 看到的是同一个位置上的同一种组件。
理解这点之后,很多“为什么这里没清空”“为什么这里还记着上一次输入”的问题,都会一下子变得很好解释。
React 什么时候会重置 state
如果某个位置上渲染的不再是同一种组件,React 就会重置 state。
例如:
{isPaused ? <p>See you later!</p> : <Counter />}
这里在同一个位置上,一会儿是 p,一会儿是 Counter。类型变了,所以 state 不会保留。
React 官方文档在这里讲得很清楚:state 不是“挂在 JSX 标签上”的,而是 React 根据渲染树位置保存的。位置和类型都变了,之前那份 state 自然就没了。
key 不只给列表用
很多人只知道列表渲染时要写 key,但 key 还有一个非常重要的用途:
告诉 React,这是两个不同身份的组件。
比如聊天窗口:
<Chat key={to.id} contact={to} />
如果你不给 key,切换聊天对象时,输入框里的内容可能会被保留下来。
如果你加上 key={to.id},React 会把不同联系人对应的 Chat 当成不同组件实例处理,输入框 state 就会重置。
所以,key 的本质不是“列表专用语法”,而是“身份标识”。
这点在表单、聊天窗口、切换用户视图这类场景里特别有用。
一个容易踩坑的错误:在组件内部定义组件
看下面的代码:
export default function App() {
function MyTextField() {
const [text, setText] = useState('');
return <input value={text} onChange={e => setText(e.target.value)} />;
}
return <MyTextField />;
}
这种写法会让组件在每次渲染时都重新定义,React 可能把它当成一个新的组件,导致 state 被重置。
正确做法是把组件定义放在顶层。
这个问题前两篇其实也提到过。组件定义放在顶层,不只是为了代码风格,更是为了让 React 能稳定识别组件身份。
这一节最重要的结论
你可以把这节压缩成这 3 句:
- state 跟组件在树中的位置有关
- 类型相同、位置相同,state 通常会保留
- 想强制重置,就改变组件身份,比如使用不同的
key
Part 5: 迁移状态逻辑至 Reducer 中
什么时候应该考虑 useReducer
一个组件只有一两个简单 state 时,useState 很好用。
但如果你开始遇到这些情况,就该考虑 reducer:
- 同一个 state 会被很多事件处理函数修改
- 更新逻辑越来越长
- 组件里有很多
setXxx(...)
- 你越来越难看清“某个操作到底改了什么”
这时候的问题通常不是“React 不够用”,而是“更新逻辑已经散了”。
我的经验是,只要你开始频繁在不同函数里改同一份 state,就该警觉了。因为这往往意味着后面会越来越难维护。
先看 useState 写法
以任务列表为例:
const [tasks, setTasks] = useState(initialTasks);
function handleAddTask(text) {
setTasks([
...tasks,
{ id: nextId++, text, done: false },
]);
}
function handleChangeTask(task) {
setTasks(tasks.map(t => (
t.id === task.id ? task : t
)));
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter(t => t.id !== taskId));
}
这没有错,但随着操作变多,逻辑会越来越分散。
把“怎么更新”收拢到 reducer
使用 reducer 后,事件处理函数只负责派发 action,也就是描述“发生了什么”:
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
真正的更新逻辑集中写在 reducer:
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added':
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
case 'changed':
return tasks.map(task =>
task.id === action.task.id ? action.task : task
);
case 'deleted':
return tasks.filter(task => task.id !== action.id);
default:
throw Error('Unknown action: ' + action.type);
}
}
组件中这样使用:
import { useReducer } from 'react';
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
React 官方文档把这个迁移过程拆成了三个步骤:
- 把设置 state 的逻辑改写成 dispatch action
- 编写 reducer 函数
- 在组件中用
useReducer
这个拆分很实用,因为它告诉你 reducer 不是“推倒重来”,而是一步一步迁移过去。
Reducer 的好处是什么
用了 reducer 之后:
- 事件处理函数变短了
- 更新逻辑集中到一个地方
- 每个 action 都在描述“发生了什么”
- reducer 是纯函数,更容易测试
我觉得 reducer 最大的价值,不是“更高级”,而是把状态更新逻辑从组件里剥出来。组件负责交互,reducer 负责状态变化,这样职责会清楚很多。
写 reducer 时的两个要求
1. reducer 必须是纯函数
不要在 reducer 里:
它只应该根据 state + action 返回新的 state。
2. action 要表达业务动作
好的 action 例子:
dispatch({ type: 'deleted', id: 3 });
dispatch({ type: 'changed', task });
它们表达的是“用户做了什么”,而不是“我要调用哪个 setter”。
useState 和 useReducer 怎么选
可以这样简单判断:
- 简单组件:优先
useState
- 更新逻辑复杂、操作很多:考虑
useReducer
不要为了“高级”而用 reducer。它的价值在于整理复杂逻辑,不在于替代所有 useState。
Part 6: 使用 Context 深层传递参数
Context 解决什么问题
假设你有这样一棵组件树:
<Page>
<Layout>
<Sidebar />
<Content>
<Profile />
</Content>
</Layout>
</Page>
如果 Profile 需要当前登录用户,而这个用户数据在 Page 里,你可能会这样传:
<Layout user={user} />
<Content user={user} />
<Profile user={user} />
这就叫 prop drilling,也就是逐层透传 props。
如果中间组件根本不关心这个数据,只是被迫传下去,代码会越来越烦。
Context 就是为了解决这个问题。
你可以把它理解成一种“跨中间层传值”的机制。
React 官方文档这里有一句话我很认同:如果数据可以“不经过 props 直达需要它的组件”,很多中间层组件就能干净很多。
Context 的基本三步
第一步:创建 context
import { createContext } from 'react';
export const LevelContext = createContext(1);
第二步:提供 context
import { LevelContext } from './LevelContext.js';
function Section({ level, children }) {
return (
<LevelContext value={level}>
{children}
</LevelContext>
);
}
第三步:读取 context
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';
function Heading({ children }) {
const level = useContext(LevelContext);
switch (level) {
case 1:
return <h1>{children}</h1>;
case 2:
return <h2>{children}</h2>;
case 3:
return <h3>{children}</h3>;
default:
return <p>{children}</p>;
}
}
这样,Heading 不需要一级一级接收 level,它会直接从最近的 provider 读取。
这个例子看起来简单,但它很好地说明了 Context 最适合做什么:提供一个“周围环境”。像标题层级、主题、语言、当前用户,都属于这类信息。
Context 的特点
你需要记住两点:
-
useContext(SomeContext) 会读取最近的 provider 提供的值
- 如果上层没有 provider,就使用
createContext 时传入的默认值
什么情况下该用 Context
比较适合 Context 的数据:
- 主题
- 当前用户
- 语言环境
- 路由信息
- 某个功能域内多层组件都要读取的状态
什么情况下不要急着用 Context
如果只是传一两层,props 往往更直接。
因为 props 的依赖关系是显式的,读代码时很容易看懂。而 Context 一旦用多了,数据来源会变得不够直观。
所以一个很实用的建议是:
先问自己,这个值是真的“很多层都要用”吗?如果不是,就先用 props。
React 官方文档还提醒了另一种替代思路:有时候你不需要传某个具体 prop,而是可以把 JSX 作为 children 往下传。这样也能减少中间层的负担。
Part 7: 使用 Reducer 和 Context 拓展你的应用
到了这里,你已经学了两件事:
-
useReducer 可以整理复杂的状态更新逻辑
- Context 可以避免层层传递 props
把它们结合起来,就是 React 原生组织复杂状态的一种常见方式。
如果说 reducer 解决的是“逻辑分散”,那 Context 解决的就是“传递太深”。
一个典型问题
任务列表状态在顶层:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
接下来你会发现:
-
TaskList 需要读 tasks
-
AddTask 需要用 dispatch
-
Task 也需要用 dispatch
如果继续用 props 层层往下传,组件树很快会变得臃肿。
官方文档的常见做法
把 state 和 dispatch 放进两个不同的 context。
import { createContext } from 'react';
export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);
然后统一提供:
import { useReducer } from 'react';
function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
<h1>任务列表</h1>
<AddTask />
<TaskList />
</TasksDispatchContext>
</TasksContext>
);
}
深层组件直接读取:
import { useContext } from 'react';
function TaskList() {
const tasks = useContext(TasksContext);
return (
<ul>
{tasks.map(task => (
<li key={task.id}>{task.text}</li>
))}
</ul>
);
}
function AddTask() {
const dispatch = useContext(TasksDispatchContext);
function handleAdd(text) {
dispatch({
type: 'added',
id: nextId++,
text,
});
}
// ...
}
为什么分成两个 context
这是一个很常见的组织方式,因为它把:
分开了。
这样做的一个好处是,读代码时你会更容易分清:哪些组件只是消费数据,哪些组件还会发起更新。
React 官方文档在后面还进一步把这部分封装成 useTasks 和 useTasksDispatch。这个思路很好,因为它能把“从哪里取数据”也一起隐藏起来,让业务组件更干净。
后面如果你想封装自定义 Hook,也会更清楚:
function useTasks() {
return useContext(TasksContext);
}
function useTasksDispatch() {
return useContext(TasksDispatchContext);
}
什么时候适合这样做
Reducer + Context 比较适合这些场景:
- 某个功能模块已经有一定复杂度
- 多层组件都要读写同一份状态
- 你还不想引入额外状态管理库
它不是所有项目都必须上,但它是 React 原生能力里非常值得掌握的一种模式。
如果项目规模还小,直接 props 或局部 state 完全没问题。只有当一个功能域已经明显变复杂时,Reducer + Context 才会真正体现价值。
学习路径与实践建议
到这里,你可以把本章内容串成一条完整的学习链路:
- 先学会把 UI 拆成几个状态
- 再学会只保存必要的 state
- 接着学会把共享状态提升到父组件
- 再理解 React 为什么会保留或重置 state
- 状态逻辑复杂后,用 reducer 收拢更新
- 组件层级太深后,用 context 传递状态或 dispatch
最常见的几个错误
如果你刚开始练习状态管理,最容易犯这些错:
- 把能计算出来的值也放进 state
- 用多个布尔值描述互斥状态
- 兄弟组件各自维护本该共享的数据
- 不理解
key,导致 state 该重置时没重置
- 过早使用 Context,让数据流变复杂
你可以把这几个问题当成自己的排错清单。
如果你发现某个组件越来越难懂,通常可以反过来检查:
- state 是不是存多了
- 谁拥有这份 state 是不是不清楚
- 更新逻辑是不是散在太多地方
- 组件身份是不是不稳定
很多所谓“React 状态管理问题”,最后都能落回这几个基本点。
总结
这一章最核心的不是 API,而是状态思维。
-
用 State 响应输入:先定义状态,再写 UI
-
选择 State 结构:只保存必要且不冲突的数据
-
共享状态:把共享数据放到最近的共同父级
-
保留和重置:理解位置、类型和
key
-
Reducer:把复杂的更新逻辑集中起来
-
Context:减少层层传递 props 的成本
-
Reducer + Context:在 React 原生能力内组织更复杂的应用
如果你能把这一章真正吃透,后面再学表单、路由、全局状态管理库,都会顺很多。
如果说前两篇解决的是“React 是怎么渲染 UI 和响应交互的”,那这一篇解决的就是另一件更实际的事:
当你的应用不再只是几个按钮和输入框时,状态应该怎么组织,代码才不会失控。
相关资源
本文基于 React 官方文档 “状态管理” 章节。