本章覆盖 React 中最容易产生复杂度的部分:状态归属、Hook、Effect、Context、Reducer、异步数据、缓存、表单 Action 与乐观更新。
1. 状态分类
| 状态类型 | 示例 | 生命周期 | 推荐方案 |
|---|---|---|---|
| 局部 UI 状态 | 弹窗、展开、输入框 | 组件内 | useState |
| 页面状态 | 搜索、筛选、分页 | 当前页面 | useState / useReducer |
| 领域状态 | 学习进度、购物车、审批流 | 业务模块 | useReducer / 状态库 |
| 服务端状态 | 课程列表、用户信息 | 服务端决定 | TanStack Query / SWR |
| 全局配置 | 主题、语言、当前用户 | 应用级 | Context |
专家判断:状态管理不是"用不用 Redux",而是回答"状态属于哪里、谁能修改、如何同步、如何测试、如何演进"。
2. 派生状态
可以计算出来的值,不要重复存储。
错误:
jsx
const [progress, setProgress] = useState(0);
useEffect(() => {
setProgress(completedIds.length / items.length);
}, [completedIds, items]);
正确:
jsx
const progress = Math.round((completedIds.length / items.length) * 100);
昂贵计算再使用 useMemo:
jsx
const visibleItems = useMemo(() => {
return items.filter((item) => {
return item.title.includes(keyword) && item.level === level;
});
}, [items, keyword, level]);
3. 状态提升
多个组件共享同一状态时,把状态提升到最近公共父组件。
jsx
function CourseExplorer() {
const [keyword, setKeyword] = useState('');
return (
<>
<CourseSearch value={keyword} onChange={setKeyword} />
<CourseResults keyword={keyword} />
</>
);
}
状态不要盲目提升。状态放得越高,更新影响范围越大。
4. useReducer
当状态转移有业务语义时,用 Reducer。
jsx
const initialState = {
keyword: '',
level: '全部',
favorites: [],
completed: [],
};
function reducer(state, action) {
switch (action.type) {
case 'keyword-changed':
return { ...state, keyword: action.keyword };
case 'level-changed':
return { ...state, level: action.level };
case 'favorite-toggled':
return {
...state,
favorites: state.favorites.includes(action.id)
? state.favorites.filter((id) => id !== action.id)
: [...state.favorites, action.id],
};
case 'completed-toggled':
return {
...state,
completed: state.completed.includes(action.id)
? state.completed.filter((id) => id !== action.id)
: [...state.completed, action.id],
};
default:
return state;
}
}
组件:
jsx
const [state, dispatch] = useReducer(reducer, initialState);
dispatch({ type: 'favorite-toggled', id: item.id });
Reducer 优点:
- 更新逻辑集中。
- Action 表达业务语义。
- 可单元测试。
- 更容易迁移到外部状态库。
5. Selector
把派生逻辑从组件中抽离。
js
function selectVisibleItems(items, state) {
const keyword = state.keyword.trim().toLowerCase();
return items.filter((item) => {
const matchLevel = state.level === '全部' || item.level === state.level;
const matchKeyword =
!keyword ||
item.title.toLowerCase().includes(keyword) ||
item.tags.some((tag) => tag.toLowerCase().includes(keyword));
return matchLevel && matchKeyword;
});
}
组件:
jsx
const visibleItems = useMemo(
() => selectVisibleItems(items, state),
[items, state],
);
6. Context
Context 用于跨层级读取稳定的横切信息。
jsx
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Button() {
const theme = useContext(ThemeContext);
return <button className={theme}>保存</button>;
}
Provider value 要稳定:
jsx
const value = useMemo(() => ({ theme, setTheme }), [theme]);
不要把高频变化值放进大 Context。
7. Context + Reducer
jsx
const StateContext = createContext(null);
const DispatchContext = createContext(null);
function KnowledgeProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
拆分 state 和 dispatch 可以减少只需要 dispatch 的组件重新渲染。
8. Hook 规则
Hook 必须:
- 在函数组件或自定义 Hook 中调用。
- 在顶层调用。
- 不在条件、循环、嵌套函数、try/catch/finally 中调用。
错误:
jsx
if (enabled) {
useEffect(() => {}, []);
}
正确:
jsx
useEffect(() => {
if (!enabled) return;
connect();
}, [enabled]);
原因:React 通过 Hook 调用顺序关联内部状态。
9. Hooks 完整参考
State:
useState:组件记忆。useReducer:复杂状态转移。
Context:
useContext:读取上下文。
Ref:
useRef:保存可变值或 DOM。useImperativeHandle:定制暴露给父组件的 ref。
Effect:
useEffect:同步外部系统。useLayoutEffect:浏览器绘制前测量布局。useInsertionEffect:CSS-in-JS 注入样式。useEffectEvent:Effect 中读取最新值但不重新同步。
性能:
useMemo:缓存计算结果。useCallback:缓存函数引用。useTransition:标记非紧急更新。useDeferredValue:延迟非关键值更新。
其他:
useDebugValue:DevTools 标签。useId:生成稳定唯一 ID。useSyncExternalStore:订阅外部 store。useActionState:Action 状态。useOptimistic:乐观 UI。use:读取 Promise 或 Context 资源。
10. useRef
DOM:
jsx
const inputRef = useRef(null);
function focus() {
inputRef.current?.focus();
}
保存可变值:
jsx
const latestRequestId = useRef(0);
更新 ref 不触发渲染。
11. useImperativeHandle
低频使用,用于暴露命令式 API。
jsx
const TextInput = forwardRef(function TextInput(props, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus() {
inputRef.current.focus();
},
clear() {
inputRef.current.value = '';
},
}));
return <input ref={inputRef} />;
});
大多数场景更应该用 Props 表达状态。
12. Effect 同步模型
Effect 用于同步 React 外部系统。
jsx
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
含义:
roomId变化后重新同步。- 重新同步前清理旧连接。
- 卸载时清理。
不要用 Effect 派生内部状态。
13. useLayoutEffect 与 useInsertionEffect
测量布局:
jsx
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setRect(rect);
}, []);
它会阻塞浏览器绘制,谨慎使用。
useInsertionEffect 主要给 CSS-in-JS 库插入样式,业务组件很少需要。
14. useEffectEvent
Effect 内部事件读取最新值,但不触发重新连接。
jsx
const onConnected = useEffectEvent(() => {
showNotification('Connected', theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
不要用它逃避依赖数组。只有逻辑确实属于 Effect 里的事件时才使用。
15. 自定义 Hook
jsx
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue((current) => !current);
}, []);
return [value, toggle, setValue];
}
数据 Hook:
jsx
function useKnowledgeItems() {
const [state, setState] = useState({
status: 'loading',
data: [],
error: null,
});
useEffect(() => {
let ignore = false;
async function load() {
try {
const data = await getKnowledgeItems();
if (!ignore) {
setState({ status: 'success', data, error: null });
}
} catch (error) {
if (!ignore) {
setState({ status: 'error', data: [], error });
}
}
}
load();
return () => {
ignore = true;
};
}, []);
return state;
}
16. 异步请求与取消
jsx
useEffect(() => {
const controller = new AbortController();
async function load() {
setState({ status: 'loading', data: null, error: null });
try {
const response = await fetch(`/api/courses/${courseId}`, {
signal: controller.signal,
});
const data = await response.json();
setState({ status: 'success', data, error: null });
} catch (error) {
if (error.name !== 'AbortError') {
setState({ status: 'error', data: null, error });
}
}
}
load();
return () => controller.abort();
}, [courseId]);
必须处理:
- loading。
- error。
- empty。
- success。
- 取消和竞态。
17. 服务端状态与缓存
服务端状态特点:
- 数据源在服务端。
- 可能失败、过期、被别人修改。
- 需要缓存、失效、重试、去重。
真实项目推荐 TanStack Query / SWR。
jsx
function KnowledgeList() {
const query = useQuery({
queryKey: ['knowledge-items'],
queryFn: getKnowledgeItems,
});
if (query.isLoading) return <Loading />;
if (query.isError) return <ErrorView error={query.error} />;
return <List items={query.data} />;
}
Mutation:
jsx
const mutation = useMutation({
mutationFn: ({ id, completed }) => updateProgress(id, completed),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] });
},
});
18. React 19 表单 Action
jsx
async function submitAction(formData) {
const title = formData.get('title');
await save({ title });
}
function Form() {
return (
<form action={submitAction}>
<input name="title" />
<button>保存</button>
</form>
);
}
Action 更贴近 HTML 表单模型。
19. useActionState
jsx
const initialState = { error: null, message: '' };
async function createLesson(prevState, formData) {
const title = String(formData.get('title') ?? '');
if (!title.trim()) {
return { error: '标题不能为空', message: '' };
}
await saveLesson({ title });
return { error: null, message: '保存成功' };
}
function LessonForm() {
const [state, action, isPending] = useActionState(createLesson, initialState);
return (
<form action={action}>
<input name="title" />
<button disabled={isPending}>保存</button>
{state.error && <p role="alert">{state.error}</p>}
{state.message && <p>{state.message}</p>}
</form>
);
}
20. useFormStatus
jsx
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? '提交中' : '提交'}
</button>
);
}
必须在 form 子树中使用。
21. useOptimistic
jsx
function CommentList({ comments, addComment }) {
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(current, text) => [
...current,
{ id: crypto.randomUUID(), text, pending: true },
],
);
async function formAction(formData) {
const text = String(formData.get('text'));
addOptimistic(text);
await addComment(text);
}
return (
<>
<form action={formAction}>
<input name="text" />
<button>发送</button>
</form>
{optimisticComments.map((comment) => (
<p key={comment.id}>
{comment.text} {comment.pending ? '发送中' : ''}
</p>
))}
</>
);
}
适合点赞、收藏、评论、购物车数量等即时反馈。
22. 外部状态库选择
Zustand:
js
const useKnowledgeStore = create((set) => ({
favorites: [],
toggleFavorite: (id) =>
set((state) => ({
favorites: state.favorites.includes(id)
? state.favorites.filter((itemId) => itemId !== id)
: [...state.favorites, id],
})),
}));
Redux Toolkit 适合大型团队和复杂领域状态。Jotai 适合原子状态组合。Valtio 适合 proxy 风格状态。
判断是否需要状态库:
- 多页面共享复杂客户端状态。
- 需要状态 DevTools。
- Context 导致渲染范围过大。
- 业务模块需要细粒度订阅。
23. 状态管理决策树
text
状态只在一个组件用?
是 -> useState
否 -> 多个兄弟组件用?
是 -> 提升到公共父组件
否 -> 是服务端数据?
是 -> 请求缓存库
否 -> 是横切配置?
是 -> Context
否 -> 领域状态复杂?
是 -> useReducer 或状态库
24. Hook 依赖、闭包与数据流扩展
闭包旧值是 React 中最常见的问题之一。
jsx
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
}
count 永远是首次渲染时的值。修正:
jsx
setCount((current) => current + 1);
如果 Effect 中读取某个响应式值,并且这个值变化应重新同步,就把它放进依赖数组:
jsx
useEffect(() => {
loadCourse(courseId);
}, [courseId]);
如果读取最新值但不希望重新同步,考虑 useEffectEvent 或 ref,但不要用它逃避依赖数组。
25. Reducer 设计深化
Action 命名建议使用业务语义:
js
{ type: 'lesson/completed', id }
{ type: 'filter/queryChanged', query }
{ type: 'sync/failed', error }
不推荐:
js
{ type: 'click' }
{ type: 'setData' }
{ type: 'change' }
把复杂转移拆成领域函数:
js
function completeLesson(state, id) {
if (state.completed.includes(id)) return state;
return { ...state, completed: [...state.completed, id] };
}
这样 reducer 更像业务动作路由,领域函数更容易单元测试。
26. 异步数据高级场景
请求缓存库要处理请求去重、缓存复用、后台刷新、重试、失效和乐观更新。
预取:
jsx
queryClient.prefetchQuery({
queryKey: ['course', id],
queryFn: () => getCourse(id),
});
乐观更新回滚:
jsx
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ['lessons'] });
const previous = queryClient.getQueryData(['lessons']);
queryClient.setQueryData(['lessons'], (lessons) =>
lessons.map((lesson) =>
lesson.id === id ? { ...lesson, completed: true } : lesson,
),
);
return { previous };
},
onError: (error, variables, context) => {
queryClient.setQueryData(['lessons'], context.previous);
}
27. 表单复杂度分层
- 轻量表单:
useState,适合 1 到 5 个字段。 - 中等表单:
useReducer,适合字段级错误、dirty、touched。 - 复杂表单:React Hook Form + Zod/Yup,适合字段数组、异步校验、分步表单。
- 服务端表单:React 19 Actions、
useActionState、useFormStatus、渐进增强。
28. 自定义 Hook 设计原则
好的 Hook 名字表达能力,如 useCourseSearch、useOnlineStatus。它应该隐藏同步外部系统的细节,返回稳定且明确的数据,不把 UI 组件混进去,并对错误、加载和清理有设计。
反例:
jsx
function useEverything() {
// 请求、弹窗、路由、权限、表单全塞在一起
}
专家检查题:
- 这个状态是否可以派生?
- 这个 Effect 是否真的连接外部系统?
- mutation 后缓存如何更新?
- 乐观更新失败如何回滚?
- Context value 是否稳定?
- 状态库是否解决真实问题?
29. Hooks 选择矩阵
| 问题 | 首选 |
|---|---|
| 简单局部状态 | useState |
| 多动作复杂状态 | useReducer |
| 跨层级稳定配置 | useContext |
| 保存 DOM 或可变值 | useRef |
| 暴露命令式方法 | useImperativeHandle |
| 同步外部系统 | useEffect |
| 绘制前测量布局 | useLayoutEffect |
| CSS-in-JS 注入样式 | useInsertionEffect |
| Effect 内读取最新值 | useEffectEvent |
| 缓存昂贵计算 | useMemo |
| 稳定函数引用 | useCallback |
| 非紧急更新 | useTransition |
| 延迟派生值 | useDeferredValue |
| 外部 store 订阅 | useSyncExternalStore |
| 表单 Action 状态 | useActionState |
| 乐观 UI | useOptimistic |
| 读取 Promise / Context 资源 | use |
30. Effect 场景库
适合 Effect:
- WebSocket 连接。
- DOM 事件订阅。
- 定时器。
- 第三方 SDK 初始化。
- 浏览器存储同步。
- 手动数据请求。
- 页面标题。
- 媒体查询监听。
不适合 Effect:
- 根据 props 计算显示文本。
- 根据数组计算数量。
- 事件发生时提交请求。
- 纯业务规则判断。
- 表单字段同步派生。
31. 数据一致性专题
客户端常见一致性问题:
- 请求 A 后发出请求 B,但 A 后返回覆盖 B。
- 乐观更新失败没有回滚。
- 多页面各自缓存同一数据。
- 删除后列表缓存未失效。
- 权限变更后旧数据仍显示。
解决:
- AbortController。
- request id。
- query cache。
- mutation invalidation。
- optimistic rollback。
- 服务端版本号。
32. 状态建模练习
为"课程学习流程"建模:
ts
type LearningState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; lessons: Lesson[] }
| { status: 'error'; message: string };
这种 discriminated union 比多个布尔值更可靠。
反例:
ts
type State = {
loading: boolean;
error: string | null;
data: Lesson[] | null;
};
多个字段可能出现非法组合,如 loading: true 同时有 data 和 error。
面试题完整答案总集:状态、Hooks 与数据
这个状态是否可以派生?
如果一个值能从已有 Props、State 或服务端数据计算出来,就应该优先派生,而不是单独存储。重复存储会产生同步问题,例如 completedIds.length 已经能得到完成数量,就不需要再存 completedCount。只有计算昂贵时才考虑 useMemo。
Effect 是否真的连接外部系统?
Effect 的职责是同步 React 外部系统,例如网络请求、订阅、定时器、DOM、浏览器存储、第三方 SDK。如果只是根据数据计算另一个值,通常不需要 Effect。滥用 Effect 会导致额外渲染、依赖混乱和状态不同步。
mutation 后缓存如何更新?
常见方式有失效查询和直接更新缓存。失效查询简单可靠,mutation 成功后让相关 query 重新请求。直接更新缓存适合结果明确的场景。乐观更新则在请求完成前先更新 UI,失败时必须回滚。
乐观更新失败如何回滚?
mutation 前保存旧缓存快照,先写入乐观结果;请求失败时用快照恢复缓存;请求成功后可保留结果或重新失效查询校验服务端数据。核心是保存 previous、失败恢复 previous、结束后重新同步。
Context value 是否稳定?
如果 Provider 每次渲染都创建新对象,如 value={``{ theme, setTheme }},消费者会收到新引用并重渲染。应使用 useMemo 稳定 value,或拆分 Context。高频变化状态不适合放在大 Context 中。
状态库是否解决真实问题?
引入状态库应解决真实复杂度,如多页面共享客户端状态、需要细粒度订阅、需要 DevTools、Context 渲染范围过大。若只是单页局部状态,useState、useReducer 和 Context 通常足够。状态库不能替代服务端缓存,也不能修复混乱的领域模型。
useState 和 useReducer 如何选择?
简单、局部、更新规则少的状态用 useState。多个字段联动、动作有业务语义、更新逻辑复杂或需要单元测试时,用 useReducer。useReducer 的价值是集中状态转移,让 UI 只触发业务动作。
useEffect 依赖数组的意义是什么?
依赖数组描述 Effect 使用了哪些响应式值,以及这些值变化时是否需要重新同步外部系统。它不是随意控制执行次数的性能开关。依赖缺失会产生旧闭包和数据不同步,依赖过多可能说明 Effect 中混入了不该同步的逻辑。
如何处理请求竞态?
可以使用 AbortController 取消旧请求,或使用 request id / ignore flag 忽略过期结果。核心原则是后发出的请求拥有更新 UI 的优先权,旧请求返回不能覆盖新请求结果。
客户端状态和服务端状态有什么区别?
客户端状态由前端拥有,如弹窗、筛选、草稿。服务端状态由服务端拥有,如用户、课程、订单,它可能过期、失败、被别人修改,需要缓存、重试、失效和同步策略。服务端状态应优先交给 TanStack Query、SWR 或框架数据层。