状态管理
用 State 响应输入
一句话理解
不要逐个命令按钮、输入框和提示信息如何变化,而是用 state 描述当前处境,再让 JSX 决定这个处境下应该显示什么。
从操作 DOM 转向描述状态
| 思路 | 提交表单时怎么做 |
|---|---|
| 命令式 | 禁用输入框、禁用按钮、显示加载提示;失败后逐个恢复 |
| 声明式 | 设置 status = 'submitting',由 JSX 统一计算禁用状态和提示内容 |
声明式不是没有执行步骤,而是把界面更新规则集中到渲染逻辑中。事件处理函数只改变数据,React 负责把结果反映到 DOM。
const isSubmitting = status === 'submitting';
<textarea disabled={isSubmitting} />
<button disabled={isSubmitting || answer.trim() === ''}>提交</button>
{isSubmitting && <p>提交中……</p>}这里的 isSubmitting 是渲染时计算的普通变量,不需要再存一份 state。状态更新如何触发界面变化,可以结合 添加交互:渲染和提交 理解。
先设计交互,再写事件函数
以答题表单为例,可以按五步思考:
- 列出视图状态:空白、可提交、提交中、成功、失败。
- 列出触发条件:输入、提交、请求成功、请求失败。
- 存储必要信息:答案
answer、阶段status、错误error。 - 去掉可推导信息:是否为空由答案计算;是否提交中由阶段计算。
- 连接事件:人为操作和异步结果都通过 setter 改变 state。
| 发生的事情 | 数据变化 | UI 结果 |
|---|---|---|
| 输入答案 | 更新 answer | 根据是否为空决定按钮能否提交 |
| 提交 | status 变为 submitting,清空旧错误 | 禁用表单,显示等待提示 |
| 请求成功 | status 变为 success | 显示成功内容 |
| 请求失败 | 回到 typing,记录 error | 恢复输入,允许修正后重试 |
五种视图状态不等于必须声明五个 state 变量。视图是必要数据组合后得到的结果。
一个完整的状态驱动表单
下面用本地异步函数模拟校验;输入 React 成功,其他答案失败:
import { useState } from 'react';
async function checkAnswer(answer) {
await new Promise(resolve => setTimeout(resolve, 500));
if (answer.trim().toLowerCase() !== 'react') {
throw new Error('答案不正确,请重试');
}
}
export default function AnswerForm() {
const [answer, setAnswer] = useState('');
const [status, setStatus] = useState('typing');
const [error, setError] = useState(null);
const isSubmitting = status === 'submitting';
const isEmpty = answer.trim() === '';
async function handleSubmit(e) {
e.preventDefault();
if (isEmpty || isSubmitting) return;
setStatus('submitting');
setError(null);
try {
await checkAnswer(answer);
setStatus('success');
} catch (err) {
setError(err.message);
setStatus('typing');
}
}
if (status === 'success') return <p>回答正确!</p>;
return (
<form onSubmit={handleSubmit}>
<label>
用于构建 UI 的库是什么?
<textarea
value={answer}
disabled={isSubmitting}
onChange={e => {
setAnswer(e.target.value);
setError(null);
}}
/>
</label>
<button disabled={isEmpty || isSubmitting}>
{isSubmitting ? '提交中……' : '提交'}
</button>
{error !== null && <p role="alert">{error}</p>}
</form>
);
}不要用多个布尔值表示互斥阶段
isTyping、isSubmitting、isSuccess 分别保存时,可能出现“提交中且已成功”的矛盾组合。用一个 status 表达互斥阶段,可以从结构上减少非法组合;不同变量之间仍需保持合理关系,例如重试时清除旧错误。
选择 State 结构
一句话理解
好的 state 不是把所有显示内容都存起来,而是只保存最小的必要事实,让其他信息由它们计算出来。
五条设计原则
| 原则 | 判断问题 | 做法 |
|---|---|---|
| 合并关联状态 | 这些值是否总是一起更新? | 坐标合为 { x, y } |
| 避免矛盾 | 是否能组合出不可能的界面? | 用 status 代替多个互斥布尔值 |
| 避免冗余 | 能否由现有 state 或 props 算出? | 姓名拼接、计数、筛选结果在渲染时计算 |
| 避免重复 | 同一业务数据是否保存了两份? | 选中项存 ID,不再保存完整对象 |
| 避免深层嵌套 | 更新一个字段要复制多少层? | 用 ID 建立关系,按 ID 保存实体 |
“合并”不是把所有 state 都放进一个大对象。彼此独立的输入或 UI 状态可以分开保存。
派生数据不必保存为 State
const [firstName, setFirstName] = useState('小');
const [lastName, setLastName] = useState('明');
const fullName = firstName + lastName;fullName 会在每次渲染时根据最新的姓名重新计算。不必新增 setFullName,也不必用 Effect 同步它。
冗余状态会让一次业务更新变成多次维护:改了姓名,却忘了改全名,界面就会不同步。
Props 的初始值不等于持续同步
function ColorPicker({ initialColor }) {
const [color, setColor] = useState(initialColor);
// 后续 initialColor 改变,不会自动重设 color
}- 希望始终跟随父组件:直接读取
colorprop,不要复制到 state。 - 希望只用作初始值,之后独立编辑:存入 state,并用
initialColor或defaultColor表明语义。 - 希望切换业务对象时重新初始化整个编辑器:考虑使用不同的
key,见 对 State 进行保留和重置。
useState(prop) 不是双向绑定
它只在该组件实例初始化时采用这个初始值。父组件重新渲染或 prop 改变,不会让已有 state 自动重置。
选中项存 ID,而不是对象副本
const [items, setItems] = useState([
{ id: 1, title: '学习 State' },
{ id: 2, title: '练习 Reducer' },
]);
const [selectedId, setSelectedId] = useState(1);
const selectedItem = items.find(item => item.id === selectedId);如果把 selectedItem 也保存在 state,编辑列表时新建了任务对象,选中项可能仍指向旧对象。保存 ID 后,重新渲染就会找到最新版本。
删除选中项后,find 可能返回 undefined,应明确显示“未选择”,或同时调整 selectedId。列表会插入、删除或排序时,稳定 ID 比索引更适合表达选择身份。
扁平化:数据与关系分开存
const [places, setPlaces] = useState({
root: { id: 'root', title: '旅行计划', childIds: ['asia'] },
asia: { id: 'asia', title: '亚洲', childIds: ['china'] },
china: { id: 'china', title: '中国', childIds: [] },
});
function renamePlace(id, title) {
setPlaces(places => ({
...places,
[id]: { ...places[id], title },
}));
}嵌套展示不要求数据也深度嵌套。节点内容按 ID 存储,父子关系用 childIds 表达,修改节点时只需要复制实体表和目标节点。
移除一条父子关系,只代表从父节点的 childIds 中去掉 ID;若确定实体及其子树不再被使用,可以再清理对应记录。这两件事不要混淆。
合并成对象后,Setter 不会自动合并字段
setPosition({ x: 100 }) 会替换整个对象,丢掉 y。局部更新应写 setPosition(p => ({ ...p, x: 100 }))。不可变更新规则见 添加交互:更新 State 中的对象。
在组件间共享状态
一句话理解
多个组件要协调同一份信息时,把 state 提升到最近的公共父组件,向下传数据和回调,而不是各存一份再互相同步。
状态提升改变的是所有权
假设两个面板只能展开一个。若每个面板都有自己的 isOpen,它们只知道自己,无法保证互斥。
改为父组件保存一个 activeId:
import { useState } from 'react';
function Panel({ title, isActive, onShow, children }) {
return (
<section>
<h3>{title}</h3>
{isActive ? children : <button onClick={onShow}>展开</button>}
</section>
);
}
export default function Accordion() {
const [activeId, setActiveId] = useState('intro');
return (
<>
<Panel
title="简介"
isActive={activeId === 'intro'}
onShow={() => setActiveId('intro')}
>
<p>认识状态管理。</p>
</Panel>
<Panel
title="练习"
isActive={activeId === 'practice'}
onShow={() => setActiveId('practice')}
>
<p>实现一个折叠面板。</p>
</Panel>
</>
);
}交互链条是:子组件触发回调 → 父组件更新 state → 新 props 下传 → 两个子组件一起反映结果。
这里不是子组件修改了 props,而是父组件通过回调开放了一个“请求变化”的入口。状态提升也不只是移动代码:两个布尔值被重新建模为一个选中 ID。
受控与非受控
| 方式 | 关键状态由谁决定 | 特点 |
|---|---|---|
| 非受控 | 组件自己的 state | 易独立使用,但不方便外部协调 |
| 受控 | 父组件传入的 props | 方便协调,但需要父组件提供值和回调 |
这里说的是组件设计中的控制权,不仅仅指 <input>。一个组件可以让“是否展开”受控,同时把“是否悬停”保留为内部状态。
单一数据源不等于全局只有一个 State
每份需要同步的信息有一个明确的所有者即可。只在局部使用的状态留在局部;不要为了“统一管理”把所有输入和悬停状态都提升到应用根部。
对 State 进行保留和重置
一句话理解
State 由 React 按组件在渲染树中的身份保存;父级内的位置或 key,以及组件类型决定更新时能否沿用原来的状态。
重新渲染不等于重新初始化
{isFancy
? <Counter isFancy={true} />
: <Counter isFancy={false} />}虽然写了两个 JSX 标签,但它们占据同一个父级下的同一个位置,类型也都是 Counter,没有不同的 key。因此切换只更新 prop,原来的计数 state 会保留。
反过来,同一个 <Counter /> JSX 值如果渲染在两个位置,就会得到两个独立的组件状态。
React 比较的是返回的渲染树,不是代码行号,也不是 if 分支。
哪些变化会重置状态
| 变化 | 结果 |
|---|---|
| 相同位置、相同类型、相同 key,只改变 props | 保留 state |
| 组件从树中移除,再重新添加 | 重新初始化 |
同一位置由 Counter 换成 p | 原组件及其子树被移除 |
外层从 section 换成 div | 原外层子树重建,内部 Counter 也重置 |
| 同一位置换成不同 key | 视为新的组件身份,重置子树 |
// Counter 本身没变,但它所在的父级类型变了
{isFancy
? <div><Counter /></div>
: <section><Counter /></section>}不要在组件函数内部定义组件
父组件每次渲染都会创建新的内部组件函数。React 看到的是不同组件类型,可能反复重置输入内容。组件定义应放在模块顶层;可参考 描述UI:你的第一个组件。
用 Key 表达业务身份
聊天输入框切换联系人时,如果只改变 contact prop,草稿通常会留下来:
<Chat contact={contact} />希望切换联系人时清空整个聊天编辑区,可以写:
<Chat key={contact.id} contact={contact} />这个 key 表示“这是某位联系人的聊天”,而不是“这里一直有同一个 Chat”。联系人 ID 改变时,Chat 及其下方状态重新初始化,相关 DOM 也会重建。
另一种重置方式是把条件组件放在不同位置:
{isPlayerA && <Counter person="A" />}
{!isPlayerA && <Counter person="B" />}切换时原位置的组件被移除,新位置的组件被创建,所以也会重置。
Key 不是缓存,也不是全局身份证
Key 只在同一父级内参与身份匹配。切换 A → B → A 时,已卸载的 A 不会因为 key 相同而自动找回旧 state;也不要用 Math.random() 生成 key,否则会不断重建组件。需要在组件内使用 ID 时,要另传普通 prop。
想保留不同对象的草稿怎么办
| 方案 | 适用情况 | 代价 |
|---|---|---|
| 所有组件保持挂载,只用 CSS 隐藏 | 数量少、子树小 | 隐藏内容仍占用资源 |
| 草稿提升到父组件,按联系人 ID 保存 | 切换对象后仍要恢复内容 | 父组件负责管理草稿表 |
保存到 localStorage 等外部存储 | 刷新或关闭页面后仍要保留 | 需要额外的读取、保存和同步逻辑 |
重置的是组件内部状态;想活得更久的数据,要放在生命周期更长的位置。
迁移状态逻辑至 Reducer 中
一句话理解
事件函数负责说明发生了什么,reducer 负责统一计算状态应该怎样改变,把分散的更新规则集中起来。
State、Action、Dispatch、Reducer 的关系
| 名称 | 职责 |
|---|---|
| state | 当前数据 |
| action | 对一次事件的描述,通常包含 type 和必要数据 |
| dispatch | 把 action 交给 React,安排更新 |
| reducer | 接收当前 state 和 action,返回下一份 state |
const [tasks, dispatch] = useReducer(tasksReducer, []);这并没有让 state 变成全局状态:它仍属于调用 useReducer 的组件。改变的只是更新方式。
用 Reducer 管理任务列表
下面的 reducer 定义在组件外,可以单独放到 tasksReducer.js 文件:
export function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, {
id: action.id,
title: action.title,
done: false,
}];
}
case 'toggled': {
return tasks.map(task =>
task.id === action.id
? { ...task, done: !task.done }
: task
);
}
case 'deleted': {
return tasks.filter(task => task.id !== action.id);
}
default: {
throw new Error('未知 action:' + action.type);
}
}
}组件内只描述动作:
const [tasks, dispatch] = useReducer(tasksReducer, []);
function handleAdd(title) {
dispatch({ type: 'added', id: crypto.randomUUID(), title });
}
function handleToggle(id) {
dispatch({ type: 'toggled', id });
}
function handleDelete(id) {
dispatch({ type: 'deleted', id });
}useReducer 需要从 react 导入。这里把随机 ID 生成放在事件处理函数中,reducer 只使用 action 提供的值,保证同样输入能得到同样结果。
Reducer 必须是纯函数
- 不修改原来的对象或数组,返回新 state。
- 不在内部发请求、开启定时器或操作 DOM。
- 每条处理分支明确返回结果,避免漏写
return得到undefined。 - 不依赖随机数、当前时间等外部变化;需要这些数据时先生成,再随 action 传入。
异步请求可以在事件处理中执行,再根据结果派发成功或失败 action。Reducer 负责状态转换,不负责执行请求。
Dispatch 也遵守状态快照规则
调用 dispatch 不会改写当前事件函数已经读取到的 state。不要紧接着读取旧变量并认为那是更新后的值;新结果会用于后续渲染。
Action 表达一次业务动作
用户点击“重置表单”,更适合派发一个 form_reset,由 reducer 一次返回完整的初始状态;不必把每个字段的清空都拆成独立 action。
这样既能把相关变化放在一起,也更容易根据 action 理解“为什么发生这次更新”。
什么时候值得使用 Reducer
| 情况 | 更合适的选择 |
|---|---|
| 一个开关、简单输入,更新规则很少 | useState |
| 多个事件修改同一份复杂数据 | useReducer |
| 多字段需要按业务规则一起变化 | useReducer |
| 希望脱离组件测试状态转换 | 纯 reducer 函数 |
| 一个复杂列表加一个局部输入框 | 列表用 reducer,输入框用 state,可以混用 |
测试 reducer 不需要挂载组件,例如:
const before = [{ id: 'a', title: '学习', done: false }];
const after = tasksReducer(before, { type: 'toggled', id: 'a' });
console.assert(after[0].done === true);
console.assert(before[0].done === false); // 旧快照没被修改嵌套更新过于冗长时,可以使用 use-immer 的 useImmerReducer,在草稿上执行修改。它与普通 reducer 的区别是允许修改 draft,不是允许修改旧 state。
使用 Context 深层传递参数
一句话理解
Context 是沿组件树提供数据的通道,让深层组件读取最近的对应 Provider 的值,省掉中间组件只负责转交的 props。
创建、提供、读取
下面使用兼容 React 18/19 的 .Provider 写法;React 19 也支持直接写 <ThemeContext value={theme}>。
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext('light');
function SaveButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>保存</button>;
}
function Toolbar() {
return <div><SaveButton /></div>;
}
export default function Page() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
切换主题
</button>
<Toolbar />
</ThemeContext.Provider>
);
}Toolbar 不再需要接收和转发 theme。当提供的主题值改变时,读取该 Context 的后代会随之更新。示例中的类名需要配套 CSS 才会有视觉差异。
拆成多个文件时,应在一个模块里创建并导出 Context,其他组件导入同一个对象;不要各自重新 createContext。
最近的 Provider 决定值
<ThemeContext.Provider value="dark">
<SaveButton />
<ThemeContext.Provider value="light">
<SaveButton />
</ThemeContext.Provider>
</ThemeContext.Provider>第一个按钮读取 dark,第二个读取 light。内层只覆盖自身子树,不会改变外层或其他分支的值。
- 找不到对应 Provider 时,使用
createContext的默认值。 - 默认值是兜底值,不是一份会自动更新的 state。
- 不同 Context 相互独立,可以分别提供主题、用户、任务等信息。
useContext在组件或自定义 Hook 顶层调用,不放在条件或循环里。
当前组件读取不到自己返回的 Provider
useContext 向上找祖先 Provider,而当前组件返回的 Provider 位于它下方。即使在同一个组件内“读取并提供”,读到的仍是上层值。
读取上层,再向下提供新值
例如让嵌套章节的标题级别自动递增:
const LevelContext = createContext(0);
function Section({ children }) {
const parentLevel = useContext(LevelContext);
return (
<section>
<LevelContext.Provider value={parentLevel + 1}>
{children}
</LevelContext.Provider>
</section>
);
}外层 Section 读到默认值 0,给后代提供 1;嵌套 Section 读到 1,再提供 2。标题组件可读取这个值选择 h1~h6,实际使用应限制合法范围。
使用 Context 之前,先考虑组合
如果布局组件根本不使用 posts,不一定要让数据穿过 Layout,也不一定需要 Context:
// Layout 接收并转交 posts
<Layout posts={posts} />
// 改为 Layout 只负责布局,数据直接交给 Posts
<Layout>
<Posts posts={posts} />
</Layout>优先顺序可以是:明确的 props → 用 children 减少中间转交 → 确有跨层共享需求时使用 Context。
Context 解决传递问题,不自动解决更新问题
Context 本身不是 reducer,也不是全局数据库。状态依然需要由 useState、useReducer 等持有;消费者想修改它,需要读取提供的回调或 dispatch,而不是直接修改 Context 值。
使用 Reducer 和 Context 扩展应用
一句话理解
Reducer 集中“怎么改”,Context 解决“怎么传”;组合后,深层组件可以读取状态或派发动作,而中间组件不用层层转交。
把状态与更新入口分别提供
继续使用前面的 tasksReducer,新增 TasksContext.jsx:
import { createContext, useContext, useReducer } from 'react';
import { tasksReducer } from './tasksReducer.js';
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, []);
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}
export function useTasks() {
const tasks = useContext(TasksContext);
if (tasks === null) {
throw new Error('useTasks 必须在 TasksProvider 内使用');
}
return tasks;
}
export function useTasksDispatch() {
const dispatch = useContext(TasksDispatchContext);
if (dispatch === null) {
throw new Error('useTasksDispatch 必须在 TasksProvider 内使用');
}
return dispatch;
}两个自定义 Hook 封装读取入口,并在缺少 Provider 时给出明确错误。它们没有各自创建新 state,读取的是同一个 Provider 提供的值。
分成两个 Context 后,读取列表和派发动作是两个明确的依赖;组件按需读取即可。但不要把这种拆分理解为“子组件从此绝不会重新渲染”。
消费组件只关心自己的职责
// TaskApp.jsx
import { useState } from 'react';
import { TasksProvider, useTasks, useTasksDispatch } from './TasksContext.jsx';
function AddTask() {
const [title, setTitle] = useState('');
const dispatch = useTasksDispatch();
function handleSubmit(e) {
e.preventDefault();
const text = title.trim();
if (!text) return;
dispatch({ type: 'added', id: crypto.randomUUID(), title: text });
setTitle('');
}
return (
<form onSubmit={handleSubmit}>
<input
aria-label="新任务"
value={title}
onChange={e => setTitle(e.target.value)}
/>
<button disabled={!title.trim()}>添加</button>
</form>
);
}
function TaskList() {
const tasks = useTasks();
const dispatch = useTasksDispatch();
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.done}
onChange={() => dispatch({ type: 'toggled', id: task.id })}
/>
{task.title}
</label>
<button onClick={() => dispatch({ type: 'deleted', id: task.id })}>
删除
</button>
</li>
))}
</ul>
);
}
export default function TaskApp() {
return (
<TasksProvider>
<AddTask />
<TaskList />
</TasksProvider>
);
}此时数据流是:
- 用户在
AddTask或TaskList中触发操作。 - 组件通过 Context 取得
dispatch,派发 action。 - Provider 中的
useReducer使用 reducer 算出新 tasks。 - Provider 提供新的 tasks,列表读取并展示新数据。
输入框里的临时文字仍保存在 AddTask 内,不必放进共享状态。共享任务列表和局部输入各有自己的所有者。
按业务范围组织,而不是一律全局化
tasksReducer.js:任务数据的更新规则。TasksContext.jsx:状态所有者、Provider、自定义 Hook。TaskApp.jsx及子组件:交互和展示。
同一 Context 可以有多个 Provider 实例。两个独立 TasksProvider 各自调用 useReducer,就拥有两份独立任务列表,不会因为导入同一个 Context 就共享全部状态。
Provider 的生命周期也是状态的生命周期
如果 TasksProvider 被卸载,或因为 key 改变而重建,它内部的任务 state 也会重新初始化。Context 不提供持久化,也不会自动保存到服务器或浏览器存储。

