02 状态、Hooks、副作用与数据流

本章覆盖 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、useActionStateuseFormStatus、渐进增强。

28. 自定义 Hook 设计原则

好的 Hook 名字表达能力,如 useCourseSearchuseOnlineStatus。它应该隐藏同步外部系统的细节,返回稳定且明确的数据,不把 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 同时有 dataerror

面试题完整答案总集:状态、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 渲染范围过大。若只是单页局部状态,useStateuseReducer 和 Context 通常足够。状态库不能替代服务端缓存,也不能修复混乱的领域模型。

useState 和 useReducer 如何选择?

简单、局部、更新规则少的状态用 useState。多个字段联动、动作有业务语义、更新逻辑复杂或需要单元测试时,用 useReduceruseReducer 的价值是集中状态转移,让 UI 只触发业务动作。

useEffect 依赖数组的意义是什么?

依赖数组描述 Effect 使用了哪些响应式值,以及这些值变化时是否需要重新同步外部系统。它不是随意控制执行次数的性能开关。依赖缺失会产生旧闭包和数据不同步,依赖过多可能说明 Effect 中混入了不该同步的逻辑。

如何处理请求竞态?

可以使用 AbortController 取消旧请求,或使用 request id / ignore flag 忽略过期结果。核心原则是后发出的请求拥有更新 UI 的优先权,旧请求返回不能覆盖新请求结果。

客户端状态和服务端状态有什么区别?

客户端状态由前端拥有,如弹窗、筛选、草稿。服务端状态由服务端拥有,如用户、课程、订单,它可能过期、失败、被别人修改,需要缓存、重试、失效和同步策略。服务端状态应优先交给 TanStack Query、SWR 或框架数据层。

相关推荐
Aurorar0rua1 小时前
CS50 x 2024 Notes C - 09
c语言·开发语言·学习方法
兔小盈1 小时前
多线程篇-(二)线程创建、中断与终止
java·开发语言·多线程
hoiii1872 小时前
基于MATLAB实现内点法解决凸优化问题
开发语言·matlab
空中海2 小时前
04 React Native工程化、质量、发布与生态选型
javascript·react native·react.js
杨超凡2 小时前
豆包收费了?我特么自己用“意念”搓了一个!
javascript
特种加菲猫2 小时前
多态:让代码拥有“千变万化”的能力
开发语言·c++
Mr_pyx2 小时前
【LeetHOT100】LRU缓存——Java多解法详解
java·开发语言
zx2859634002 小时前
Laravel 4.x:颠覆PHP框架的10大革新特性
开发语言·php·laravel