React Hooks:useEffect 的邊界,哪些邏輯不該寫進去

一個組件裡只要開始出現下面這些現象,基本就說明 useEffect 已經有點失控了:
一堆 effect 在互相傳球,A 改了觸發 B,B 又觸發 C。
明明只是算個列表、拼個字段、響應一個點擊,卻非要先 setState,再等 effect 跑。
依賴數組越來越像玄學,寫全了死循環,少寫了出舊值。
問題的根子通常只有一個:
useEffect 本來是 React 給你的一個「逃生艙」,用來同步外部系統,結果很多人把它當成了組件內部邏輯的總調度室。
這一篇就專門來講清楚這條邊界。
一、先記住一句判斷標準
React 官方對 useEffect 的定位,已經說得很清楚了:
如果這裡沒有「外部系統」參與,那你通常就不需要 Effect。
這裡的「外部系統」包括這些東西:
網路請求。
瀏覽器 API,比如 title、scroll、storage。
DOM 訂閱和第三方庫。
定時器、WebSocket、事件監聽。
反過來說,如果你做的只是這些事,那大概不該用 effect:
根據已有 state/props 計算一個新值。
響應用戶點擊、輸入、提交這樣的事件。
在組件內部同步兩份本來就可以推導的數據。
初始化一個本來就能直接傳給 useState 的默認值。
換句話說,useEffect 不是「組件裡任何在 render 之外運行的代碼」都該去的地方。它應該是 React 和外部世界之間的一條同步通道。
二、最常見的誤用:把「計算屬性」寫進 useEffect
先看一段很典型的代碼:
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 → 再渲染」的雙階段流程。
問題有三個:
filteredUsers 完全可以由 users 和 keyword 推導出來,它不是獨立狀態。
每次都額外多一次渲染,代碼更慢,也更繞。
一旦有別的地方也能改 filteredUsers,數據就容易失真。
重構其實很簡單:
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 都不一定需要,直接在渲染階段計算就行。
const filteredUsers = users.filter(...);這裡真正重要的不是 useMemo,而是一個判斷:
能在渲染階段直接算出來的值,就不要再繞去 effect 裡「補算一次」。
三、第二種誤用:用 useEffect 響應用戶事件
再看一個更隱蔽、但項目裡非常常見的寫法:
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>;
}這類代碼的思路通常是:
點擊提交按鈕。
先把要提交的數據塞進 state。
再讓 effect 監聽這個 state,順便發請求。
看起來像是在「解耦」,其實是在繞路。
React 官方明確建議: 如果某段邏輯是因為「用戶做了某個動作」才發生,那它應該待在事件處理函數裡,而不是放進 effect。
更直接的寫法是:
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>;
}為什麼這樣更好?
因果關係清楚:提交按鈕觸發請求。
不需要額外製造一個中間狀態。
不會出現「某個別的地方改了 jsonToSubmit,結果又觸發提交」的意外副作用。
一句話總結:
「因為組件顯示出來了」而發生的邏輯,適合放 effect;「因為用戶做了某個動作」而發生的邏輯,優先放事件處理器。
這個界線一旦立住,很多 effect 會自動消失。
四、第三種誤用:用 useEffect 同步組件內部狀態
再看一個經典例子:
function Cart({ items }: { items: Item[] }) {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(items.length);
}, [items]);
return <div>共 {count} 件商品</div>;
}這個問題和前面的過濾一樣:count 本質上是 items.length 的別名,不是一個獨立事實。
更合理的寫法:
function Cart({ items }: { items: Item[] }) {
const count = items.length;
return <div>共 {count} 件商品</div>;
}這種「內部狀態同步」最大的問題在於,它很容易長成鏈式更新:
items 變了,effect 去改 count。
count 變了,另一個 effect 再去改 totalPrice。
totalPrice 變了,又有一個 effect 去做別的事。
於是一個原本可以在一行表達式裡完成的數據關係,被拆成了狀態機。
當你看到這種模式時,應該立刻警覺:
這不是狀態管理,這是把數據計算改寫成了副作用。
五、那到底什麼該放進 useEffect?
說完「不該做什麼」,再來講「該做什麼」。 只要記住一句話就夠了:
useEffect 用來讓 React 狀態和外部系統保持同步。
比較標準的場景有這些:
1. 網路請求
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. 訂閱與解綁
useEffect(() => {
const unsubscribe = chat.subscribe(roomId, onMessage);
return unsubscribe;
}, [roomId]);這類代碼非常適合 effect,因為「建立連接」和「銷毀連接」本來就是一對配套動作。
3. 瀏覽器或第三方 API
useEffect(() => {
document.title = `共 ${count} 條消息`;
}, [count]);或者:
useEffect(() => {
const observer = new IntersectionObserver(handleIntersect);
observer.observe(ref.current!);
return () => observer.disconnect();
}, []);這時候你是在同步 DOM / 瀏覽器能力,而不是在組件內部算數據,所以 effect 是合理的。
六、什麼時候該用 useLayoutEffect,而不是 useEffect?
絕大多數場景下,默認用 useEffect 就行。
useLayoutEffect 只適合一種相對特殊的情況:
你要在瀏覽器繪製之前,讀取佈局信息(例如寬度,高度)或立刻改 DOM。
如果等到
useEffect再執行,用戶可能會看到閃動或錯位。
比如:
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
setTooltipPosition(calcPosition(rect));
}, []);Kent C. Dodds 的建議非常直接:99% 的時間用 useEffect,只有涉及佈局測量或必須同步 DOM 外觀變化時,才考慮 useLayoutEffect。
所以團隊規範裡完全可以寫成一句:
默認
useEffect。只有「測量佈局 / 繪製前 DOM 修正」才上
useLayoutEffect。
七、一個完整的重構示例:從一堆 effect 到數據流清晰
看一段更接近真實項目的壞代碼:
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 都只做一件事,但整體上完全是副作用驅動的數據流。
重構後:
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>
</>
);
}這樣改完之後有幾個明顯好處:
數據關係是顯式的:
products -> filtered -> sorted -> count。沒有多餘渲染鏈。
不存在 effect 依賴數組寫錯導致局部失效的問題。
組件讀起來像「數據變換」,而不是「副作用流程圖」。
你會發現,很多所謂的 Hooks 最佳實踐,最後都落到同一個方向上:讓組件更像純函數,讓 effect 只處理那些純函數處理不了的東西。
八、可以直接落地的 useEffect 判斷清單
寫代碼時,準備新加一個 useEffect,先問自己這幾個問題:
這裡有沒有外部系統?
沒有,就先懷疑這個 effect 是否真的需要。
這段邏輯是不是因為「組件顯示出來了」而發生?
如果是因為「用戶點擊了按鈕」,優先放事件裡。
這個值是不是能從現有 props/state 算出來?
能算出來,就不要再建 state,更不要 effect + setState。
這個 effect 裡是否真的需要清理?
訂閱、監聽、定時器、請求競態都需要 cleanup。
這段邏輯如果刪掉 effect,改成 render 裡的表達式或事件處理器,會不會更直接?
大多數時候,答案是:會。
在 Google 上持續關注
把 HeyBinyang 加入 Google 首選來源
如果你希望之後在 Google 上更容易看到我的更新,可以把這個站點加入 preferred source,讓它在相關閱讀情境裡更容易被找到。
SHARE
分享
分享這篇文章。