技術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

分享

分享這篇文章。