為什麼 Factory AI 禁用了 useEffect?五個你可能也在犯的 React anti-patterns

前幾天在 X 上看到 Factory AI 的 Alvin Sng 發了一篇文章叫 "Why we banned React's useEffect",一個多禮拜就破百萬瀏覽了,留言也是炸鍋,有人覺得太激進,有人覺得早該這樣做。
我看完之後覺得蠻有共鳴的,因為自己也踩過不少 useEffect 的坑,所以想整理一下他們的想法,順便聊聊那些常見的 anti-patterns。
1. Factory AI 到底在講什麼
Factory AI 的前端團隊有一條很簡單的規則:不准直接用 useEffect。
聽起來很極端對吧?但他們不是說 effect 這個概念不好,而是說大部分人用 useEffect 的方式是錯的。他們在 production 踩過太多次坑了——race condition、infinite loop、莫名其妙的 re-render——最後決定乾脆從源頭禁掉。
他們的核心論點是:useEffect 把原本明確的事件驅動邏輯,變成了隱式的同步邏輯。dependency array 表面上看起來很宣告式,但實際上它把元件之間的耦合藏起來了。你沒辦法從 dependency array 看出「為什麼這段 code 要跑」,你只能看到「什麼東西變了它就會跑」。
結果就是,debug 的過程從「追蹤事件流」變成了「猜測:這個 effect 為什麼跑了?」。小小的重構就可能觸發脆弱的行為,而且這種問題不是一次性爆掉,是慢性退化——效能慢慢變差、行為慢慢變怪、flaky test 慢慢變多,你根本不知道什麼時候壞的。
如果你真的需要在 mount 的時候跟外部系統同步(比如 WebSocket 連線、DOM 事件監聽),他們有一個自己包的 useMountEffect() hook,只在 mount 的時候跑一次。除此之外?不准用。
後來有人在推文底下請 Alvin 分享具體的 lint rule 跟 agent 設定,他大方地丟了一個 gist 出來,裡面有完整的替代模式、smell test、甚至連 component 的結構慣例都寫好了。接下來我就根據原文跟這份 gist 一起整理。
2. 那些年我們一起犯過的 useEffect 錯誤
Factory 的 gist 裡面把替代方案整理成五條規則,每條都附了一個 smell test——就是一個快速判斷「你是不是正在犯這個錯」的嗅覺測試。我覺得這個 smell test 的概念很實用,整理如下。
(我也寫過這些 anti-pattern,不要覺得丟臉 (´・ω・`))
Rule 1:Derive state,不要 sync state
這大概是最經典的一個:
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);看起來很合理?firstName 或 lastName 變了,就更新 fullName。
但問題是,fullName 根本不需要是一個 state。它完全可以從現有的 state 推導出來:
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// 直接算就好了,不需要 state,不需要 effect
const fullName = firstName + ' ' + lastName;每次 render 的時候 fullName 都會重新計算,而且因為 firstName 跟 lastName 變了本來就會觸發 re-render,所以 fullName 永遠是最新的。
用 useEffect 的版本反而多了一次不必要的 re-render:先 render 一次(firstName 變了),然後 effect 跑完又 setFullName,又 render 一次。兩次 render 做一次的事。
Smell test:你正要寫 useEffect(() => setX(deriveFromY(y)), [y]),或是你有一個 state 只是在鏡像(mirror)另一個 state 或 props。
Rule 1.5:用 useMemo 處理昂貴的計算
const [searchTerm, setSearchTerm] = useState('');
const [matchingTodos, setMatchingTodos] = useState([]);
useEffect(() => {
setMatchingTodos(
todos.filter(todo => todo.title.includes(searchTerm))
);
}, [searchTerm, todos]);跟上面一樣的問題——matchingTodos 是 derived state。但這次 filter 可能比較貴,每次 render 都跑會不會有效能問題?
用 useMemo 就好:
const matchingTodos = useMemo(
() => todos.filter(todo => todo.title.includes(searchTerm)),
[todos, searchTerm]
);useMemo 只有在 dependency 變的時候才會重新計算,而且不會觸發額外的 re-render。比 useEffect + setState 少一次 render cycle。
(補充:Rule 1 跟 1.5 本質上是同一件事——derived state。差別只在計算的成本。便宜的直接算,貴的用 useMemo 包。)
Rule 2:資料抓取交給專門的 library
這個大概是每個 React 初學者都寫過的:
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/todos/${id}`)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, [id]);看起來沒什麼問題?問題可多了:
id快速切換的時候,先發的請求可能比後發的晚回來(race condition)- 沒有 cache,同樣的資料每次都重新抓
- 沒有 loading / error 的統一處理
- 沒有 cleanup(component unmount 之後 setState 會噴 warning)
用 data fetching library 就好:
// TanStack Query
const { data, isLoading } = useQuery({
queryKey: ['todos', id],
queryFn: () => fetch(`/api/todos/${id}`).then(res => res.json()),
});或是用 SWR:
const { data, isLoading } = useSWR(`/api/todos/${id}`, fetcher);這些 library 幫你處理了 cache、race condition、retry、deduplication... 你手寫 useEffect 永遠不可能寫得比它們好。
Smell test:你的 effect 裡面有 fetch(...) 然後 setState(...),或是你正在手動實作 cache、retry、cancellation、stale handling。
Rule 3:使用者操作放在 event handler,不要繞路
const [isSelected, setIsSelected] = useState(false);
useEffect(() => {
if (!isSelected) return;
toast.success('已選取');
}, [isSelected]);使用者點了某個東西 → 改了 state → effect 偵測到 state 變了 → 顯示 toast。
繞了一大圈,為什麼不直接在 event handler 裡面做?
function handleSelect() {
setIsSelected(true);
toast.success('已選取');
}簡單、直覺、沒有間接性。你一看就知道使用者點了之後會發生什麼事。
Factory 的 gist 裡面有一個更極端的例子:
// ❌ 用 state flag 驅動 effect
function LikeButton() {
const [liked, setLiked] = useState(false);
useEffect(() => {
if (liked) {
postLike();
setLiked(false); // 重設 flag
}
}, [liked]);
return <button onClick={() => setLiked(true)}>Like</button>;
}
// ✅ 直接在 handler 做
function LikeButton() {
return <button onClick={() => postLike()}>Like</button>;
}上面那個 set flag → effect 偵測 → 做事 → reset flag 的模式,根本就是自己在 React 裡面手刻了一個 event system,何必呢 (´;ω;`)
Smell test:你用 state 當作 flag 讓 effect 去做真正的事,或是你在寫「set flag → effect runs → reset flag」的迴路。
Rule 4:一次性的外部系統同步用 useMountEffect
講了這麼多「不要用」的情境,那什麼時候才是真正需要 effect 的場景?
React 官方文件給了一個很好的判斷基準:
如果沒有涉及外部系統,你通常就不需要 Effect。
所謂的「外部系統」是指:
- 瀏覽器 API:
addEventListener、IntersectionObserver、ResizeObserver - 第三方服務:WebSocket 連線、analytics SDK
- 非 React 管理的 DOM:地圖套件、影片播放器
Factory 的做法是用 useMountEffect 取代 useEffect(..., []):
// ✅ 瀏覽器 API 訂閱
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useMountEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
return size;
}還有一種常見場景是 singleton 的事件訂閱,比如從 context 拿到的 connection manager:
// ❌ dependency 其實永遠不會變,但 ESLint 會叫你加
useEffect(() => {
connectionManager.on('connected', handleConnect);
return () => connectionManager.off('connected', handleConnect);
}, [connectionManager]);
// ✅ 用 useMountEffect,語意更明確
useMountEffect(() => {
connectionManager.on('connected', handleConnect);
return () => connectionManager.off('connected', handleConnect);
});Smell test:你在跟外部系統做同步,而且行為天生就是「mount 時 setup、unmount 時 cleanup」。
Rule 5:用 key 重設,不要用 dependency 編舞
這條規則跟前面的 Anti-pattern 3 類似,但 Factory 的 gist 給了一個更完整的模式——key + useMountEffect 的組合:
// ❌ 用 dependency array 監聽 videoId 變化
function VideoPlayer({ videoId }: { videoId: string }) {
useEffect(() => {
loadVideo(videoId);
}, [videoId]);
}
// ✅ 用 key 讓 React 幫你 remount
function VideoPlayerWrapper({ videoId }: { videoId: string }) {
return <VideoPlayer key={videoId} videoId={videoId} />;
}
function VideoPlayer({ videoId }: { videoId: string }) {
useMountEffect(() => {
loadVideo(videoId);
});
}當 videoId 變了,key 跟著變,React 會 unmount 舊的 VideoPlayer 然後 mount 一個全新的。useMountEffect 自然就會跑一次,不需要 dependency array 來追蹤。
Smell test:你寫了一個 effect,唯一的工作就是在某個 ID 或 prop 改變時重設 local state,或是你想讓 component 在每個 entity 都表現得像全新的 instance。
3. Factory 的 useMountEffect 與架構哲學
Factory AI 沒有完全消滅 effect,而是把它包成一個意圖更明確的 hook:
export function useMountEffect(effect: () => void | (() => void)) {
/* eslint-disable no-restricted-syntax */
useEffect(effect, []);
}是的,本質上就是一個 useEffect(..., []),但它的價值在於命名:
- 看到
useMountEffect,你馬上知道這段程式碼只在 mount 的時候跑一次 - 看到
useEffect,你要先去看 dependency array,然後在腦子裡推演什麼時候會跑 - ESLint 不會再對著空的 dependency array 一直叫
而且 useMountEffect 還有一個很重要的特性:它的失敗模式是 binary 的。要嘛 mount 的時候成功了,要嘛失敗了,非常明確。反觀 useEffect 的失敗模式是慢性退化的——效能慢慢變差、render 次數慢慢變多、偶爾出現 flaky behavior,你很難定位到底是哪個 effect 出了問題。
條件式 mounting
但光有 useMountEffect 還不夠,Factory 更進一步的做法是條件式 mounting:與其在 effect 裡面加 guard,不如讓 component 在前置條件滿足之後才被 mount。
// ❌ 在 effect 裡面加 guard
function ChatRoom({ roomId }: { roomId: string | null }) {
useMountEffect(() => {
if (!roomId) return;
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect();
});
}
// ✅ 條件式 mounting:前置條件不滿足就不要 render 這個 component
function Parent() {
if (!roomId) return <Placeholder />;
return <ChatRoom roomId={roomId} />;
}
function ChatRoom({ roomId }: { roomId: string }) {
// roomId 一定有值,不用 guard
useMountEffect(() => {
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect();
});
}這衍生出一個更大的架構原則:父層負責生命週期的編排(orchestration),子層假設所有前置條件已經被滿足。
這樣做的好處是 component tree 會變得更簡單——每個 component 都不用擔心「我拿到的資料是不是有可能是 null」,因為父層已經幫你過濾好了。減少防禦性程式碼,也減少了 effect 的數量。
4. 怎麼在你的專案落地
看到這裡你可能會想:「好,我被說服了,但具體要怎麼做?」Factory 的 gist 裡面有兩個很實用的東西。
ESLint 設定
他們用的是 no-restricted-syntax 這條 ESLint rule 來擋 useEffect:
{
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.name='useEffect']",
"message": "useEffect is banned. Use derived state, event handlers, data-fetching libraries, or useMountEffect instead. See: https://gist.github.com/alvinsng/5dd68c6ece355dbdbd65340ec2927b1d"
}
]
}
}然後在 useMountEffect 的實作裡面用 /* eslint-disable no-restricted-syntax */ 來豁免。這樣整個 codebase 只有 useMountEffect 這一個地方可以合法使用 useEffect。
Component 結構慣例
gist 裡面還定義了 component 內部的程式碼順序:
export function FeatureComponent({ featureId }: ComponentProps) {
// 1. Hooks 放最前面
const { data, isLoading } = useQueryFeature(featureId);
// 2. Local state
const [isOpen, setIsOpen] = useState(false);
// 3. Computed values(就是 derived state,不是 useEffect + setState)
const displayName = user?.name ?? 'Unknown';
// 4. Event handlers
const handleClick = () => { setIsOpen(true); };
// 5. Early returns
if (isLoading) return <Loading />;
// 6. Render
return <Flex direction="column" gap="lg">...</Flex>;
}注意第 3 步:computed values 放在 hooks 跟 state 之後、event handler 之前。這個順序本身就在暗示你——如果一個值可以算出來,它就應該在這裡,不需要 effect。
5. 為什麼 AI 時代讓這件事更重要了
其實 Factory 分享的那個 gist,它的 description 寫得很清楚:
ACTIVATE when writing React components, refactoring existing useEffect calls, reviewing PRs with useEffect, or when an agent adds useEffect "just in case."
這不只是給人看的規範,這是寫給 AI agent 看的。Factory 的文章最後也提到:他們之所以要嚴格禁用 useEffect,不只是因為人會犯錯,還因為 AI agent 也會犯一樣的錯。
想想看,當你讓 AI 幫你寫 React code 的時候,它最容易生成什麼樣的 code?
// AI 超愛寫這種東西
useEffect(() => {
if (data) {
setProcessedData(transform(data));
}
}, [data]);AI 模型是從大量的開源程式碼學來的,而這些程式碼裡面充斥著 useEffect 的誤用。所以 AI 生出來的 code 也會繼承這些壞習慣。
如果你的 codebase 有一條 lint rule 說「不准用 useEffect」,AI 就被迫要用正確的方式來寫——derive state、用 event handler、用 useMemo。這比你事後 code review 去抓 AI 寫的壞 code 有效率多了。
這個觀點我覺得蠻值得深思的。我們花了很多時間在討論 AI 怎麼寫出更好的 code,但也許更有效的方式是:設計好約束,讓 AI 想寫爛 code 也寫不出來。
6. 我的看法
老實說,「禁用 useEffect」這個標題確實有點標題黨 (╯°□°)╯︵ ┻━┻
但拋開標題不看,他們的論點其實非常紮實:
- 大部分的 useEffect 都是 derived state 的問題——直接算就好
- 使用者操作應該在 event handler 處理——不要繞路
- 資料抓取交給專門的 library——別再手寫 fetch + useEffect 了
- 需要重設 state 的時候用
keyprop——讓 React 幫你管 - 真的需要 effect 的場景,用語意更明確的 custom hook 包起來——降低認知負擔
- 父層負責生命週期,子層假設前置條件已滿足——條件式 mounting
- 嚴格的規則對 AI 生成的 code 特別有效——約束即品質
我自己不會在所有專案都禁用 useEffect,但我覺得養成「先想想是不是真的需要 effect」的習慣很重要。每次要寫 useEffect 之前,先問自己:
- 這個值可以直接算出來嗎?→ 不需要 effect,直接 derive
- 這是在回應使用者的操作嗎?→ 用 event handler
- 這是在 props 改變時重設 state 嗎?→ 用
keyprop - 這是在做資料抓取嗎?→ 用 TanStack Query 或 SWR
- 這個 component 是不是不應該在某些條件下被 render?→ 條件式 mounting
- 上面都不是,真的需要跟外部系統同步?→ OK,用
useMountEffect,包成 custom hook
如果每個人都這樣想一遍再寫,我覺得世界上大概可以少掉 80% 的 useEffect ヽ(✿゚▽゚)ノ