技术1 阅读

React Hooks:useEffect 的边界,哪些逻辑不该写进去

一个组件里只要开始出现下面这些现象,基本就说明 useEffect 已经有点失控了:

问题的根子通常只有一个:

useEffect 本来是 React 给你的一个“逃生舱”,用来同步外部系统,结果很多人把它当成了组件内部逻辑的总调度室。

这一篇就专门来讲清楚这条边界。

一、先记住一句判断标准

React 官方对 useEffect 的定位,已经说得很清楚了:

如果这里没有“外部系统”参与,那你通常就不需要 Effect。

这里的“外部系统”包括这些东西:

反过来说,如果你做的只是这些事,那大概率不该用 effect:

换句话说,useEffect 不是“组件里任何在 render 之外运行的代码”都该去的地方。它应该是 React 和外部世界之间的一条同步通道。

二、最常见的误用:把“计算属性”写进 useEffect

先看一段很典型的代码:

tsx
function UserList({ users, keyword }: Props) {
  const [filteredUsers, setFilteredUsers] = useState<User[]>([]);

  useEffect(() => {
    const next = users.filter(user =>
      user.name.toLowerCase().includes(keyword.toLowerCase())
    );
    setFilteredUsers(next);
  }, [users, keyword]);

  return (
    <ul>
      {filteredUsers.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

很多人第一次看会觉得这段没毛病。输入变化了,effect 就重新过滤,再把结果存进 state。

但这其实是 React 官方最反对的一类写法之一,因为它把一个纯计算,拆成了“渲染 → effect → setState → 再渲染”的双阶段流程。

问题有三个:

  1. filteredUsers 完全可以由 users 和 keyword 推导出来,它不是独立状态。

  2. 每次都额外多一次渲染,代码更慢,也更绕。

  3. 一旦有别的地方也能改 filteredUsers,数据就容易失真。

重构其实很简单:

tsx
function UserList({ users, keyword }: Props) {
  const filteredUsers = useMemo(() => {
    return users.filter(user =>
      user.name.toLowerCase().includes(keyword.toLowerCase())
    );
  }, [users, keyword]);

  return (
    <ul>
      {filteredUsers.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

如果这个过滤本身不重,甚至连 useMemo 都不一定需要,直接在渲染阶段计算就行。

tsx
const filteredUsers = users.filter(...);

这里真正重要的不是 useMemo,而是一个判断:

能在渲染阶段直接算出来的值,就不要再绕去 effect 里“补算一次”。

三、第二种误用:用 useEffect 响应用户事件

再看一个更隐蔽、但项目里非常常见的写法:

tsx
function Form() {
  const [jsonToSubmit, setJsonToSubmit] = useState<string | null>(null);

  useEffect(() => {
    if (!jsonToSubmit) return;

    fetch('/api/register', {
      method: 'POST',
      body: jsonToSubmit,
    });
  }, [jsonToSubmit]);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const json = JSON.stringify({ hello: 'world' });
    setJsonToSubmit(json);
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

这类代码的思路通常是:

看起来像是在“解耦”,其实是在绕路。

React 官方明确建议: 如果某段逻辑是因为“用户做了某个动作”才发生,那它应该待在事件处理函数里,而不是放进 effect。

更直接的写法是:

tsx
function Form() {
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    const json = JSON.stringify({ hello: 'world' });

    await fetch('/api/register', {
      method: 'POST',
      body: json,
    });
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

为什么这样更好?

一句话总结:

“因为组件显示出来了”而发生的逻辑,适合放 effect;“因为用户做了某个动作”而发生的逻辑,优先放事件处理器。

这个界线一旦立住,很多 effect 会自动消失。

四、第三种误用:用 useEffect 同步组件内部状态

再看一个经典例子:

tsx
function Cart({ items }: { items: Item[] }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(items.length);
  }, [items]);

  return <div>共 {count} 件商品</div>;
}

这个问题和前面的过滤一样:count 本质上是 items.length 的别名,不是一个独立事实。

更合理的写法:

tsx
function Cart({ items }: { items: Item[] }) {
  const count = items.length;
  return <div>共 {count} 件商品</div>;
}

这种“内部状态同步”最大的问题在于,它很容易长成链式更新:

于是一个原本可以在一行表达式里完成的数据关系,被拆成了状态机。

当你看到这种模式时,应该立刻警觉:

这不是状态管理,这是把数据计算改写成了副作用。

五、那到底什么该放进 useEffect?

说完“不该做什么”,再来讲“该做什么”。 只要记住一句话就够了:

useEffect 用来让 React 状态和外部系统保持同步。

比较标准的场景有这些:

1. 网络请求

tsx
useEffect(() => {
  let ignore = false;

  async function load() {
    const result = await fetchUser(userId).then(r => r.json());
    if (!ignore) setUser(result);
  }

  load();

  return () => {
    ignore = true;
  };
}, [userId]);

这里用 effect 是合理的,因为请求就是一个外部副作用,而且 React 官方也专门提醒:如果你在 effect 里请求数据,要处理清理逻辑,避免竞态条件和过期结果覆盖新结果。

2. 订阅与解绑

tsx
useEffect(() => {
  const unsubscribe = chat.subscribe(roomId, onMessage);
  return unsubscribe;
}, [roomId]);

这类代码非常适合 effect,因为“建立连接”和“销毁连接”本来就是一对配套动作。

3. 浏览器或第三方 API

tsx
useEffect(() => {
  document.title = `共 ${count} 条消息`;
}, [count]);

或者:

tsx
useEffect(() => {
  const observer = new IntersectionObserver(handleIntersect);
  observer.observe(ref.current!);

  return () => observer.disconnect();
}, []);

这时候你是在同步 DOM / 浏览器能力,而不是在组件内部算数据,所以 effect 是合理的。

六、什么时候该用 useLayoutEffect,而不是 useEffect?

绝大多数场景下,默认用 useEffect 就行。

useLayoutEffect 只适合一种相对特殊的情况:

比如:

tsx
useLayoutEffect(() => {
  const rect = ref.current?.getBoundingClientRect();
  setTooltipPosition(calcPosition(rect));
}, []);

Kent C. Dodds 的建议非常直接:99% 的时间用 useEffect,只有涉及布局测量或必须同步 DOM 外观变化时,才考虑 useLayoutEffect

所以团队规范里完全可以写成一句:

七、一个完整的重构示例:从一堆 effect 到数据流清晰

看一段更接近真实项目的坏代码:

tsx
function ProductTable({ products, category }: Props) {
  const [filtered, setFiltered] = useState<Product[]>([]);
  const [sorted, setSorted] = useState<Product[]>([]);
  const [count, setCount] = useState(0);

  useEffect(() => {
    setFiltered(
      products.filter(p => p.category === category)
    );
  }, [products, category]);

  useEffect(() => {
    setSorted(
      [...filtered].sort((a, b) => a.price - b.price)
    );
  }, [filtered]);

  useEffect(() => {
    setCount(sorted.length);
  }, [sorted]);

  return (
    <>
      <div>数量:{count}</div>
      <ul>
        {sorted.map(p => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </>
  );
}

这段代码最可怕的地方不是“错”,而是“看起来很有条理”。 每个 effect 都只做一件事,但整体上完全是副作用驱动的数据流。

重构后:

tsx
function ProductTable({ products, category }: Props) {
  const filtered = useMemo(() => {
    return products.filter(p => p.category === category);
  }, [products, category]);

  const sorted = useMemo(() => {
    return [...filtered].sort((a, b) => a.price - b.price);
  }, [filtered]);

  const count = sorted.length;

  return (
    <>
      <div>数量:{count}</div>
      <ul>
        {sorted.map(p => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </>
  );
}

这样改完之后有几个明显好处:

你会发现,很多所谓的 Hooks 最佳实践,最后都落到同一个方向上:让组件更像纯函数,让 effect 只处理那些纯函数处理不了的东西。

八、可以直接落地的 useEffect 判断清单

写代码时,准备新加一个 useEffect,先问自己这几个问题:

  1. 这里有没有外部系统?

    没有,就先怀疑这个 effect 是否真的需要。

  2. 这段逻辑是不是因为“组件显示出来了”而发生?

    如果是因为“用户点击了按钮”,优先放事件里。

  3. 这个值是不是能从现有 props/state 算出来?

    能算出来,就不要再建 state,更不要 effect + setState。

  4. 这个 effect 里是否真的需要清理?

    订阅、监听、定时器、请求竞态都需要 cleanup。

  5. 这段逻辑如果删掉 effect,改成 render 里的表达式或事件处理器,会不会更直接?

    大多数时候,答案是:会。

SHARE

分享

分享这篇文章。