React 基础体系 · 第 5/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React State 与 Hooks:useState、useReducer、批处理和状态快照

在 React 中,状态更新最容易被误解的地方有三个:

  1. useState 返回的状态不是一个会被原地修改的变量,而是某次渲染产生的“快照”。
  2. 多次状态更新不会简单地立即执行,而是先进入更新队列,再由 React 在合适的时机批量处理。
  3. useReducer 并不是“更高级的 useState”,它改变的是状态转移的组织方式:从直接提交新值,变为提交 action,再由 reducer 计算新状态。

理解这三个机制,才能解释诸如“连续调用三次 setCount(count + 1) 为什么只加一”“为什么定时器里读到的是旧值”“为什么 reducer 中不能修改原对象”等问题。


一、状态、渲染和快照

1.1 状态不是普通可变变量

考虑下面的组件:

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count);
  }

  return (
    <button onClick={handleClick}>
      {count}
    </button>
  );
}

点击按钮后,界面最终显示 1,但控制台打印的是 0

这不是因为 React 更新失败,而是因为:

  • 当前渲染产生了一个 count = 0 的快照;
  • handleClick 函数也属于这次渲染;
  • setCount(count + 1) 只是向 React 提交一个更新请求;
  • 当前函数不会因此重新执行;
  • 重新渲染完成后,下一次渲染才会得到 count = 1

可以把一次渲染抽象为:

Rn=(Pn,Sn)UInR_n = (P_n, S_n) \rightarrow UI_n

其中:

  • RnR_n 表示第 nn 次渲染;
  • PnP_n 是该次渲染接收到的 props;
  • SnS_n 是该次渲染读取到的状态快照;
  • UInUI_n 是 React 根据这些输入计算出的 UI。

事件处理函数创建于 RnR_n,因此它闭包捕获的是 SnS_n,而不是未来的 Sn+1S_{n+1}

状态更新更接近下面的过程:

SnupdateSn+1S_n \xrightarrow{\text{update}} S_{n+1}

但这不是在当前函数中直接修改 S_n,而是在函数结束后由 React 处理更新队列,并启动新的渲染。

1.2 “快照”会影响闭包

下面的代码会连续弹出三个 0

import { useState } from "react";

export default function SnapshotDemo() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);

    setTimeout(() => {
      alert(count);
    }, 1000);
  }

  return <button onClick={handleClick}>当前值:{count}</button>;
}

假设点击时 count0

  1. 当前渲染的 count0
  2. setCount(count + 1) 提交更新 1
  3. 定时器回调闭包捕获当前渲染中的 count = 0
  4. 一秒后回调仍然读取 0
  5. 即使此时界面已经显示 1,这个回调也不会自动改读新快照。

如果业务需要读取“回调执行时的最新值”,就不能依赖旧闭包中的状态变量。可以使用 ref 保存可变引用:

import { useEffect, useRef, useState } from "react";

export default function LatestValueDemo() {
  const [count, setCount] = useState(0);
  const latestCountRef = useRef(count);

  useEffect(() => {
    latestCountRef.current = count;
  }, [count]);

  function handleClick() {
    setCount((current) => current + 1);

    setTimeout(() => {
      alert(latestCountRef.current);
    }, 1000);
  }

  return <button onClick={handleClick}>当前值:{count}</button>;
}

这里的 ref 不是用来触发渲染的:

  • 修改 ref.current 不会触发重新渲染;
  • 它适合保存不需要直接驱动 UI 的可变值;
  • 如果值需要展示在界面上,仍然应该使用 state。

这也是 state 和 ref 的根本区别:state 参与渲染,ref 保存跨渲染但不负责渲染的数据。


二、useState:直接描述状态更新

2.1 基本形式

const [state, setState] = useState(initialState);

useState 返回:

  • state:当前渲染的状态快照;
  • setState:提交状态更新的函数。

初始值可以直接给出:

const [count, setCount] = useState(0);

也可以传入惰性初始化函数:

const [settings, setSettings] = useState(() => {
  const raw = localStorage.getItem("settings");

  return raw
    ? JSON.parse(raw)
    : { theme: "light", pageSize: 20 };
});

惰性初始化函数只用于计算初始状态,而不是每次渲染都执行。它必须是纯函数,并且在服务端渲染场景中不能直接无条件访问 windowlocalStorage 等浏览器 API。

在开发环境的 Strict Mode 下,React 可能调用初始化函数多次以检测副作用,其中一次结果会被丢弃。因此初始化函数不能执行写文件、发送请求、修改全局变量等副作用。

2.2 两种更新形式

setState 支持直接值和函数式更新:

setCount(10);

setCount((current) => current + 1);

直接值适合“我已经知道目标值”的情况;函数式更新适合“新值依赖旧值”的情况。

例如,下面的代码并不会得到 3

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

如果当前快照中的 count = 0,三次调用实际上都提交了:

setCount(1);
setCount(1);
setCount(1);

最后状态是 1

正确写法是:

function handleClick() {
  setCount((current) => current + 1);
  setCount((current) => current + 1);
  setCount((current) => current + 1);
}

这时 React 会按队列顺序计算:

0f1(x)=x+11f2(x)=x+12f3(x)=x+130 \xrightarrow{f_1(x)=x+1} 1 \xrightarrow{f_2(x)=x+1} 2 \xrightarrow{f_3(x)=x+1} 3

函数式更新的参数 current 不是当前事件处理函数闭包里的旧变量,而是更新队列在处理到该更新时的中间状态。

直接值更新和函数式更新混合时,顺序也很重要:

function handleClick() {
  setCount((current) => current + 1);
  setCount(10);
  setCount((current) => current + 1);
}

0 开始时,计算过程是:

  1. 函数式更新:0 + 1 = 1
  2. 直接值更新:状态替换为 10
  3. 函数式更新:10 + 1 = 11

最终结果是 11

因此,函数式更新不仅能解决“连续加法”问题,也明确表达了一个状态转移函数。

2.3 对象状态不会自动合并

类组件中的旧式 setState 曾经会浅合并对象,但函数组件的 useState 不会:

type User = {
  name: string;
  age: number;
};

const [user, setUser] = useState<User>({
  name: "Ada",
  age: 30,
});

下面的写法会丢失 age

setUser({ name: "Grace" });

因为它把整个状态替换成了不完整对象。

应该显式保留其他字段:

setUser((current) => ({
  ...current,
  name: "Grace",
}));

数组也必须创建新引用:

setItems((current) =>
  current.filter((item) => item.id !== targetId)
);

不要在 state 中原地修改数组或对象:

// 错误:修改了旧状态对象
user.name = "Grace";
setUser(user);

// 错误:修改了旧数组
items.push(newItem);
setItems(items);

React 通常使用 Object.is 判断某个状态更新前后的值是否相同。如果仍然传入同一个对象引用,React 可能认为状态没有变化,从而跳过相关渲染。即使某次场景下界面碰巧更新,也不能依赖原地修改,因为它会破坏状态快照、调试工具和并发渲染的假设。


三、批处理:更新请求何时被统一处理

3.1 批处理解决什么问题

批处理(batching)是指 React 将同一处理范围内的多个状态更新合并处理,避免每调用一次 setter 就立刻产生一次独立渲染。

function handleClick() {
  setFirstName("Ada");
  setLastName("Lovelace");
  setCount((current) => current + 1);
}

React 通常会把这些更新放在同一轮处理。这样可以减少中间 UI 状态,例如避免先显示新名字、再显示新姓氏、最后才显示新计数。

批处理不等于“状态更新互相覆盖”,也不等于“所有更新都合并成一个值”。更新队列仍然逐项计算,函数式更新仍然会读取前一个更新产生的中间结果。

3.2 React 18 之后的自动批处理边界

在现代 React 中,批处理不只发生在 React 点击事件里,也通常覆盖 Promise、定时器等异步回调:

function handleClick() {
  setTimeout(() => {
    setCount((current) => current + 1);
    setFlag((current) => !current);
  }, 0);
}

在 React 18 及之后的常见客户端渲染环境中,这两个更新通常会被自动批处理。

但应区分三件事:

  1. 更新入队:setter 调用后,更新已经提交;
  2. 重新渲染:React 根据优先级和调度安排计算 UI;
  3. 提交到 DOM:计算结果最终应用到页面。

批处理主要影响重新渲染和提交的组织方式,不改变状态更新的逻辑顺序。

React 不承诺每个任意时刻的更新都必须合并成一次渲染。并发渲染、优先级、根类型和宿主环境都可能影响具体调度。业务代码应该依赖状态转移的正确性,而不是依赖“恰好只渲染一次”。

3.3 flushSync 是例外工具,不是常规更新方式

React DOM 提供 flushSync,用于在少数必须立即把更新提交到 DOM 的场景:

import { flushSync } from "react-dom";

function handleAdd() {
  flushSync(() => {
    setItems((current) => [...current, createItem()]);
  });

  // 此时通常可以读取已经提交的 DOM
  listRef.current?.lastElementChild?.scrollIntoView();
}

它可能强制 React 提前刷新工作,破坏批处理收益,并增加性能成本。只有当外部浏览器 API 或第三方库要求“更新后立刻读取 DOM”时才应考虑它。大多数业务逻辑不需要 flushSync,更不应该用它来“修复”错误的状态依赖。


四、状态更新的完整流程

一次典型的客户端状态更新可以抽象为:

sequenceDiagram
    participant E as 用户事件
    participant H as 事件处理函数
    participant Q as 更新队列
    participant R as React 渲染阶段
    participant C as Commit 阶段
    participant D as DOM

    E->>H: 点击按钮
    H->>Q: 调用 setState / dispatch
    H-->>Q: 继续执行,读取的仍是当前快照
    Q->>R: 按顺序计算新状态
    R->>R: 执行组件函数,生成新 UI
    R->>C: 对比并提交结果
    C->>D: 更新 DOM

关键点是:

  • 事件处理函数执行期间,当前渲染快照不变;
  • setter 或 dispatch 不会直接改变当前函数中的变量;
  • React 可能在渲染阶段暂停、重启或丢弃一次计算;
  • 只有提交阶段完成后,新的 DOM 才正式可见;
  • 渲染函数和 reducer 必须是纯的,因为渲染结果可能被计算多次。

因此,不应在组件函数或 reducer 中执行副作用:

// 错误:渲染期间发送请求
function UserProfile({ userId }: { userId: string }) {
  fetch(`/api/users/${userId}`);

  return <div>{userId}</div>;
}

同步外部系统应放到合适的 Effect、事件处理函数或框架提供的服务端数据机制中。Effect 的依赖、清理和竞态属于另一层生命周期问题,不能通过“把所有逻辑放入 reducer”解决。


五、useReducer:把状态转移集中到 reducer

5.1 基本模型

useReducer 的形式是:

const [state, dispatch] = useReducer(reducer, initialArg, init?);

其中 reducer 是一个纯函数:

type Reducer<State, Action> = (
  state: State,
  action: Action
) => State;

它描述:

Sn+1=R(Sn,An)S_{n+1} = R(S_n, A_n)

其中:

  • SnS_n 是当前状态;
  • AnA_n 是 action;
  • RR 是 reducer;
  • Sn+1S_{n+1} 是新状态。

dispatch(action) 不直接告诉 React 新状态是什么,而是提交一个事件:

dispatch({ type: "increment" });

React 随后调用:

const nextState = reducer(currentState, action);

这使状态变化具有更明确的事件语义。

5.2 一个完整的 TypeScript 示例

下面是一个可以运行在客户端组件中的任务列表:

"use client";

import { useReducer, useState } from "react";

type Task = {
  id: number;
  title: string;
  done: boolean;
};

type State = {
  tasks: Task[];
  filter: "all" | "active" | "done";
};

type Action =
  | {
      type: "added";
      task: Task;
    }
  | {
      type: "toggled";
      id: number;
    }
  | {
      type: "removed";
      id: number;
    }
  | {
      type: "filterChanged";
      filter: State["filter"];
    };

const initialState: State = {
  tasks: [],
  filter: "all",
};

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "added":
      return {
        ...state,
        tasks: [...state.tasks, action.task],
      };

    case "toggled":
      return {
        ...state,
        tasks: state.tasks.map((task) =>
          task.id === action.id
            ? { ...task, done: !task.done }
            : task
        ),
      };

    case "removed":
      return {
        ...state,
        tasks: state.tasks.filter((task) => task.id !== action.id),
      };

    case "filterChanged":
      return {
        ...state,
        filter: action.filter,
      };

    default: {
      const exhaustiveCheck: never = action;
      return exhaustiveCheck;
    }
  }
}

export default function TaskApp() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const [title, setTitle] = useState("");

  const visibleTasks = state.tasks.filter((task) => {
    if (state.filter === "active") return !task.done;
    if (state.filter === "done") return task.done;
    return true;
  });

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const trimmedTitle = title.trim();
    if (!trimmedTitle) return;

    dispatch({
      type: "added",
      task: {
        id: Date.now(),
        title: trimmedTitle,
        done: false,
      },
    });

    setTitle("");
  }

  return (
    <section>
      <form onSubmit={handleSubmit}>
        <input
          value={title}
          onChange={(event) => setTitle(event.target.value)}
          placeholder="输入任务"
        />
        <button type="submit">添加</button>
      </form>

      <label>
        筛选:
        <select
          value={state.filter}
          onChange={(event) =>
            dispatch({
              type: "filterChanged",
              filter: event.target.value as State["filter"],
            })
          }
        >
          <option value="all">全部</option>
          <option value="active">未完成</option>
          <option value="done">已完成</option>
        </select>
      </label>

      <ul>
        {visibleTasks.map((task) => (
          <li key={task.id}>
            <label>
              <input
                type="checkbox"
                checked={task.done}
                onChange={() =>
                  dispatch({ type: "toggled", id: task.id })
                }
              />
              {task.title}
            </label>

            <button
              type="button"
              onClick={() =>
                dispatch({ type: "removed", id: task.id })
              }
            >
              删除
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

这个例子中:

  • title 是输入框当前值,变化简单,因此用 useState
  • tasksfilter 之间存在多个相关事件,因此放入同一个 reducer;
  • visibleTasks 是由 state 推导出的数据,不需要额外保存;
  • reducer 只描述状态变化,不执行 DOM 操作、请求和日志副作用;
  • Action 使用 TypeScript 联合类型,使非法 action 在编译期更容易被发现。

5.3 reducer 必须返回新状态

下面的 reducer 看起来能工作,但实际上破坏了不可变更新:

function badReducer(state: State, action: Action): State {
  if (action.type === "toggled") {
    const task = state.tasks.find((item) => item.id === action.id);

    if (task) {
      task.done = !task.done;
    }

    return state;
  }

  return state;
}

它修改了 state.tasks 中的对象,并返回原来的根对象。这样会导致:

  • React 可能因为根引用没有变化而跳过更新;
  • 旧渲染快照中的对象被意外改变;
  • 组件之间共享该对象时产生隐蔽影响;
  • 调试工具无法准确追踪每次状态变化。

正确实现需要沿着被修改的路径创建新引用:

function goodToggle(state: State, id: number): State {
  return {
    ...state,
    tasks: state.tasks.map((task) =>
      task.id === id
        ? { ...task, done: !task.done }
        : task
    ),
  };
}

“不可变”并不表示递归复制整个状态树,而是表示从根到被修改节点的引用必须更新,未变化的子树可以继续共享。


六、useStateuseReducer 如何选择

可以把两者的状态更新分别表示为:

useState

事件处理函数 -> 计算新值 -> setState(newValue)

useReducer

事件处理函数 -> dispatch(action) -> reducer(state, action) -> newState

当状态很简单、更新逻辑很少时,useState 通常更直接:

const [isOpen, setIsOpen] = useState(false);

<button onClick={() => setIsOpen((open) => !open)}>
  切换
</button>

当满足以下条件时,useReducer 更容易保持一致性:

  • 一个状态由多个字段组成;
  • 多种事件会修改相互关联的字段;
  • 更新逻辑需要集中测试;
  • 希望用明确的 action 描述业务事件;
  • 需要让 Context 暴露统一的 statedispatch

useReducer 不会自动带来:

  • 跨组件共享;
  • 服务端持久化;
  • 异步请求管理;
  • 性能优化;
  • 防止 reducer 内的可变操作。

例如,下面的 reducer 仍然是错误的:

function reducer(state: State, action: Action): State {
  fetch("/api/log"); // 副作用
  return state;
}

异步请求应当在事件处理函数、Effect、数据请求库或框架的服务端能力中完成,成功或失败后再 dispatch 结果 action:

dispatch({ type: "saveStarted" });

// 请求完成后
dispatch({ type: "saveSucceeded", data });

// 请求失败后
dispatch({ type: "saveFailed", message });

reducer 只负责把这些事件转换为确定的状态。


七、惰性初始化与重置状态

useReducer 支持第三个参数 init,用于从初始参数计算初始状态:

type FormState = {
  username: string;
  valid: boolean;
};

type FormAction =
  | { type: "changed"; username: string }
  | { type: "reset" };

const initialUsername = "guest";

function init(username: string): FormState {
  return {
    username,
    valid: username.length >= 3,
  };
}

function formReducer(
  state: FormState,
  action: FormAction
): FormState {
  switch (action.type) {
    case "changed":
      return {
        username: action.username,
        valid: action.username.length >= 3,
      };

    case "reset":
      return init(initialUsername);
  }
}

function Form() {
  const [state, dispatch] = useReducer(
    formReducer,
    initialUsername,
    init
  );

  return (
    <>
      <input
        value={state.username}
        onChange={(event) =>
          dispatch({
            type: "changed",
            username: event.target.value,
          })
        }
      />

      <button onClick={() => dispatch({ type: "reset" })}>
        重置
      </button>

      {!state.valid && <p>用户名至少需要 3 个字符</p>}
    </>
  );
}

这里第三个参数的意义是:

initialState = init(initialArg);

它适合:

  • 初始数据需要转换;
  • 初始数据计算成本较高;
  • 初始化逻辑需要在初始化和重置时复用。

如果 initialArg 来源于 props,必须明确区分“初始化一次”和“props 改变后重置”。useStateuseReducer 的初始参数不会在每次 props 改变时自动重新初始化:

function Editor({ documentId }: { documentId: string }) {
  const [draft, setDraft] = useState(() => loadDraft(documentId));

  // documentId 改变不会自动让 draft 重新初始化
  return <div>{draft}</div>;
}

如果确实要在 ID 改变时重置,应该显式处理,或者使用不同的 key 让 React 创建新的组件实例:

<Editor key={documentId} documentId={documentId} />

使用 key 会重置该组件及其子树的 state,这是组件身份变化的结果,不是普通 state 更新。


八、状态快照与事件中的旧值:常见失败模式

8.1 依赖旧状态时使用直接值

错误:

function addItem(item: Item) {
  setItems([...items, item]);
}

如果同一事件或异步流程中多次调用,items 可能来自同一个旧快照,后一次更新会覆盖前一次。

更稳妥的写法:

function addItem(item: Item) {
  setItems((current) => [...current, item]);
}

形式上,直接值更新是:

Sn+1=VS_{n+1} = V

函数式更新是:

Sn+1=F(Sn)S_{n+1} = F(S_n)

当新状态依赖旧状态时,应使用后者。

8.2 把 state setter 当作同步赋值

错误:

setQuery(nextQuery);
performSearch(query);

这里的 query 仍然是当前渲染快照中的值。如果搜索操作必须使用新输入,应直接使用 nextQuery

setQuery(nextQuery);
performSearch(nextQuery);

如果搜索依赖“提交后的 state”,则可以在 Effect 中响应 query 变化,但必须正确处理依赖和请求竞态:

useEffect(() => {
  // query 改变后执行同步外部系统的逻辑
}, [query]);

Effect 不是为了让 setter 变成同步函数,而是为了在渲染提交后同步外部系统。

8.3 把派生数据重复存入 state

不必要的设计:

const [items, setItems] = useState<Item[]>([]);
const [completedCount, setCompletedCount] = useState(0);

如果 completedCount 完全由 items 决定,就存在两个需要同步的事实来源。删除、批量修改或异步更新时,很容易漏更新计数。

更合理:

const completedCount = items.filter((item) => item.done).length;

只有当派生过程确实昂贵,或者它代表独立的业务事实时,才考虑缓存或单独保存。useMemo 可以优化计算,但不会改变状态快照语义。


九、useReducer 的测试与错误边界

纯 reducer 的一个重要价值是可以脱离 React 单独测试:

const state = {
  tasks: [{ id: 1, title: "阅读", done: false }],
  filter: "all" as const,
};

const nextState = reducer(state, {
  type: "toggled",
  id: 1,
});

console.assert(nextState.tasks[0].done === true);
console.assert(state.tasks[0].done === false);

这个测试验证了两个事实:

  1. action 被正确解释为“切换任务”;
  2. 原状态没有被修改。

对于不存在的 ID,reducer 通常应该保持幂等或安全:

case "toggled":
  return {
    ...state,
    tasks: state.tasks.map((task) =>
      task.id === action.id
        ? { ...task, done: !task.done }
        : task
    ),
  };

如果找不到对应项,map 仍会返回一个新数组。若希望完全保持引用以便跳过无意义更新,可以先检查是否存在目标项:

case "toggled": {
  const exists = state.tasks.some((task) => task.id === action.id);
  if (!exists) return state;

  return {
    ...state,
    tasks: state.tasks.map((task) =>
      task.id === action.id
        ? { ...task, done: !task.done }
        : task
    ),
  };
}

这里返回旧 state 是有意的:既然 action 没有产生状态变化,就可以让 React 利用引用相等性跳过不必要工作。

错误处理也应成为状态模型的一部分,而不是在 reducer 中抛出随机副作用。例如请求状态可以建模为:

type RequestState<T> =
  | { status: "idle"; data: T | null; error: null }
  | { status: "pending"; data: T | null; error: null }
  | { status: "success"; data: T; error: null }
  | { status: "error"; data: T | null; error: string };

然后通过 startedsucceededfailed 等 action 描述状态转移。这样 UI 可以根据 status 明确决定显示加载、数据或错误,而不是从多个布尔值推断出互相矛盾的组合,例如:

{
  isLoading: true,
  hasError: true,
  data: null
}

状态联合类型通常比多个彼此独立的标志位更能约束非法状态。


十、客户端与服务端边界

10.1 状态 Hook 需要客户端组件

在 React Server Components 模型中,服务端组件不能使用依赖客户端交互的 Hook,例如:

// 服务端组件中不能这样使用
const [count, setCount] = useState(0);

需要交互状态的组件应标记为客户端组件:

"use client";

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((current) => current + 1)}>
      {count}
    </button>
  );
}

"use client" 是模块边界声明,不是普通运行时函数。它告诉支持该模型的框架:该文件及其客户端依赖需要发送到浏览器,并可以使用事件处理函数和客户端 Hook。

常见边界规则包括:

  • 服务端组件可以渲染客户端组件;
  • 传给客户端组件的 props 通常需要可序列化;
  • 不能把服务端组件中定义的普通函数随意作为客户端事件处理器传递;
  • windowdocumentlocalStorage 等浏览器对象只能在客户端代码的合适生命周期中使用;
  • 服务端渲染产生的初始 HTML 不等于客户端 state 已经可以在服务端持续修改。

10.2 SSR 与 hydration

服务端渲染时,React 可以先生成 HTML;客户端随后进行 hydration,使已有 DOM 与客户端组件逻辑接管连接。

因此,初始渲染必须尽量保持服务端和客户端一致。下面这种写法有潜在问题:

"use client";

import { useState } from "react";

export default function Theme() {
  const [theme] = useState(
    localStorage.getItem("theme") ?? "light"
  );

  return <div data-theme={theme}>内容</div>;
}

如果代码参与服务端预渲染,服务端没有 localStorage,会直接报错;即使绕过报错,服务端和客户端初始值不同,也可能造成 hydration 不匹配。

应根据框架能力选择:

  • 在客户端初始化后读取浏览器存储;
  • 在服务端读取可传递的初始数据,再交给客户端组件;
  • 使用框架提供的客户端数据边界;
  • 确保服务端和客户端首屏采用相同初始值。

例如:

"use client";

import { useEffect, useState } from "react";

export default function Theme() {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  useEffect(() => {
    const saved = window.localStorage.getItem("theme");

    if (saved === "light" || saved === "dark") {
      setTheme(saved);
    }
  }, []);

  return <div data-theme={theme}>内容</div>;
}

这会先以稳定的 "light" 渲染,再在客户端 Effect 中同步浏览器存储。它避免了服务端直接访问浏览器 API,但会带来一次客户端状态切换。是否接受这次切换,取决于页面对首屏主题一致性的要求。


十一、并发渲染下为什么纯函数更重要

现代 React 可以将渲染工作拆分,并根据优先级延后某些更新。一个渲染过程可能:

  1. 开始计算新 UI;
  2. 被更高优先级更新打断;
  3. 丢弃尚未提交的结果;
  4. 重新计算;
  5. 最后只提交最终结果。

因此,不能把“渲染函数执行一次”当作可靠的副作用时机,也不能在 reducer 中依赖执行次数:

function reducer(state: State, action: Action): State {
  console.log("写入数据库"); // 错误
  return nextState;
}

如果 reducer 在开发 Strict Mode 或并发调度下被执行多次,数据库可能被重复写入。正确边界是:

  • reducer:纯计算;
  • 事件处理函数:处理由用户操作直接触发的命令;
  • Effect:在提交后同步外部系统;
  • 数据请求框架或服务端能力:负责加载、缓存、重试和竞态控制。

这不是风格问题,而是 React 能够安全重试和重新安排渲染的必要条件。


十二、诊断状态问题的路径

遇到状态更新异常时,可以按状态转移顺序检查:

第一步:确认读取的是哪个快照

在事件处理函数、Promise 或定时器中记录值:

console.log("handler snapshot", count);

如果它来自旧闭包,说明问题是快照语义,不是 React 丢数据。

第二步:确认更新是否依赖旧状态

如果存在:

setValue(value + 1);

并且同一流程可能连续提交更新,应改为:

setValue((current) => current + 1);

第三步:确认对象或数组是否被原地修改

检查是否出现:

object.field = value;
array.push(value);
array.sort();

这些操作会修改旧引用。应复制受影响的数据路径。

第四步:确认是否错误地保存了派生状态

如果一个字段可以由另一个字段计算出来,检查两者是否在所有 action 中都保持同步。不能同步时,删除重复 state,改为渲染期间推导。

第五步:确认 reducer 是否纯且 action 完整

检查:

  • reducer 是否发请求、写缓存或修改全局对象;
  • action 是否包含完成一次状态转移所需的全部信息;
  • switch 是否覆盖了所有 action;
  • 未产生变化时是否可以安全返回原 state。

第六步:确认是否把客户端逻辑放到了服务端边界

检查:

  • 文件是否需要 "use client"
  • 是否在服务端执行了浏览器 API;
  • props 是否可序列化;
  • 服务端和客户端初始状态是否一致。

结语:用“快照、队列、转移”理解 React State

React state 可以用三个词概括:

  • 快照:一次渲染中的 state 是固定值,事件处理函数通过闭包读取该次渲染的值;
  • 队列:setter 和 dispatch 提交更新,React 按顺序处理,并通常对同一范围内的更新进行批处理;
  • 转移:直接值更新是替换,函数式更新是基于前一状态计算,reducer 则把这种计算集中表示为 reducer(state, action)

因此:

setCount(count + 1);

表达的是“把状态替换为当前快照加一”;而:

setCount((current) => current + 1);

表达的是“无论队列处理到哪里,都基于当时的状态加一”。

useState 适合简单、局部的状态;useReducer 适合事件种类多、字段关联强、需要集中测试的状态转移。两者都建立在相同的 React 模型上:状态不可直接修改,渲染必须纯净,外部系统必须放在明确的客户端或副作用边界中。理解这些约束后,批处理、异步回调、并发渲染以及服务端边界都不再是互相孤立的例外,而是同一套状态快照模型的自然结果。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。