Skip to main content

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

7 min read
why-factory-ai-banned-useeffect

前幾天在 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]);

看起來很合理?firstNamelastName 變了,就更新 fullName

但問題是,fullName 根本不需要是一個 state。它完全可以從現有的 state 推導出來:

const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); // 直接算就好了,不需要 state,不需要 effect const fullName = firstName + ' ' + lastName;

每次 render 的時候 fullName 都會重新計算,而且因為 firstNamelastName 變了本來就會觸發 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。

所謂的「外部系統」是指:

  • 瀏覽器 APIaddEventListenerIntersectionObserverResizeObserver
  • 第三方服務: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」這個標題確實有點標題黨 (╯°□°)╯︵ ┻━┻

但拋開標題不看,他們的論點其實非常紮實:

  1. 大部分的 useEffect 都是 derived state 的問題——直接算就好
  2. 使用者操作應該在 event handler 處理——不要繞路
  3. 資料抓取交給專門的 library——別再手寫 fetch + useEffect 了
  4. 需要重設 state 的時候用 key prop——讓 React 幫你管
  5. 真的需要 effect 的場景,用語意更明確的 custom hook 包起來——降低認知負擔
  6. 父層負責生命週期,子層假設前置條件已滿足——條件式 mounting
  7. 嚴格的規則對 AI 生成的 code 特別有效——約束即品質

我自己不會在所有專案都禁用 useEffect,但我覺得養成「先想想是不是真的需要 effect」的習慣很重要。每次要寫 useEffect 之前,先問自己:

  • 這個值可以直接算出來嗎?→ 不需要 effect,直接 derive
  • 這是在回應使用者的操作嗎?→ 用 event handler
  • 這是在 props 改變時重設 state 嗎?→ 用 key prop
  • 這是在做資料抓取嗎?→ 用 TanStack Query 或 SWR
  • 這個 component 是不是不應該在某些條件下被 render?→ 條件式 mounting
  • 上面都不是,真的需要跟外部系統同步?→ OK,用 useMountEffect,包成 custom hook

如果每個人都這樣想一遍再寫,我覺得世界上大概可以少掉 80% 的 useEffect ヽ(✿゚▽゚)ノ


參考資源