关于 react-effects
讲解 React 中何时用 `useEffect` 与外部系统同步、何时采用替代方案的开发技能,面向 React 开发者。它帮助识别多余的状态更新与 Effect 链,并说明清理和竞态处理。
主要功能
- 01识别不必要的 `useEffect`
- 02改用渲染时计算和事件处理器
- 03为状态重置与订阅选择替代方案
- 04处理清理逻辑和异步竞态
- 05检查依赖项与 Effect 链
适用场景
- 01审查组件中的 `useEffect` 逻辑
- 02将派生状态从 Effect 中移出
- 03避免 Effect 数据请求中的竞态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ whinc/ super-skills --skill react-effects安装后在 Claude Code 中输入/react-effects确认已加载。
附带文件evals/evals.json
详细介绍
React Effects:何时需要,何时不需要
Effect 是一种逃生舱口,用于将 React 组件与外部系统(浏览器 API、网络、第三方库)同步。大多数组件逻辑不需要 Effect。在编写或保留 useEffect 之前,先检查以下场景——很可能有更简单、性能更好的替代方案。
两个问题
每次使用 useEffect 之前,都要先问自己:
- 这是在为渲染转换数据吗? 如果是,就在渲染期间计算。
- 这是在处理用户事件吗? 如果是,就放到事件处理程序中。
如果两者都不是,你可能确实需要 Effect。
不应该使用 Effect 的场景
1. 从 Props 或 State 派生状态
这是最常见的错误。如果某个值可以根据已有的 props 或 state 计算得出,它就不是真正的状态——而是渲染时计算的值。
为什么 Effect 有害: React 会先用过时的值渲染一次并提交到 DOM,然后 Effect 才会触发第二次 setState,导致又进行一轮完整渲染。用户会短暂看到过时的 UI。
// WRONG: Redundant state + unnecessary Effect
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// RIGHT: Compute during render — zero extra renders, zero extra state
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const fullName = firstName + ' ' + lastName;
识别模式: useEffect 的唯一作用是调用 setSomeState(f(props, state))。
2. 缓存开销较大的计算
如果计算确实开销较大(在生产环境分析中超过 1ms),请使用 useMemo,而不是用 Effect 搭配 state。
// WRONG: Effect + state for caching
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// RIGHT (simple case): Just compute it
const visibleTodos = getFilteredTodos(todos, filter);
// RIGHT (expensive): useMemo skips recomputation when deps haven't changed
const visibleTodos = useMemo(
() => getFilteredTodos(todos, filter),
[todos, filter]
);
怎样才算开销大? 在生产模式下使用 console.time/console.timeEnd。如果记录的耗时持续大于或等于 1ms,就用 memoize。由于额外检查,开发模式下的计时并不可靠。
3. Prop 变化时重置所有状态
当 userId 之类的 prop 发生变化,而你想清除组件的所有状态(表单字段、滚动位置等)时,不要在 Effect 中逐个重置状态——请使用 React 的 key 机制。
为什么 Effect 有害: 组件会先使用过时状态渲染一次(为新用户显示旧评论),然后 Effect 清除状态,导致第二次渲染。每个包含状态的嵌套组件都需要自己的重置 Effect——这既脆弱又容易出错。
// WRONG: Effect to reset state on prop change
function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [userId]);
return /* ... */;
}
// RIGHT: key tells React "this is a different component instance"
function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />;
}
function Profile({ userId }) {
const [comment, setComment] = useState(''); // Auto-resets when key changes
return /* ... */;
}
识别模式: useEffect(() => { setX(initial); setY(initial); ... }, [someProp]) 用于重置多个状态。
4. Prop 变化时调整部分状态
有时你不想重置所有状态,只想调整其中一部分。通常,最好的做法是完全避免使用状态,直接派生出所需的值。
// WRONG: Effect to clear selection when items change
function List({ items }) {
const [selection, setSelection] = useState(null);
useEffect(() => {
setSelection(null);
}, [items]);
return /* ... */;
}
// BETTER: Store the ID, derive the selected object
function List({ items }) {
const [selectedId, setSelectedId] = useState(null);
// If the selected item is still in the list, keep it; otherwise null
const selection = items.find(item => item.id === selectedId) ?? null;
return /* ... */;
}
如果确实必须在渲染期间调整状态(这种情况很少见),可以不使用 Effect,但这种模式应作为最后的手段:
function List({ items }) {
const [prevItems, setPrevItems] = useState(items);
const [selection, setSelection] = useState(null);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
}
5. 在 Effect 中处理特定事件的逻辑
如果代码应该因用户做了某件事而运行(点击按钮、提交表单),它就应该放在事件处理程序中,而不是放在响应状态变化的 Effect 中。
为什么 Effect 有害: 每当被跟踪的状态发生变化时,逻辑都会运行,包括页面加载、导航或其他状态恢复时,而不只是响应用户操作时。
// WRONG: Shows notification whenever product.isInCart becomes true
// (including page refresh, back navigation, etc.)
function ProductPage({ product, addToCart }) {
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
}
// RIGHT: Notification is a direct response to user action
function ProductPage({ product, addToCart }) {
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
}
识别模式: useEffect 中运行了 showNotification、navigate、alert 或其他副作用,而这些副作用由事件处理程序中设置的 [someFlag] 触发。
6. 由用户操作触发的 POST 请求
响应用户操作(提交表单、点击按钮)向服务端发送数据的逻辑应该放在事件处理程序中。只有真正由页面展示触发的请求(例如页面浏览分析)才应该放在 Effect 中。
// WRONG: Roundabout way to send form data
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// RIGHT: Submit directly in the event handler
function handleSubmit(e) {
e.preventDefault();
post('/api/register', { firstName, lastName });
}
// This analytics Effect IS correct — it runs because the component displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
7. Effect 链
多个 Effect 彼此相连,每个 Effect 都设置状态并触发下一个 Effect。这会造成不必要的渲染级联,也让逻辑难以理解。
为什么 Effect 链有害: 链中的每次 setState 都会触发单独的一轮渲染。如果链中有 4 个 Effect,组件就会渲染 5 次,而不是一次。逻辑分散在多个 Effect 中,难以追踪。
// WRONG: Chain of Effects triggering each other
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1);
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
// RIGHT: Derive what you can, compute the rest in the event handler
const isGameOver = round > 5; // Derived, not state
function handlePlaceCard(nextCard) {
if (isGameOver) throw Error('Game already ended.');
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount < 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
识别模式: 存在多个 useEffect Hook,其中一个设置的状态出现在另一个 Hook 的依赖数组中。
8. 应用初始化
每次应用加载时运行一次(而不是每次组件挂载时运行一次)的代码,例如检查身份验证令牌或从 localStorage 加载配置。
// WRONG: Runs twice in StrictMode development, may cause issues
function App() {
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
}
// RIGHT (option A): Module-level guard
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
}
// RIGHT (option B): Module-level execution (runs on import, once)
if (typeof window !== 'undefined') {
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
9. 通知父组件状态变化
在本地状态变化后使用 Effect 调用父组件的回调,会导致额外的渲染周期,并使更新顺序难以预测。
// WRONG: Effect to notify parent — renders twice
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
useEffect(() => {
onChange(isOn);
}, [isOn, onChange]);
function handleClick() {
setIsOn(!isOn);
}
}
// RIGHT: Update child + notify parent in the same event
// React batches both setState calls into a single render
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
setIsOn(nextIsOn);
onChange(nextIsOn); // Parent updates in the same batch
}
function handleClick() {
updateToggle(!isOn);
}
}
// BEST: Fully controlled — no local state at all
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
}
检测模式: useEffect(() => { onSomething(localState); }, [localState, onSomething])。
10. 向父组件传递数据
子组件获取数据,然后使用 Effect 将数据传给父组件。这颠倒了 React 的数据流,也让问题难以追踪。
// WRONG: Child fetches, then pushes data to parent via Effect
function Parent() {
const [data, setData] = useState(null);
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
useEffect(() => {
if (data) onFetched(data);
}, [onFetched, data]);
}
// RIGHT: Parent owns the data fetching, passes data down
function Parent() {
const data = useSomeAPI();
return <Child data={data} />;
}
原则: 在 React 中,数据自上而下流动。如果子组件和父组件都需要数据,应由父组件获取数据并向下传递。
11. 订阅外部存储
订阅在 React 控制范围之外发生变化的浏览器 API 或外部数据源(例如 navigator.onLine、浏览器历史记录、第三方状态库)。
// SUBOPTIMAL: Manual subscription in Effect
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function update() { setIsOnline(navigator.onLine); }
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return isOnline;
}
// RIGHT: useSyncExternalStore — purpose-built for this
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // Client snapshot
() => true // Server snapshot
);
}
12. 获取数据
这是适合使用 Effect 的情况之一——你需要与网络同步。但必须使用清理标记处理竞态条件。
// CORRECT: Effect with ignore flag for race condition handling
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => { ignore = true; };
}, [query, page]);
对于生产应用,最好将数据获取逻辑提取到自定义 Hook 中,或使用 TanStack Query / SWR 等库,以自动处理缓存、请求去重和竞态条件。
快速参考:我需要这个 Effect 吗?
| Effect 的作用 | 替代方案 |
|---|---|
| 根据 props/state 计算值 | 在渲染期间计算(如果计算开销较大,则使用 useMemo) |
| 在 prop 变化时重置所有状态 | 添加 key prop |
| 在 prop 变化时调整部分状态 | 直接推导值,而不是存储它 |
| 在用户点击/提交时运行代码 | 移至事件处理函数 |
| 根据用户操作发送 POST 请求 | 移至事件处理函数 |
| 设置状态并触发另一个 Effect | 合并到同一个事件处理函数中 |
| 初始化应用一次 | 模块级代码或 didInit 守卫 |
在本地 setState 后调用父组件的 onChange | 在同一个事件处理函数中调用 onChange |
| 将数据从子组件传给父组件 | 将数据获取逻辑提升到父组件 |
| 订阅外部数据源 | 使用 useSyncExternalStore |
| 获取数据 | 保留 Effect,但添加清理逻辑;优先使用 TanStack Query |
审查清单
审查或编写 useEffect 时,请确认:
- 必要性: 能否改为在渲染期间计算、使用
useMemo或事件处理函数? - 清理: Effect 是否清理了订阅、计时器或连接?
- 竞态条件: 异步操作是否使用
ignore标记或中止控制器? - 依赖项: 是否列出了所有响应式值?是否没有为 exhaustive-deps 使用
eslint-disable? - 没有链式触发: 在此 Effect 中设置状态是否会触发另一个 Effect?如果会,请合并处理。
- 不是生命周期: 这是否确实是在与外部系统同步,还是伪装成
componentDidMount/componentDidUpdate的思路?