「App 傳 user 給 Layout,Layout 傳給 Sidebar,Sidebar 傳給 UserProfile…… 但 Layout 和 Sidebar 根本不用 user,它們只是「中間人」。」
這就是 Props Drilling——資料不得不穿過一層又一層它根本不需要的元件。 這篇教你用 Context API 解決這個問題,同時說清楚 Context 的使用邊界, 讓你不會把它用在它不擅長的地方。
Props Drilling 的痛苦
假設你有這樣的元件樹:UserProfile 需要 user 資料,但它藏在樹的很深處。
// 元件樹結構
App
└── Layout ← 不需要 user,但被迫傳遞
└── Sidebar ← 不需要 user,但被迫傳遞
└── UserProfile ← 真正需要 user 的地方
為了讓 UserProfile 拿到 user,你必須讓每一層都接收並往下傳:
Props Drilling 的問題(三個層次)
- 1.中間元件被迫接受它不需要的 props:Layout 和 Sidebar 的 props 介面被污染, 它們根本不在乎 user 是誰,卻必須在型別定義裡聲明。
- 2.改 user 型別時,每一層都要改:假設你在 User 裡加了 avatar 欄位, Layout、Sidebar 的 PropTypes 都要更新,即使它們完全不用 avatar。
- 3.難以重構元件:你想把 UserProfile 移到另一個位置, 但 props chain 斷掉了,整個資料流要重新梳理。
Context 的解法 — 全域的「廣播頻道」
生活類比:Context 像辦公室的內部廣播系統。 HR 透過廣播說「今天提早下班」,所有人都直接聽到, 不需要 HR 親自跑去每個人的座位告知, 也不需要主管把消息一層一層往下傳達。
每個元件就像一個員工——它只需要「打開廣播收聽」(useContext), 就能拿到資料,完全不依賴中間層傳遞。
Context 的使用分三個步驟:建立 Context、提供資料(Provider)、消費資料(useContext)。 加上一個最佳實踐:把 useContext 封裝成自訂 Hook:
有了這個 Context,原本四層都要傳遞 props 的問題消失了。 UserProfile 直接「收聽廣播」就好:
完整 ThemeContext 實戰
Dark/Light mode 切換是最常見的 Context 使用案例,也是學習 Context 最好的入口。 讓我們寫一個完整的 ThemeContext,包含持久化到 localStorage:
任何元件都可以直接使用 useTheme, 完全不需要 props 傳遞:
Tailwind Dark Mode 提醒:使用這個方式時,記得在 tailwind.config 設定darkMode: 'class', 這樣 Tailwind 才會根據 .dark class 套用深色樣式, 而不是根據系統設定。
多個 Context 的組合
真實 app 通常有多個 Context——主題、使用者、購物車…… 它們都需要在 layout 中組合起來。Provider 的巢狀順序有個慣例:
Provider 巢狀順序的邏輯
當一個 Context 的值改變,它的所有 children(包含其他 Provider)都可能重新渲染。 所以慣例是:
- 外層:ThemeContext — 極少變動(用戶切換一次就固定了)
- 中層:UserContext — 登入/登出才變(每個 session 一次)
- 內層:CartContext — 相對頻繁(每次加入商品都會更新)
這樣 Cart 更新時,外層的 Theme 和 User Context 不受影響。
如果 Provider 巢狀太多,可以建立一個 AppProviders 元件來整理:
Context 的效能陷阱 — 什麼時候不該用
這是最重要的 section。很多人把 Context 當 Redux 用,把所有狀態都塞進去, 結果導致整個 app 頻繁、不必要地重新渲染。
Context 的重渲染機制
Context 的 value 只要改變,所有呼叫了 useContext 的元件都會重新渲染, 無論它們用到的是不是那個改變的值。
解法是:依據更新頻率,把 Context 拆分成多個獨立的 Context。
Context 適合 vs. 不適合的場景
| 適合用 Context | 不適合用 Context |
|---|---|
| ✓主題 / 語言切換(極少變動) | ✗高頻更新的 UI 狀態(滾動位置、動畫值) |
| ✓登入用戶資訊(每個 session 一次) | ✗表單輸入值(每個鍵盤按鍵都更新) |
| ✓全域 Modal / Toast 狀態 | ✗動畫 / 過渡狀態(每幀都在變) |
| ✓購物車(加入商品頻率低) | ✗複雜的非同步操作流程 |
進階最佳化:如果你的 Context value 是一個物件,可以搭配 useMemo 防止不必要的重渲染——const value = useMemo(() => ({ user, setUser }), [user])。 物件字面量每次 render 都會產生新的 reference,會導致所有 consumer 重渲染, useMemo 確保只有 user 真的改變時才建立新物件。
Context vs Zustand vs Redux
「既然 Context 有效能問題,我應該直接用 Redux 嗎?」 這個問題很常見。答案是:要看 app 的規模和複雜度。
Context API
優點
- • 零依賴,React 內建
- • 學習成本低
- • 適合簡單的跨元件狀態
- • TypeScript 整合自然
限制
- • 效能需自行最佳化
- • 沒有 DevTools
- • 複雜狀態邏輯難以維護
適合:中小型 app、主題/用戶/語言等低頻全域狀態
Zustand
優點
- • 極輕量(2KB gzip)
- • API 非常直觀
- • 原生支援選擇性訂閱(效能好)
- • 不需要 Provider 包裝
限制
- • 需要額外安裝
- • 生態系比 Redux 小
適合:中大型 app、需要高效能全域狀態管理
Redux Toolkit
優點
- • 完整的 DevTools(時間旅行 debug)
- • 成熟的生態系和社群
- • 嚴格的單向資料流
- • 企業級最佳實踐
限制
- • 學習成本高
- • 樣板代碼多
- • 小專案殺雞用牛刀
適合:大型 app、複雜異步邏輯、多人協作大型團隊
2026 年的建議
先用 Context,複雜了再遷移到 Zustand,不要一上來就用 Redux。
- • 新專案:Context API(零成本,先跑起來)
- • 遇到效能問題或狀態邏輯複雜:遷移到 Zustand(API 差不多,改起來快)
- • 企業大型專案或已有 Redux 基礎:Redux Toolkit(有 DevTools 優勢)
從 Context 遷移到 Zustand 成本很低——因為你已經把邏輯封裝在自訂 Hook 裡了, 只要換掉 Hook 的內部實作,元件完全不用改。這也是為什麼「封裝成自訂 Hook」是重要習慣。
本篇重點整理
- Props Drilling 的痛點:中間元件被迫傳遞不相干的 props,型別改動要多層更新
- Context 三步驟:createContext → Provider(廣播站)→ useContext(收聽)
- 把 useContext 封裝成自訂 Hook(如 useUser、useTheme),確保在 Provider 內使用
- Provider 巢狀:從最不常變的放外層(Theme > User > Cart)
- 效能陷阱:按更新頻率拆分 Context;考慮 useMemo 防止不必要重渲染
- 選型建議:Context → Zustand → Redux,按複雜度遞進,不要過度設計