mcpField
R
react-effects

react-effects

github.com/whinc开发40123 次安装4 天前更新
代码仓库

关于 react-effects

讲解 React 中何时用 `useEffect` 与外部系统同步、何时采用替代方案的开发技能,面向 React 开发者。它帮助识别多余的状态更新与 Effect 链,并说明清理和竞态处理。

主要功能

  1. 01识别不必要的 `useEffect`
  2. 02改用渲染时计算和事件处理器
  3. 03为状态重置与订阅选择替代方案
  4. 04处理清理逻辑和异步竞态
  5. 05检查依赖项与 Effect 链

适用场景

  1. 01审查组件中的 `useEffect` 逻辑
  2. 02将派生状态从 Effect 中移出
  3. 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 之前,都要先问自己:

  1. 这是在为渲染转换数据吗? 如果是,就在渲染期间计算。
  2. 这是在处理用户事件吗? 如果是,就放到事件处理程序中。

如果两者都不是,你可能确实需要 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 时,请确认:

  1. 必要性: 能否改为在渲染期间计算、使用 useMemo 或事件处理函数?
  2. 清理: Effect 是否清理了订阅、计时器或连接?
  3. 竞态条件: 异步操作是否使用 ignore 标记或中止控制器?
  4. 依赖项: 是否列出了所有响应式值?是否没有为 exhaustive-deps 使用 eslint-disable?
  5. 没有链式触发: 在此 Effect 中设置状态是否会触发另一个 Effect?如果会,请合并处理。
  6. 不是生命周期: 这是否确实是在与外部系统同步,还是伪装成 componentDidMount/componentDidUpdate 的思路?

评论

支持 Markdown