在 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 重试这次渲染,可能发生以下情况:
setThings的 updater 被多次执行。- 每次执行都会再次调用
setSelectedThingId。 - 如果
useStoredState会同步写入localStorage或sessionStorage,还会造成重复写入。 - 即使某次渲染最终没有提交,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、调整状态所有权,或提前计算能够独立生成的数据。
备注:内容仅供参考。
