PAYATHON 2026

在 setState callback 中调用 setState 是否安全?

技术老齐

结论

不建议在一个 state 的函数式更新器中调用另一个 setState。这段代码在简单场景下或许能正常运行,但这种写法并不安全,也不可靠。

传给 setThings 的函数并不是更新完成后的 callback,而是 React 用来计算下一个 state 的 updater function。updater 应当保持纯净,只根据参数计算并返回结果,不应调用其他 setter、写入 Storage 或执行其他副作用。

React 可能延迟、重试或重复调用 updater function。在开发环境的 Strict Mode 下,React 还可能调用 updater 两次来检查它是否纯净。即使副作用是幂等的,放在 updater 中也可能引发额外更新,或使两个 state 短暂失去同步。

风险在哪里

问题出在这段代码:

setThings(things => {
  const newId = 1 + Math.max(...things.map(thing => thing.id));

  setSelectedThingId(newId);

  return [
    ...things,
    { id: newId, name: 'New Thing' },
  ];
});

这个函数会在 React 处理 setThings 时执行,业务代码不应依赖它的执行时机和次数。

如果 React 重试这次渲染,可能发生以下情况:

  1. setThings 的 updater 被多次执行。
  2. 每次执行都会再次调用 setSelectedThingId。
  3. 如果 useStoredState 会同步写入 localStorage 或 sessionStorage,还会造成重复写入。
  4. 即使某次渲染最终没有提交,updater 中的副作用也可能已经发生。

多执行一次而结果不变,只能减轻部分影响,无法让这个 updater 重新符合纯函数要求。自动批处理也解决不了这个问题。它只会合并渲染,不保证嵌套 updater 只执行一次。

用一次状态转换计算关联状态

当两个值需要共同满足某项约束时,可以用 useReducer 表达一次完整的业务操作。持久化逻辑仍可分开处理,无需把两个值写入同一个 Storage 项。

import { useEffect, useReducer } from 'react';

const initialState = {
  things: [{ id: 0, name: 'New Thing' }],
  selectedThingId: 0,
};

function reducer(state, action) {
  switch (action.type) {
    case 'addThing': {
      const newId =
        state.things.length === 0
          ? 0
          : Math.max(...state.things.map(thing => thing.id)) + 1;

      return {
        things: [
          ...state.things,
          { id: newId, name: 'New Thing' },
        ],
        selectedThingId: newId,
      };
    }

    case 'deleteSelectedThing': {
      const remainingThings = state.things.filter(
        thing => thing.id !== state.selectedThingId
      );

      return {
        things: remainingThings,
        selectedThingId: remainingThings[0]?.id ?? null,
      };
    }

    default:
      return state;
  }
}

export function App() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { things, selectedThingId } = state;

  useEffect(() => {
    window.localStorage.setItem('things', JSON.stringify(things));
  }, [things]);

  useEffect(() => {
    window.sessionStorage.setItem(
      'selectedThingId',
      JSON.stringify(selectedThingId)
    );
  }, [selectedThingId]);

  const newThing = () => {
    dispatch({ type: 'addThing' });
  };

  const deleteThing = () => {
    dispatch({ type: 'deleteSelectedThing' });
  };

  // remainder omitted
}

这种写法有几个好处:

  • reducer 是纯函数,可以安全地重新执行。
  • things 和 selectedThingId 会在同一次状态转换中计算。
  • 两个值仍可分别写入 localStorage 和 sessionStorage。
  • 删除最后一个元素时,不会访问不存在的 remainingThings[0].id。

初始化时,可以分别读取两个 Storage,再将数据组合成 reducer 的初始状态。如果需要服务端渲染,应避免在服务端直接访问 window。

ID 可以独立生成时

如果新增项目的 ID 不依赖 things 的当前最大值,可以在 updater 外生成唯一 ID,写法会更简单:

const newThing = useCallback(() => {
  const newId = crypto.randomUUID();

  setThings(things => [
    ...things,
    { id: newId, name: 'New Thing' },
  ]);

  setSelectedThingId(newId);
}, [setThings, setSelectedThingId]);

两个 setter 都由事件处理函数直接调用,React 可以对它们进行批处理,同时让 setThings 的 updater 保持纯净。

如果必须按当前最大 ID 加一,就需要处理连续调用、并发更新和空数组。此时把计算放进 reducer 通常更稳妥。

父组件 state 与本地 state

如果其中一个 state 由父组件管理,子组件无法保证父级更新与本地更新构成真正的原子事务。可以让父组件提供一个表示完整业务操作的 callback:

function Child({ onDeleteThing }) {
  const handleDelete = id => {
    onDeleteThing(id);
  };

  // ...
}

如果这个操作还要修改子组件的本地状态,可以让父组件返回计算结果,也可以重新安排状态所有权,把需要保持一致的数据交给同一层处理。

适合使用 effect 的情况

如果第二个值可以从第一个值派生出来,通常可以在渲染时直接计算,不必单独保存:

const selectedThing = things.find(
  thing => thing.id === selectedThingId
);

如果需要在选中项目被删除后自动选择第一项,也可以用 effect 恢复约束:

useEffect(() => {
  const selectionExists = things.some(
    thing => thing.id === selectedThingId
  );

  if (!selectionExists) {
    setSelectedThingId(things[0]?.id ?? null);
  }
}, [things, selectedThingId, setSelectedThingId]);

这种方式会在一次提交后再修正选择状态,因此可能增加一次渲染。如果两个值必须在同一次状态转换中保持一致,使用 reducer 更合适。

其他边界条件

原代码还需要处理两个边界问题。

Math.max(...things.map(thing => thing.id))

当 things 为空时,结果是 -Infinity,随后算出的 ID 仍然是 -Infinity。

remainingThings[0].id

删除最后一个元素后,remainingThings[0] 是 undefined,继续访问 .id 会抛出异常。应当明确没有选中项时使用什么值,例如 null:

const nextSelectedThingId = remainingThings[0]?.id ?? null;

setter 可以在同一个事件处理函数中连续调用,但不应在函数式 updater 内调用另一个 setter。协调多个关联状态时,可以使用 reducer、调整状态所有权,或提前计算能够独立生成的数据。

备注:内容仅供参考。