React 透過 State 管理元件的資料和渲染時機,透過 Props 由父元件向子元件傳遞資料。但當元件樹變得複雜時,Props 傳遞會變得繁瑣(Prop Drilling)。本章將學習 Context API 和 useReducer, 它們能幫助我們更優雅地管理跨元件的狀態共享和複雜的狀態邏輯。
本教學使用版本:
React 19+
React Router 7.9.4+
Vite 6.0+
Node.js 20+ LTS
本章將延續前面章節的專案結構,逐步建立多個範例來深入理解 Context 和 useReducer 的使用場景。
Context API:解決 Props 傳遞問題 在開始學習 Context 之前,讓我們先了解它要解決的問題。
Prop Drilling(屬性鑽取) 是指當你需要將資料從父元件傳遞到深層巢狀的子元件時,必須透過中間的每一層元件逐層傳遞 Props, 即使中間層元件並不需要使用這些資料。
graph TD
A["👤 UserProfile 元件<br/>管理 user 資料"]
B["📄 Layout 元件<br/>不使用 user"]
C["📋 Content 元件<br/>不使用 user"]
D["💬 UserInfo 元件<br/>需要 user"]
A -->|"props: user"| B
B -->|"props: user"| C
C -->|"props: user"| D
問題點:
中間層元件(Layout、Content)不需要 user 資料,卻必須傳遞它
如果 Props 結構改變,需要修改所有中間層元件
程式碼冗長且難以維護
Context API 解決方案 Context API 允許父元件向其子樹中的任何元件提供資料,無論層級多深,都不需要透過 Props 逐層傳遞。
graph TD
A["👤 UserProfile 元件<br/>createContext + Provider"]
B["📄 Layout 元件<br/>不感知 Context"]
C["📋 Content 元件<br/>不感知 Context"]
D["💬 UserInfo 元件<br/>useContext 讀取 user"]
A -.->|"Context 直接傳遞"| D
A --> B
B --> C
C --> D
優點:
中間層元件不需要處理 Props
當資料結構變動時,僅需調整 Provider 端與需要讀取資料的元件即可
程式碼更簡潔、可維護性更高
Context 語法說明 Context API 包含三個核心概念:建立 Context 、提供 Context 和 使用 Context 。
第一步:宣告 Context
使用 createContext() 建立一個 Context 物件。通常建議將這個 Context 物件集中存放在 context 資料夾的獨立檔案中(例如 ThemeContext.jsx),而不是直接寫在元件內,這樣可以讓多個元件引用同一份 Context,讓程式碼更清晰易維護:
context/ThemeContext.jsx import { createContext } from 'react' ;const ThemeContext = createContext ();
小技巧:Context 可以提供預設值 createContext() 函數可以接受一個參數作為「預設值」。這個預設值會在 Context 沒有被 Provider 包覆時生效,有助於元件在缺少外層 Provider 的情境下仍能正常運作(如測試或靜態展示元件時)。
context/ThemeContext.jsx import { createContext } from 'react' ;const ThemeContext = createContext ({ theme : 'light' , setTheme : () => {} }); export { ThemeContext };
預設值主要用於「找不到 Provider」時才會觸發,一般應用盡量讓所有使用 Context 的元件都被 Provider 包覆。
第二步:上層元件提供資料(Provider) Context 建立後,必須由上層元件透過 Provider 提供實際的資料值。這是因為應用程式的「狀態」與資料來源,通常會掌握在 App 或其他高層元件。只有上層才知道整個應用狀態需要如何傳遞給下層元件,並能確保所有需要該資料的元件都被包在 Provider 內,才能順利讀取 Context。如果在下層元件或個別元件才設 Provider,其他同層或上層元件就無法存取同一份資料,不利於集中管理與維護。
也就是說,「上層統一管理、下層方便取得」是 Context 的設計原則之一。
App.jsx import { useState } from 'react' ;import { ThemeContext } from './context/ThemeContext' ;import Header from './components/Header' ;import Main from './components/Main' ;import Footer from './components/Footer' ;function App ( ) { const [theme, setTheme] = useState ('light' ); return ( <ThemeContext.Provider value ={{ theme , setTheme }}> <Header /> <Main /> <Footer /> </ThemeContext.Provider > ); }
Provider 角色與使用說明:
Provider 是 Context API 中的「資料提供者」,負責將需要共享的狀態(state)透過 value 屬性傳遞給所有被其包覆的元件。
當 Provider 設在元件樹的頂層時,下層的所有元件(消費者,Consumer)都能直接取用這些共用資料或方法,無需一層層透過 props 傳遞。
這種共享機制能大幅簡化元件間的資料流動,使專案結構更清晰、維護更容易。
第三步:任一子孫元件讀取資料
只有在被高層 Provider 元件包覆的範圍內,所有子元件才會「共用同一份」Context 資料。這些子元件只要透過 useContext() 就能直接取得該 Context 的資料:
components/Header.jsx import { useContext } from 'react' ;import { ThemeContext } from '../context/ThemeContext' ;function Header ( ) { const { theme, setTheme } = useContext (ThemeContext ); return ( <header className ={ `header- ${theme }`}> <button onClick ={() => setTheme(theme === 'light' ? 'dark' : 'light')}> 切換主題 </button > </header > ); }
初始值 vs Provider 的 value Context 的值來源遵循以下優先順序:
有 Provider 時 :使用 Provider 的 value 屬性(優先)
沒有 Provider 時 :使用 createContext() 的初始值(備用)
都沒有時 :得到 undefined
完整範例說明
example-context-value-priority.jsx const ThemeContext = createContext ('light' );function App1 ( ) { return <Header /> ; } function App2 ( ) { return ( <ThemeContext.Provider value ="dark" > <Header /> {/* 讀取到 'dark',不是初始值 'light' */} </ThemeContext.Provider > ); } function App3 ( ) { const [theme, setTheme] = useState ('dark' ); return ( <ThemeContext.Provider value ={theme} > <Header /> {/* 讀取到動態值,會隨 state 變化 */} </ThemeContext.Provider > ); } function Header ( ) { const theme = useContext (ThemeContext ); return <div className ={theme} > 內容</div > ; } const ContextWithoutInit = createContext (); const ContextWithInit = createContext ('light' ); function Component ( ) { const value1 = useContext (ContextWithoutInit ); const value2 = useContext (ContextWithInit ); return <div > {value1} / {value2}</div > ; }
重點整理:
Provider 的 value 永遠優先 :即使設定了初始值,Provider 的 value 會覆蓋它
初始值是備用方案 :只有在沒有 Provider 時才會使用,可防止 undefined 錯誤
實際建議 :複雜的 Context 通常不設定初始值,強制要求必須有 Provider,這樣更安全且明確
完整語法結構 context-syntax.jsx const MyContext = createContext ();<MyContext.Provider value ={contextValue} > <ChildComponent /> </MyContext.Provider > const contextValue = useContext (MyContext );
重要概念:
createContext() 建立 Context 物件
Provider 元件提供資料給子元件樹
useContext() Hook 在子元件中讀取 Context 值
Context 值可以是任何 JavaScript 型別(字串、物件、函數等)
規劃專案結構 讓我們從一個簡單的主題切換範例開始,理解 Context 的基本用法。延續前面的課程,我們將在 lesson03 中建立 Context 相關的範例:
src/ ├── pages/ │ ├── lesson01/ # 第一章:基礎元件 │ ├── lesson02/ # 第二章:Router 教學 │ └── lesson03/ # 🌟 第三章:Context 與 Reducer │ ├── index.jsx # Lesson03 主頁面 │ ├── index.css │ ├── lessonContext.js # 🌟 集中管理本章所有 Context │ └── pages/ │ ├── ThemeExample/ # 主題切換範例 │ │ ├── index.jsx │ │ └── index.css │ ├── MenuExample/ # 巢狀選單範例 │ └── TodoExample/ # Todo List 範例 └── App.jsx
更新路由配置 首先,更新 App.jsx 添加 Lesson03 的路由:
src/App.jsx import { Routes , Route , Navigate } from 'react-router' ;import Layout from './components/Layout' ;import Lesson01 from './pages/lesson01' ;import Lesson02 from './pages/lesson02' ;import Lesson03 from './pages/lesson03' ; export default function App ( ) { return ( <Routes > <Route element ={ <Layout /> }> <Route index element ={ <Navigate to ="/lesson01" replace /> } /> <Route path ="lesson01" element ={ <Lesson01 /> } /> <Route path ="lesson02/*" element ={ <Lesson02 /> } /> <Route path ="lesson03/*" element ={ <Lesson03 /> } /> {/* 🌟 新增 */} </Route > </Routes > ); }
更新 Layout 側邊選單 在 Layout.jsx 中添加 Lesson03 的選單項目:
src/components/Layout.jsx import { Outlet , NavLink } from 'react-router' ;import './Layout.css' ;export default function Layout ( ) { return ( <div className ="layout" > <aside className ="sidebar" > <h2 > 🎓 React 學習系統</h2 > <nav > <ul > <li > <NavLink to ="/lesson01" > 📘 Lesson 01:基礎元件 </NavLink > </li > <li > <NavLink to ="/lesson02" > 📗 Lesson 02:Router 教學 </NavLink > </li > <li > <NavLink to ="/lesson03" > 📙 Lesson 03:Context & Reducer </NavLink > </li > </ul > </nav > </aside > <main className ="content" > <Outlet /> </main > </div > ); }
建立 Lesson03 主頁面 創建 Lesson03 的主頁面和內部路由:
src/pages/lesson03/index.jsx import { Routes , Route , Navigate , Link , Outlet } from 'react-router' ;import './index.css' ;import ThemeExample from './pages/ThemeExample' ;import MenuExample from './pages/MenuExample' ;import TodoExample from './pages/TodoExample' ;export default function Lesson03 ( ) { return ( <div className ="lesson03-container" > {/* 內部導航列 */} <nav className ="lesson03-nav" > <h2 > 📚 Context & Reducer 範例</h2 > <div className ="nav-links" > <Link to ="theme" className ="nav-link" > 🎨 主題切換</Link > <Link to ="menu" className ="nav-link" > 📑 巢狀選單</Link > <Link to ="todo" className ="nav-link" > Todo List</Link > </div > </nav > {/* 子頁面渲染區域 */} <div className ="lesson03-content" > <Routes > <Route index element ={ <Navigate to ="theme" replace /> } /> <Route path ="theme" element ={ <ThemeExample /> } /> <Route path ="menu" element ={ <MenuExample /> } /> <Route path ="todo" element ={ <TodoExample /> } /> </Routes > </div > </div > ); }
src/pages/lesson03/index.css .lesson03-container { width : 100% ; max-width : 1200px ; margin : 0 auto; } .lesson03-nav { background : #f8f9fa ; padding : 1.5rem ; border-radius : 8px ; margin-bottom : 2rem ; h2 { margin : 0 0 1rem 0 ; color : #333 ; } } .nav-links { display : flex; gap : 1rem ; flex-wrap : wrap; } .nav-link { padding : 0.5rem 1rem ; background : white; border : 2px solid #dee2e6 ; border-radius : 6px ; text-decoration : none; color : #495057 ; font-weight : 500 ; transition : all 0.2s ; &:hover { background : #e9ecef ; border-color : #adb5bd ; transform : translateY (-1px ); } } .lesson03-content { background : white; padding : 2rem ; border-radius : 8px ; box-shadow : 0 2px 8px rgba (0 , 0 , 0 , 0.1 ); min-height : 500px ; }
範例:主題切換 本篇範例將用「主題切換」來說明 Context 的基本使用方式與實作步驟。
第一步:建立 Context 集中本章的 Context 到同一個檔案:
src/pages/lesson03/lessonContext.js import { createContext } from 'react' ;export const themes = { light : { name : 'light' , foreground : '#000000' , background : '#eeeeee' , buttonBg : '#ffffff' , buttonBorder : '#cccccc' , }, dark : { name : 'dark' , foreground : '#ffffff' , background : '#222222' , buttonBg : '#333333' , buttonBorder : '#555555' , }, }; export const ThemeContext = createContext (themes.light );export const FontSizeContext = createContext (3 );export const TodoStateContext = createContext (null );export const TodoDispatchContext = createContext (null );
createContext 的預設值:
const ThemeContext = createContext (themes.light );
預設值只在元件沒有被任何 Provider 包覆時 才會使用
通常用於測試或獨立使用元件時
在實際應用中,大多數情況下會被 Provider 的 value 覆蓋
第二步:使用 Provider 提供資料 創建主頁面元件,使用 <ThemeContext.Provider> 提供主題資料:
src/pages/lesson03/pages/ThemeExample/index.jsx import { useState } from 'react' ;import { ThemeContext , themes } from '../../lessonContext' ;import ThemedButton from './ThemedButton' ;import './index.css' ;export default function ThemeExample ( ) { const [currentTheme, setCurrentTheme] = useState (themes.light ); const toggleTheme = ( ) => { setCurrentTheme ((prev ) => prev.name === 'light' ? themes.dark : themes.light ); }; return ( <div className ="theme-example" > <h1 > 🎨 Context API:主題切換範例</h1 > <div className ="example-intro" > <p > 這個範例展示如何使用 Context API 在多層元件中共享主題資料,</p > <p > 中間層元件(Toolbar)不需要處理 Props。</p > </div > {/* 切換主題按鈕 */} <div className ="control-panel" > <button onClick ={toggleTheme} className ="toggle-btn" > 切換到 {currentTheme.name === 'light' ? '深色' : '淺色'} 主題 </button > <p className ="current-theme" > 當前主題:<strong > {currentTheme.name === 'light' ? '淺色' : '深色'}</strong > </p > </div > {/* 🌟 使用 Provider 提供主題資料給子樹 */} <ThemeContext.Provider value ={currentTheme} > <Toolbar /> </ThemeContext.Provider > {/* 說明區域 */} <div className ="explanation" > <h3 > 💡 程式碼說明</h3 > <ol > <li > <code > ThemeContext.Provider</code > 包覆子元件樹</li > <li > <code > value</code > 屬性提供當前主題資料</li > <li > 子樹中的任何元件都可以透過 <code > useContext</code > 讀取主題</li > <li > 中間層元件(Toolbar)不需要處理 Props</li > </ol > </div > </div > ); } function Toolbar ( ) { return ( <div className ="toolbar" > <h3 > 🛠️ Toolbar 元件(中間層)</h3 > <p className ="note" > 這個元件不需要知道 theme 的存在</p > <ThemedButton /> </div > ); }
第三步:使用 useContext 消費資料 創建一個會使用主題資料的按鈕元件:
src/pages/lesson03/pages/ThemeExample/ThemedButton.jsx import { useContext } from 'react' ;import { ThemeContext } from '../../lessonContext' ;export default function ThemedButton ( ) { const theme = useContext (ThemeContext ); return ( <div className ="themed-button-container" > <h4 > 🎨 ThemedButton 元件(存取者)</h4 > <button style ={{ background: theme.buttonBg , color: theme.foreground , border: `2px solid ${theme.buttonBorder }`, padding: '12px 24px ', borderRadius: '6px ', cursor: 'pointer ', fontSize: '16px ', fontWeight: '500 ', transition: 'all 0.3s ', }} > 我是一個使用 {theme.name === 'light' ? '淺色' : '深色'} 主題的按鈕 </button > <p className ="code-hint" > <code > const theme = useContext(ThemeContext)</code > </p > </div > ); }
樣式文件 src/pages/lesson03/pages/ThemeExample/index.css .theme-example { padding : 1rem ; } .example-intro { background : #f0f7ff ; padding : 1rem 1.5rem ; border-left : 4px solid #3b82f6 ; border-radius : 4px ; margin : 1.5rem 0 ; color : #1e40af ; p { margin : 0.5rem 0 ; } } .control-panel { background : #f8f9fa ; padding : 1.5rem ; border-radius : 8px ; margin : 1.5rem 0 ; text-align : center; } .toggle-btn { background : #3b82f6 ; color : white; border : none; padding : 12px 24px ; border-radius : 6px ; font-size : 16px ; font-weight : 600 ; cursor : pointer; transition : all 0.3s ; box-shadow : 0 2px 4px rgba (59 , 130 , 246 , 0.3 ); &:hover { background : #2563eb ; transform : translateY (-2px ); box-shadow : 0 4px 8px rgba (59 , 130 , 246 , 0.4 ); } &:active { transform : translateY (0 ); } } .current-theme { margin-top : 1rem ; font-size : 18px ; color : #495057 ; strong { color : #3b82f6 ; } } .toolbar { background : #ffffff ; border : 2px solid #dee2e6 ; border-radius : 8px ; padding : 1.5rem ; margin : 2rem 0 ; h3 { margin-top : 0 ; color : #495057 ; } } .note { color : #6c757d ; font-style : italic; margin : 0.5rem 0 1rem ; } .themed-button-container { background : #f8f9fa ; padding : 1.5rem ; border-radius : 6px ; border : 1px dashed #adb5bd ; h4 { margin-top : 0 ; color : #495057 ; } } .code-hint { margin-top : 1rem ; padding : 0.5rem ; background : #e9ecef ; border-radius : 4px ; font-family : 'Monaco' , 'Menlo' , monospace; font-size : 14px ; color : #495057 ; } .explanation { background : #fff8e1 ; padding : 1.5rem ; border-radius : 8px ; border-left : 4px solid #ffc107 ; margin-top : 2rem ; h3 { margin-top : 0 ; color : #f57c00 ; } ol { margin : 1rem 0 ; padding-left : 1.5rem ; color : #e65100 ; } li { margin : 0.5rem 0 ; line-height : 1.6 ; } code { background : #ffe082 ; padding : 2px 6px ; border-radius : 3px ; font-family : 'Monaco' , 'Menlo' , monospace; font-size : 14px ; color : #e65100 ; } }
測試運行
訪問 http://localhost:5173/lesson03/theme
點擊「切換主題」按鈕
觀察按鈕樣式的變化
注意 Toolbar 元件不需要處理任何 Props
範例:巢狀選單與動態更新 這個範例展示如何在遞迴元件中使用 Context, 以及如何動態更新 Context 值。
規劃需求 建立一個可展開/收合的巢狀選單,每一層的文字大小會遞減,展示:
如何在遞迴元件中使用 Context
如何在子元件中修改 Context 值(透過 Provider 的巢套)
使用集中 Context:FontSizeContext 本範例直接從集中檔案匯入 FontSizeContext:
src/pages/lesson03/pages/MenuExample/index.jsx import { useState } from 'react' ;import MenuItem from './MenuItem' ;import { FontSizeContext } from '../../lessonContext' ;import './index.css' ;
建立巢狀選單元件 src/pages/lesson03/pages/MenuExample/index.jsx import { useState } from 'react' ;import MenuItem from './MenuItem' ;import { FontSizeContext } from '../../lessonContext' ;import './index.css' ;const menuData = [ { name : 'Menu A' , children : [ { name : 'Menu A-1' }, { name : 'Menu A-2' }, { name : 'Menu A-3' , children : [ { name : 'Menu A-3-I' }, { name : 'Menu A-3-II' }, { name : 'Menu A-3-III' }, ], }, ], }, { name : 'Menu B' , children : [ { name : 'Menu B-1' }, { name : 'Menu B-2' , children : [ { name : 'Menu B-2-I' }, { name : 'Menu B-2-II' }, ], }, { name : 'Menu B-3' }, ], }, { name : 'Menu C' , children : [ { name : 'Menu C-1' }, { name : 'Menu C-2' }, ], }, ]; export default function MenuExample ( ) { return ( <div className ="menu-example" > <h1 > 📑 Context 進階:巢狀選單</h1 > <div className ="example-intro" > <p > 這個範例展示如何在遞迴元件中使用 Context,</p > <p > 每一層的文字大小會自動遞減(3rem → 2rem → 1.33rem → ...)。</p > </div > {/* 🌟 提供初始字體大小 */} <FontSizeContext.Provider value ={3} > <ul className ="menu-list" > {menuData.map((item) => ( <MenuItem key ={item.name} data ={item} /> ))} </ul > </FontSizeContext.Provider > <div className ="explanation" > <h3 > 💡 重點技巧</h3 > <ul > <li > <strong > Context 巢套:</strong > 子元件可以用新的 <code > Provider</code > 覆蓋父層的值 </li > <li > <strong > 遞迴元件:</strong > MenuItem 會渲染自己作為子元件(樹狀結構) </li > <li > <strong > 動態計算:</strong > 每一層讀取當前 Context 值,計算後提供新值給下一層 </li > </ul > <h4 > 📐 字體大小計算:</h4 > <div className ="formula" > <code > 新字體大小 = 當前字體大小 ÷ 1.5</code > </div > </div > ); }
src/pages/lesson03/pages/MenuExample/MenuItem.jsx import { useState, useContext } from 'react' ;import { FontSizeContext } from '../../lessonContext' ;import styles from './MenuItem.module.css' ;export default function MenuItem ({ data } ) { const [isExpanded, setIsExpanded] = useState (true ); const currentFontSize = useContext (FontSizeContext ); const hasChildren = data.children && data.children .length > 0 ; return ( <li > <div className ={styles.menuItem} > {/* 顯示選單名稱,字體大小由 Context 決定 */} <span style ={{ fontSize: `${currentFontSize }rem ` }}> {data.name} </span > {/* 如果有子選單,顯示展開/收合按鈕 */} {hasChildren && ( <button className ={styles.toggleBtn} onClick ={() => setIsExpanded(!isExpanded)} aria-label={isExpanded ? '收合' : '展開'} > {isExpanded ? '−' : '+'} </button > )} </div > {/* 渲染子選單 */} {isExpanded && hasChildren && ( // 🌟 為子選單提供新的字體大小(遞減 1.5 倍) <FontSizeContext.Provider value ={currentFontSize / 1.5 }> <ul className ={styles.submenu} > {data.children.map((child) => ( <MenuItem key ={child.name} data ={child} /> ))} </ul > </FontSizeContext.Provider > )} </li > ); }
CSS Module 樣式 src/pages/lesson03/pages/MenuExample/MenuItem.module.css .menuItem { display : flex; align-items : center; justify-content : space-between; padding : 0.5rem 0 ; cursor : pointer; transition : all 0.2s ; &:hover { color : #3b82f6 ; } } .toggleBtn { display : inline-flex; align-items : center; justify-content : center; border : 2px solid #dee2e6 ; background : white; width : 2rem ; height : 2rem ; border-radius : 4px ; cursor : pointer; font-size : 1.2rem ; font-weight : bold; color : #495057 ; transition : all 0.2s ; margin-left : 1rem ; &:hover { background : #3b82f6 ; color : white; border-color : #3b82f6 ; } &:active { transform : scale (0.95 ); } } .submenu { margin-left : 1.5rem ; padding-left : 1rem ; border-left : 2px solid #e9ecef ; list-style : none; }
src/pages/lesson03/pages/MenuExample/index.css .menu-example { padding : 1rem ; } .example-intro { background : #f0f7ff ; padding : 1rem 1.5rem ; border-left : 4px solid #3b82f6 ; border-radius : 4px ; margin : 1.5rem 0 ; color : #1e40af ; p { margin : 0.5rem 0 ; } } .menu-list { background : white; border : 2px solid #e9ecef ; border-radius : 8px ; padding : 1.5rem ; margin : 2rem 0 ; list-style : none; } .explanation { background : #fff8e1 ; padding : 1.5rem ; border-radius : 8px ; border-left : 4px solid #ffc107 ; margin-top : 2rem ; h3 { margin-top : 0 ; color : #f57c00 ; } ul { margin : 1rem 0 ; color : #e65100 ; line-height : 1.8 ; } li { margin : 0.75rem 0 ; } strong { color : #e65100 ; } code { background : #ffe082 ; padding : 2px 6px ; border-radius : 3px ; font-family : 'Monaco' , 'Menlo' , monospace; font-size : 14px ; color : #e65100 ; } } .formula { background : #ffe082 ; padding : 1rem ; border-radius : 6px ; text-align : center; margin-top : 0.5rem ; code { background : transparent; font-size : 16px ; font-weight : 600 ; } }
Context 巢套的關鍵概念:
example-nested-context.jsx <FontSizeContext .Provider value={3 }> <MenuItem /> {} {} <FontSizeContext .Provider value={2 }> <MenuItem /> {} {} <FontSizeContext .Provider value={1.33 }> <MenuItem /> {} </FontSizeContext .Provider > </FontSizeContext .Provider > </FontSizeContext .Provider >
子層的 Provider 會覆蓋 父層的值
每一層元件讀取到的是最近的 Provider 提供的值
這種模式適合遞迴結構和動態層級的場景
CSS Modules:避免樣式汙染 在上面的範例中,我們使用了 CSS Modules(.module.css)。這是一項重要技術,能讓 React 專案的樣式管理更加安全。在 React 專案中,若直接使用一般的 .css 文件,所有的樣式都會以全域作用域 存在,這會導致樣式汙染問題。
MenuItem.css .toggleBtn { padding : 10px ; background : blue; }
這個 .toggleBtn 樣式會影響整個應用中所有 名為 toggleBtn 的元素,當多個元件使用相同 class 名稱時,就會互相干擾。
也就是說,不同元件若使用相同 class 名稱,樣式會互相干擾,整個應用中的所有同 class 名稱元素都會受到影響。CSS Modules 則能自動為每個 class 名稱加上唯一的 hash 值,做到局部作用域 ,讓每一個元件的樣式彼此隔離、不會互相影響,是 Vite 或 Webpack 預設支援的功能。
CSS Modules 完整實作指南 CSS Modules 是 Vite/Webpack 內建的功能,透過自動為 class 名稱添加唯一的 hash 值,實現局部作用域 ,讓每個元件的樣式完全隔離。
步驟 1:檔案命名
將 .css 改名為 .module.css:
MenuItem.css → MenuItem.module.css todoList.css → todoList.module.css
步驟 2:匯入樣式物件
MenuItem.jsx import './MenuItem.css' ;import styles from './MenuItem.module.css' ;
步驟 3:在元件中使用
MenuItem.jsx export default function MenuItem ( ) { return ( <div className ={styles.toggleBtn} > <button className ={styles.button} > 點我 </button > </div > ); }
步驟 4:撰寫 CSS 樣式
MenuItem.module.css .toggleBtn { padding : 10px ; border : 1px solid #ccc ; } .button { background : blue; color : white; border : none; padding : 8px 16px ; cursor : pointer; } .button :hover { background : darkblue; }
渲染機制解析 CSS Modules 會在編譯時自動為每個 class 添加唯一的 hash 值,確保樣式不會衝突:
編譯前的 HTML(開發時):
<div className={styles.toggleBtn }> <button className ={styles.button} > 點我</button > </div>
編譯後的 HTML(瀏覽器實際渲染):
<div class ="MenuItem_toggleBtn__a7f3k" > <button class ="MenuItem_button__x9m2p" > 點我</button > </div >
對應的 CSS(自動產生):
.MenuItem_toggleBtn__a7f3k { padding : 10px ; border : 1px solid #ccc ; } .MenuItem_button__x9m2p { background : blue; color : white; } .MenuItem_button__x9m2p :hover { background : darkblue; }
最佳實踐與注意事項 CSS Modules 使用建議
儘量全程使用 class selector 來撰寫樣式,不要用元素選擇器或 ID 選擇器避免全域汙染。
class 名稱可採小駝峰(camelCase)或連字符(kebab-case)命名。
善用現代原生巢狀語法,讓程式碼更清楚。
推薦的 CSS Modules Selector 範例:
.btn { }.card-header { }.btn { &:hover { } &:active { } } .card { .title { } .content { } }
避免以下做法(避免樣式汙染):
button { }div { }#myButton { }
常見命名注意事項:
CSS Modules 常見錯誤與解法說明
直接使用字串 className 無效
<button className="toggleBtn" >點我</button> <button className ={styles.toggleBtn} > 點我</button >
忘記 import CSS module
<button className={styles.btn }>點我</button> import styles from './MyComponent.module.css' ;<button className ={styles.btn} > 點我</button >
class 名稱拼錯或沒定義
<button className={styles.nonExistent }>點我</button>
組合多個 class(動態條件時)
<div className={`${styles.card} ${styles.active} ` }> import clsx from 'clsx' ;<div className ={clsx(styles.card, isActive && styles.active )}>
重點總結:
請全程用 class 局部化你的元件樣式。
不要用元素 selector 和 ID。
動態狀態請用物件的方式組合 class。
開發時,仔細檢查 class 與 CSS module 是否正確對應、import 是否遺漏。
CSS Modules vs 其他樣式方案
方案
優點
缺點
適用場景
CSS Modules
自動局部作用域、零配置、支援偽類和媒體查詢
需要 styles. 前綴
中小型專案(推薦)
Inline Styles
完全隔離、動態樣式方便
無法使用偽類、媒體查詢、效能較差
簡單動態樣式
CSS-in-JS
完整 JS 能力、主題化強大、動態樣式靈活
學習成本高、包體積大、執行時開銷
大型應用、複雜主題系統
Tailwind CSS
快速開發、一致性高、工具類豐富
HTML 冗長、學習成本中、需要配置
快速原型、團隊協作
Scoped CSS(Vue)
自動局部作用域、語法簡潔
Vue 專屬、無法跨框架使用
Vue 專案
useReducer:管理複雜狀態 當狀態邏輯變得複雜、需要根據不同動作類型(如新增、刪除、切換)來更新狀態時,使用 useState 會讓程式碼零散且難以維護。useReducer 提供類似 Redux 的「Reducer 模式」,將所有狀態變化集中在一個規則化的函式(reducer)中處理,讓程式邏輯更清楚、更容易追蹤每個動作如何影響狀態,也方便日後的擴充與除錯。
graph TD
A["元件觸發事件"]
B["dispatch(action)"]
C["Reducer 函式"]
D["返回新 state"]
E["React 重新渲染"]
A -->|"呼叫"| B
B -->|"傳遞"| C
C -->|"計算"| D
D -->|"更新"| E
E -.->|"顯示最新狀態"| A
style A fill:#e3f2fd
style C fill:#fff3e0
style E fill:#e8f5e9
useReducer 完整實作指南 核心概念:Reducer、Action、Dispatch useReducer 由三個核心要素組成:
1. Reducer(歸納函式) :純函式,根據舊的 state 與收到的 action 計算並回傳新的 state。
reducer-pattern.jsx function reducer (state, action ) { switch (action.type ) { case 'ACTION_TYPE' : return newState; default : return state; } }
2. Action(動作) :描述「要做什麼行為」的物件,最少要有 type 屬性,代表動作的分類,也能夠帶入其他資料(payload)。
action-pattern.jsx { type : 'ADD_TODO' , payload : { text : '買牛奶' , id : 123 } } { type : 'ADD_TODO' , text : '買牛奶' , id : 123 }
3. Dispatch(派發) :透過 dispatch(action) 來呼叫 reducer,讓指定的 action 進行狀態更新。在元件內呼叫 dispatch,就能依據 action 執行狀態變更:
someoneComponent.jsx dispatch ({ type : 'ADD_TODO' , text : '買牛奶' });
這會把 action 傳給 reducer 處理,reducer 回傳的新 state 會立即更新 React 畫面。
Hook 語法與使用方式 函式簽名:
useReducer-syntax.jsx const [state, dispatch] = useReducer (reducer, initialArg, init?);
參數說明:
reducer:(state, action) => newState 的純函式(必填)
initialArg:初始資料(必填),可為物件、陣列、數值等
init:懶加載初始化函式(可選),簽名 init(initialArg) => initialState
回傳值:
state:目前狀態值(讀取用)
dispatch:派發 action 的函式(更新用)
初始值規則:
無提供 init:initialState = initialArg
有提供 init:initialState = init(initialArg)
基本使用範例:
example-todo-reducer.jsx const ADD = 'ADD_TODO' ;const DELETE = 'DELETE_TODO' ;const TOGGLE = 'TOGGLE_TODO' ;function todoReducer (state, action ) { switch (action.type ) { case ADD : return [ ...state, { id : state.length ? state[state.length - 1 ].id + 1 : 1 , text : action.text , checked : false } ]; case DELETE : return state.filter (item => item.id !== action.id ); case TOGGLE : return state.map (item => item.id === action.id ? { ...item, checked : !item.checked } : item); default : return state; } } const initialState = [ { id : 1 , text : '去健身房' , checked : false }, { id : 2 , text : '繳帳單' , checked : true } ]; export default function TodoExample ( ) { const [list, dispatch] = useReducer (todoReducer, initialState); const handleAdd = (text ) => dispatch ({ type : ADD , text }); const handleDelete = (id ) => dispatch ({ type : DELETE , id }); const handleToggle = (id ) => dispatch ({ type : TOGGLE , id }); return ( <div > {/* 渲染 UI,呼叫上述方法 */}</div > ); }
懶加載初始化(init) init 是 useReducer 的第三個可選參數,用於將 initialArg 轉換成真正的初始狀態。它只在「第一次」建立 state 時呼叫一次,適合用於初始狀態需要昂貴計算、從外部來源推導(如 localStorage、URL 參數),或需要在 reset 時重用初始化邏輯的場景。
初始值規則:
沒有提供 init:初始狀態 = initialArg
有提供 init:初始狀態 = init(initialArg) 的回傳值
example-init-comparison.jsx const [stateA] = useReducer (reducer, 0 );function init (count ) { return { count }; } const [stateB] = useReducer (reducer, 0 , init);
使用範例:
example-init-usage.jsx function init (size ) { return { items : Array .from ({ length : size }, (_, i ) => ({ id : i + 1 })) }; } function reducer (state, action ) { switch (action.type ) { case 'reset' : return init (action.size ); default : return state; } } const [state, dispatch] = useReducer (reducer, 10000 , init);
重要注意事項:
dispatch(action) 只會執行 reducer,不會重新執行 init
只有當 reducer 回傳「新的物件/陣列」時,React 才會重新渲染使用到它的元件
如果在 reducer 內主動呼叫了 init(...),那是你主動做初始化,不是 useReducer 自動重跑
初始狀態很輕量(常數或小物件)時,直接給 initialArg 更簡單,不需要使用 init
常見誤解:為什麼看起來像是 init 又被執行?
dispatch 不會觸發 init。如果你在 reducer 內部的某個分支(例如 reset)主動呼叫了 init(...) 來復用初始化邏輯,那是你「自己呼叫了 init」,不是 useReducer 幫你再次執行初始化。
example-init-timing.jsx function init (n ) { console .log ('init run once' ); return { count : n }; } function reducer (state, action ) { console .log ('reducer run' ); switch (action.type ) { case 'increment' : return { count : state.count + 1 }; case 'reset' : return init (action.to ); default : return state; } } const [state, dispatch] = useReducer (reducer, 0 , init);
useState vs useReducer 當狀態邏輯簡單時,useState 就足夠了;但當狀態更新邏輯複雜、有多種動作型態、多個子元件要共用或協同修改一組狀態時,useReducer 會是更好的選擇。它讓狀態變化集中管理,更容易追蹤每個動作如何影響狀態,也方便日後重構與除錯。
比較項目
useState(單一值/簡單邏輯)
useReducer(多動作/複雜邏輯)
狀態複雜度
低(單一值或少量欄位)
高(多欄位、多種操作)
更新方式
直接 setState
dispatch(action) → reducer 決定更新
邏輯集中度
低(分散在多個 handler)
高(集中在 reducer)
可測試性
一般
佳(純函式 reducer 易測)
向下傳遞
多個 setter 容易凌亂
傳 dispatch 簡潔,適合搭配 Context
使用建議:
使用 useReducer :需要多種動作、集中更新邏輯、較好測試性,或要把更新方法往下傳(搭配 Context)
使用 useState :只有簡單值或少量欄位、更新邏輯單純
對照範例:
example-counter-usestate.jsx import { useState } from 'react' ;export default function Counter ( ) { const [count, setCount] = useState (0 ); return ( <button onClick ={() => setCount((c) => c + 1)}> 點擊次數:{count} </button > ); }
example-counter-usereducer.jsx import { useReducer } from 'react' ;function reducer (state, action ) { switch (action.type ) { case 'increment' : return { count : state.count + 1 }; case 'decrement' : return { count : state.count - 1 }; case 'resetTo' : return { count : action.to }; default : return state; } } export default function CounterReducer ( ) { const [state, dispatch] = useReducer (reducer, { count : 0 }); return ( <div > <p > 次數:{state.count}</p > <button onClick ={() => dispatch({ type: 'decrement' })}>-1</button > <button onClick ={() => dispatch({ type: 'increment' })}>+1</button > <button onClick ={() => dispatch({ type: 'resetTo', to: 0 })}>重設</button > </div > ); }
useReducer 的優勢:
更新邏輯集中在 reducer,事件處理器極度精簡
相同輸入(state + action)得到相同輸出,便於單元測試
可與 Context 搭配,把 dispatch 下放至深層元件,消除 Props Drilling
範例:Todo List 實作 我們將透過一個完整的 Todo List 範例來學習 useReducer。這個範例會先使用 useState 實作基本功能,再逐步重構為 useReducer,讓你能清楚看到兩種方式的差異。
功能需求:
新增待辦事項
刪除待辦事項
切換完成狀態
元件拆分(關注點分離)
專案結構:
src/pages/lesson03/pages/TodoExample/ ├── index.jsx # Todo 主元件 ├── index.css # 主樣式 ├── todoList.module.css # CSS Modules ├── components/ │ ├── TaskAdd.jsx # 新增待辦元件 │ └── TaskList.jsx # 待辦列表元件 └── store/ # 🌟 稍後會建立(useReducer 進階) ├── actions.js └── reducer.js
步驟 1:建立靜態 HTML/CSS 首先,我們先建立靜態版本,確認樣式正確。
src/pages/lesson03/pages/TodoExample/index.jsx import styles from './todoList.module.css' ;export default function TodoExample ( ) { return ( <div className ="todo-example" > <h1 > Todo List:useReducer 範例</h1 > {/* 輸入區域 */} <div className ={styles.header} > <h2 > 我的待辦清單</h2 > <input type ="text" placeholder ="輸入新的待辦事項。.." /> <span className ={styles.addBtn} > 新增</span > </div > {/* 待辦列表 */} <ul className ={styles.todoList} > <li > 去健身房</li > <li className ={styles.checked} > 繳帳單</li > <li > 見 George</li > <li > 買雞蛋</li > <li > 讀一本書</li > </ul > </div > ); }
src/pages/lesson03/pages/TodoExample/todoList.module.css .todoList { margin : 2rem 0 0 ; padding : 0 ; list-style : none; max-width : 600px ; li { position : relative; padding : 16px 48px 16px 48px ; background : #f9f9f9 ; font-size : 18px ; cursor : pointer; user-select : none; border-bottom : 1px solid #e9ecef ; transition : all 0.2s ; &:first-child { border-top-left-radius : 8px ; border-top-right-radius : 8px ; } &:last-child { border-bottom-left-radius : 8px ; border-bottom-right-radius : 8px ; border-bottom : none; } &:nth-child (even) { background : #ffffff ; } &:hover { background : #e9ecef ; } &.checked { background : #6c757d ; color : #fff ; text-decoration : line-through; &::before { content : '' ; position : absolute; left : 16px ; top : 50% ; transform : translateY (-50% ) rotate (45deg ); width : 8px ; height : 16px ; border-color : #fff ; border-style : solid; border-width : 0 3px 3px 0 ; } } } } .close { position : absolute; right : 0 ; top : 0 ; height : 100% ; padding : 0 16px ; display : flex; align-items : center; font-size : 24px ; font-weight : bold; color : #6c757d ; cursor : pointer; transition : all 0.2s ; &:hover { background : #dc3545 ; color : white; } } .header { background : linear-gradient (135deg , #667eea 0% , #764ba2 100% ); padding : 2rem ; border-radius : 8px ; color : white; box-shadow : 0 4px 6px rgba (0 , 0 , 0 , 0.1 ); max-width : 600px ; h2 { margin : 0 0 1rem 0 ; font-size : 28px ; } input { width : calc (75% - 8px ); padding : 12px ; border : none; border-radius : 6px 0 0 6px ; font-size : 16px ; outline : none; &:focus { box-shadow : 0 0 0 3px rgba (255 , 255 , 255 , 0.3 ); } } } .addBtn { display : inline-block; width : 25% ; padding : 12px ; background : #28a745 ; color : white; text-align : center; font-size : 16px ; font-weight : 600 ; cursor : pointer; border-radius : 0 6px 6px 0 ; transition : all 0.2s ; user-select : none; &:hover { background : #218838 ; transform : translateY (-1px ); } &:active { transform : translateY (0 ); } }
步驟 2:資料陣列化 將靜態的 <li> 改為從資料陣列渲染:
src/pages/lesson03/pages/TodoExample/index.jsx import styles from './todoList.module.css' ;const initData = [ { id : 1 , text : '去健身房' , checked : false }, { id : 2 , text : '繳帳單' , checked : true }, { id : 3 , text : '見 George' , checked : false }, { id : 4 , text : '買雞蛋' , checked : false }, { id : 5 , text : '讀一本書' , checked : false }, ]; export default function TodoExample ( ) { return ( <div className ="todo-example" > <h1 > Todo List:useReducer 範例</h1 > <div className ={styles.header} > <h2 > 我的待辦清單</h2 > <input type ="text" placeholder ="輸入新的待辦事項。.." /> <span className ={styles.addBtn} > 新增</span > </div > {/* 🌟 從資料陣列渲染 */} <ul className ={styles.todoList} > {initData.map((item) => ( <li key ={item.id} className ={item.checked ? styles.checked : ''} > {item.text} <span className ={styles.close} > ×</span > </li > ))} </ul > </div > ); }
步驟 3:添加 useState(CRUD 基礎) 現在使用 useState 來管理待辦列表,實現基本的 CRUD 功能。
Read(讀取) import { useState } from 'react' ;import styles from './todoList.module.css' ;const initData = [ { id : 1 , text : '去健身房' , checked : false }, { id : 2 , text : '繳帳單' , checked : true }, { id : 3 , text : '見 George' , checked : false }, { id : 4 , text : '買雞蛋' , checked : false }, { id : 5 , text : '讀一本書' , checked : false }, ]; export default function TodoExample ( ) { const [list, setList] = useState (initData); return ( <div className ="todo-example" > <h1 > Todo List:useState 版本</h1 > <div className ={styles.header} > <h2 > 我的待辦清單</h2 > <input type ="text" placeholder ="輸入新的待辦事項。.." /> <span className ={styles.addBtn} > 新增</span > </div > <ul className ={styles.todoList} > {list.map((item) => ( {/* 從 state 讀取 */} <li key ={item.id} className ={item.checked ? styles.checked : ''} > {item.text} <span className ={styles.close} > ×</span > </li > ))} </ul > </div > ); }
Create(新增) import { useState } from 'react' ;import styles from './todoList.module.css' ;const initData = [ { id : 1 , text : '去健身房' , checked : false }, { id : 2 , text : '繳帳單' , checked : true }, { id : 3 , text : '見 George' , checked : false }, { id : 4 , text : '買雞蛋' , checked : false }, { id : 5 , text : '讀一本書' , checked : false }, ]; export default function TodoExample ( ) { const [list, setList] = useState (initData); const [text, setText] = useState ('' ); const handleAdd = (text ) => { if (!text.trim ()) return ; setList ((prevList ) => [ ...prevList, { id : prevList[prevList.length - 1 ].id + 1 , text : text, checked : false , }, ]); }; return ( <div className ="todo-example" > <h1 > Todo List:實作 Create</h1 > <div className ={styles.header} > <h2 > 我的待辦清單</h2 > <input type ="text" placeholder ="輸入新的待辦事項。.." value ={text} onChange ={(e) => setText(e.target.value)} // 🌟 同步輸入框 /> <span className ={styles.addBtn} onClick ={() => { handleAdd(text); setText(''); // 🌟 清空輸入框 }} > 新增 </span > </div > <ul className ={styles.todoList} > {list.map((item) => ( <li key ={item.id} className ={item.checked ? styles.checked : ''} > {item.text} <span className ={styles.close} > ×</span > </li > ))} </ul > </div > ); }
Delete(刪除) export default function TodoExample ( ) { const [list, setList] = useState (initData); const [text, setText] = useState ('' ); const handleAdd = (text ) => { if (!text.trim ()) return ; setList ((prevList ) => [ ...prevList, { id : prevList[prevList.length - 1 ].id + 1 , text : text, checked : false , }, ]); }; const handleDelete = (id ) => { setList ((prevList ) => prevList.filter ((item ) => item.id !== id)); }; return ( <div className ="todo-example" > <h1 > Todo List:實作 Delete</h1 > <div className ={styles.header} > <h2 > 我的待辦清單</h2 > <input type ="text" placeholder ="輸入新的待辦事項。.." value ={text} onChange ={(e) => setText(e.target.value)} /> <span className ={styles.addBtn} onClick ={() => { handleAdd(text); setText(''); }} > 新增 </span > </div > <ul className ={styles.todoList} > {list.map((item) => ( <li key ={item.id} className ={item.checked ? styles.checked : ''} > {item.text} <span className ={styles.close} onClick ={() => handleDelete(item.id)} // 🌟 點擊刪除 > × </span > </li > ))} </ul > </div > ); }
Update(更新) export default function TodoExample ( ) { const [list, setList] = useState (initData); const [text, setText] = useState ('' ); const handleAdd = (text ) => { if (!text.trim ()) return ; setList ((prevList ) => [ ...prevList, { id : prevList[prevList.length - 1 ].id + 1 , text : text, checked : false , }, ]); }; const handleDelete = (id ) => { setList ((prevList ) => prevList.filter ((item ) => item.id !== id)); }; const handleToggle = (id ) => { setList ((prevList ) => prevList.map ((item ) => item.id === id ? { ...item, checked : !item.checked } : item ) ); }; return ( <div className ="todo-example" > <h1 > Todo List:實作 Update</h1 > <div className ={styles.header} > <h2 > 我的待辦清單</h2 > <input type ="text" placeholder ="輸入新的待辦事項。.." value ={text} onChange ={(e) => setText(e.target.value)} /> <span className ={styles.addBtn} onClick ={() => { handleAdd(text); setText(''); }} > 新增 </span > </div > <ul className ={styles.todoList} > {list.map((item) => ( <li key ={item.id} className ={item.checked ? styles.checked : ''} onClick ={() => handleToggle(item.id)} // 🌟 點擊切換 > {item.text} <span className ={styles.close} onClick ={(e) => { e.stopPropagation(); // 🌟 阻止事件冒泡 handleDelete(item.id); }} > × </span > </li > ))} </ul > </div > ); }
事件冒泡問題:
<li onClick={() => handleToggle (item.id )}> {} {item.text } <span onClick={() => handleDelete (item.id )}> {} × </span> </li>
問題: 點擊 × 時,會同時觸發 handleDelete 和 handleToggle
解決方案: 在子元素的事件處理中呼叫 e.stopPropagation()
<span onClick={(e ) => { e.stopPropagation (); handleDelete (item.id ); }} > × </span>
步驟 4:元件拆分 將元件拆分為更小的子元件,遵循單一職責原則。
拆分策略:
TodoExample(主元件):管理狀態和業務邏輯
TaskAdd(新增元件):負責輸入和新增
TaskList(列表元件):負責顯示待辦列表
TaskAdd 元件 src/pages/lesson03/pages/TodoExample/components/TaskAdd.jsx import { useState } from 'react' ;import styles from '../todoList.module.css' ;export default function TaskAdd ({ onAdd } ) { const [text, setText] = useState ('' ); const handleSubmit = ( ) => { if (!text.trim ()) { alert ('請輸入待辦事項!' ); return ; } onAdd (text); setText ('' ); }; return ( <> <input type ="text" placeholder ="輸入新的待辦事項。.." value ={text} onChange ={(e) => setText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); // 支援 Enter 鍵 }} /> <span className ={styles.addBtn} onClick ={handleSubmit} > 新增 </span > </> ); }
TaskList 元件 src/pages/lesson03/pages/TodoExample/components/TaskList.jsx import styles from '../todoList.module.css' ;export default function TaskList ({ items, onToggle, onDelete } ) { return ( <ul className ={styles.todoList} > {items.length === 0 ? ( <li style ={{ textAlign: 'center ', color: '#6c757d ', cursor: 'default ' }}> 沒有待辦事項,新增一個吧! </li > ) : ( items.map((item) => ( <li key ={item.id} className ={item.checked ? styles.checked : ''} onClick ={() => onToggle(item.id)} > {item.text} <span className ={styles.close} onClick ={(e) => { e.stopPropagation(); onDelete(item.id); }} > × </span > </li > )) )} </ul > ); }
更新主元件 src/pages/lesson03/pages/TodoExample/index.jsx import { useState } from 'react' ;import styles from './todoList.module.css' ;import TaskAdd from './components/TaskAdd' ;import TaskList from './components/TaskList' ;const initData = [ { id : 1 , text : '去健身房' , checked : false }, { id : 2 , text : '繳帳單' , checked : true }, { id : 3 , text : '見 George' , checked : false }, { id : 4 , text : '買雞蛋' , checked : false }, { id : 5 , text : '讀一本書' , checked : false }, ]; export default function TodoExample ( ) { const [list, setList] = useState (initData); const handleAdd = (text ) => { setList ((prevList ) => [ ...prevList, { id : prevList.length > 0 ? prevList[prevList.length - 1 ].id + 1 : 1 , text : text, checked : false , }, ]); }; const handleDelete = (id ) => { setList ((prevList ) => prevList.filter ((item ) => item.id !== id)); }; const handleToggle = (id ) => { setList ((prevList ) => prevList.map ((item ) => item.id === id ? { ...item, checked : !item.checked } : item ) ); }; return ( <div className ="todo-example" > <h1 > Todo List:useState 版本(已拆分元件)</h1 > <div className ={styles.header} > <h2 > 我的待辦清單</h2 > <TaskAdd onAdd ={handleAdd} /> </div > <TaskList items ={list} onToggle ={handleToggle} onDelete ={handleDelete} /> </div > ); }
元件拆分的好處:
單一職責 :每個元件只負責一件事
可重用性 :子元件可以在其他地方重用
可測試性 :小元件更容易測試
可維護性 :修改某個功能時,只需要修改對應的元件
關注點分離 :
主元件:管理狀態和業務邏輯
子元件:負責 UI 渲染
Props 資料流向:
TodoExample(父元件) ├─ state: list ├─ handleAdd() ├─ handleDelete() └─ handleToggle() ↓ (透過 Props 傳遞) TaskAdd & TaskList(子元件) └─ 呼叫 onAdd/onDelete/onToggle
範例:Todo List(改用 useReducer) 現在我們的 Todo List 使用 useState 已經可以正常運作了,但隨著功能增加,會遇到一些問題:
問題點:
狀態更新邏輯分散 :handleAdd、handleDelete、handleToggle 分散在元件各處
難以測試 :狀態更新邏輯混在元件中,無法單獨測試
重複的邏輯 :多個地方可能需要類似的狀態更新邏輯
難以擴充 :新增功能時需要修改元件,容易出錯
useReducer 的優勢:
集中管理 :所有狀態更新邏輯都在 reducer 中
易於測試 :reducer 是純函式,容易測試
可預測性 :相同的 state + action = 相同的結果
易於擴充 :新增功能只需添加 action 和對應的 case
更好的除錯 :可以記錄所有 action, 追蹤狀態變化
步驟 1:將 useState 轉換為 useReducer 讓我們一步步將 Todo List 從 useState 轉換為 useReducer。
第一步:定義 Reducer 函式
src/pages/lesson03/pages/TodoExample/index.jsx import { useReducer } from 'react' ; import styles from './todoList.module.css' ;import TaskAdd from './components/TaskAdd' ;import TaskList from './components/TaskList' ;const initialState = [ { id : 1 , text : '去健身房' , checked : false }, { id : 2 , text : '繳帳單' , checked : true }, { id : 3 , text : '見 George' , checked : false }, { id : 4 , text : '買雞蛋' , checked : false }, { id : 5 , text : '讀一本書' , checked : false }, ]; function todoReducer (state, action ) { switch (action.type ) { case 'ADD' : { return [ ...state, { id : state.length > 0 ? state[state.length - 1 ].id + 1 : 1 , text : action.text , checked : false , }, ]; } case 'DELETE' : { return state.filter ((item ) => item.id !== action.id ); } case 'TOGGLE' : { return state.map ((item ) => item.id === action.id ? { ...item, checked : !item.checked } : item ); } default : { throw new Error (`未知的 action type: ${action.type} ` ); } } } export default function TodoExample ( ) { const [list, dispatch] = useReducer (todoReducer, initialState); const handleAdd = (text ) => { dispatch ({ type : 'ADD' , text }); }; const handleDelete = (id ) => { dispatch ({ type : 'DELETE' , id }); }; const handleToggle = (id ) => { dispatch ({ type : 'TOGGLE' , id }); }; return ( <div className ="todo-example" > <h1 > Todo List:useReducer 版本</h1 > <div className ={styles.header} > <h2 > 我的待辦清單</h2 > <TaskAdd onAdd ={handleAdd} /> </div > <TaskList items ={list} onToggle ={handleToggle} onDelete ={handleDelete} /> </div > ); }
useState vs useReducer 對比:
useState 版本:
example-todo-usestate.jsx const [list, setList] = useState (initialState);const handleAdd = (text ) => { setList ((prevList ) => [ ...prevList, { id : prevList.length > 0 ? prevList[prevList.length - 1 ].id + 1 : 1 , text : text, checked : false , }, ]); };
useReducer 版本:
example-todo-usereducer.jsx const [list, dispatch] = useReducer (todoReducer, initialState);const handleAdd = (text ) => { dispatch ({ type : 'ADD' , text }); }; function todoReducer (state, action ) { switch (action.type ) { case 'ADD' : { return [ ...state, { id : state.length > 0 ? state[state.length - 1 ].id + 1 : 1 , text : action.text , checked : false , }, ]; } } }
優勢:
元件中的事件處理函式變得簡潔
狀態更新邏輯集中在 reducer 中
reducer 是純函式,易於測試
步驟 2:重構 Action(Action Creator) 為了避免手動編寫 action 物件時出錯,我們可以建立 Action Creator 函式。
src/pages/lesson03/pages/TodoExample/store/actions.js export const ADD_TODO = 'ADD_TODO' ;export const DELETE_TODO = 'DELETE_TODO' ;export const TOGGLE_TODO = 'TOGGLE_TODO' ;export const addTodo = (text ) => ({ type : ADD_TODO , text, }); export const deleteTodo = (id ) => ({ type : DELETE_TODO , id, }); export const toggleTodo = (id ) => ({ type : TOGGLE_TODO , id, });
為什麼要使用 Action Creator?
方式
優點
缺點
手動建立 action
簡單直觀
容易拼寫錯誤、重複代碼多
Action Creator
類型安全、可重用、易於重構
需要額外的函式
Action Types 常數
防止拼寫錯誤、支援自動完成
需要額外的常數定義
Action Creator 的好處:
❌ 手動建立 action(容易出錯):
example-action-manual.jsx dispatch ({ type : 'ADD_TOD' , text }); dispatch ({ type : 'ADD_TODO' , txt : text });
使用 Action Creator(類型安全):
example-action-creator.jsx import { addTodo, ADD_TODO } from './store/actions' ;dispatch (addTodo (text));case ADD_TODO : { return [...state, { id : state.length + 1 , text : action.text , checked : false }]; }
步驟 3:重構 Reducer 將 Reducer 獨立成單獨的文件:
src/pages/lesson03/pages/TodoExample/store/reducer.js import { ADD_TODO , DELETE_TODO , TOGGLE_TODO } from './actions' ;export const initialState = [ { id : 1 , text : '去健身房' , checked : false }, { id : 2 , text : '繳帳單' , checked : true }, { id : 3 , text : '見 George' , checked : false }, { id : 4 , text : '買雞蛋' , checked : false }, { id : 5 , text : '讀一本書' , checked : false }, ]; export function todoReducer (state, action ) { switch (action.type ) { case ADD_TODO : { return [ ...state, { id : state.length > 0 ? state[state.length - 1 ].id + 1 : 1 , text : action.text , checked : false , }, ]; } case DELETE_TODO : { return state.filter ((item ) => item.id !== action.id ); } case TOGGLE_TODO : { return state.map ((item ) => item.id === action.id ? { ...item, checked : !item.checked } : item ); } default : { throw new Error (`未知的 action type: ${action.type} ` ); } } }
更新主元件:
src/pages/lesson03/pages/TodoExample/index.jsx import { useReducer } from 'react' ;import styles from './todoList.module.css' ;import TaskAdd from './components/TaskAdd' ;import TaskList from './components/TaskList' ;import * as actions from './store/actions' ;import { todoReducer, initialState } from './store/reducer' ;export default function TodoExample ( ) { const [list, dispatch] = useReducer (todoReducer, initialState); const handleAdd = (text ) => dispatch (actions.addTodo (text)); const handleDelete = (id ) => dispatch (actions.deleteTodo (id)); const handleToggle = (id ) => dispatch (actions.toggleTodo (id)); return ( <div className ="todo-example" > <h1 > Todo List:useReducer 重構版</h1 > <div className ={styles.header} > <h2 > 我的待辦清單</h2 > <TaskAdd onAdd ={handleAdd} /> </div > <TaskList items ={list} onToggle ={handleToggle} onDelete ={handleDelete} /> {/* 🌟 顯示統計資訊 */} <div className ="todo-stats" > <p > 總共:{list.length} 項</p > <p > 已完成:{list.filter((item) => item.checked).length} 項</p > <p > 未完成:{list.filter((item) => !item.checked).length} 項</p > </div > </div > ); }
專案結構(重構後):
src/pages/lesson03/pages/TodoExample/ ├── index.jsx # 主元件(使用 useReducer) ├── index.css # 主樣式 ├── todoList.module.css # CSS Modules ├── components/ │ ├── TaskAdd.jsx # 新增元件 │ └── TaskList.jsx # 列表元件 └── store/ # 🌟 狀態管理 ├── actions.js # Action Types & Creators └── reducer.js # Reducer & Initial State
優勢:
關注點分離 :UI、邏輯、狀態管理分離
可測試性 :reducer 和 action 可以單獨測試
可維護性 :新增功能只需修改 actions 和 reducer
類型安全 :使用常數避免拼寫錯誤
Context + useReducer:終極解決方案 將 Context 和 useReducer 結合,可建立更強大的狀態管理方案。即使使用了 useReducer,若仍透過 Props 傳遞 dispatch 和 state,會遇到 Props Drilling 問題:主元件需要管理所有 handler 函式,每個子元件都需要透過 Props 接收函式,當元件層級更深時,Props 傳遞會更複雜。
解決方案是將 state 和 dispatch 放入 Context ,讓任何深層子元件都可以直接存取,完全消除 Props Drilling。
graph TD
A["TodoContext Provider<br/>(提供 state + dispatch)"]
B["TodoExample 主元件<br/>(只負責渲染 UI)"]
C["TaskAdd 元件<br/>(useContext 取得 dispatch)"]
D["TaskList 元件<br/>(useContext 取得 state + dispatch)"]
A -.->|"Context 直接傳遞"| C
A -.->|"Context 直接傳遞"| D
A --> B
B --> C
B --> D
style A fill:#e3f2fd
style B fill:#f5f5f5
style C fill:#e8f5e9
style D fill:#e8f5e9
建立 Context 與 Provider 首先,我們需要建立兩個分離的 Context:一個用於 state,一個用於 dispatch。這樣做可以優化性能,讓只讀取 state 的元件不會因為 dispatch 改變而重新渲染,只使用 dispatch 的元件也不會因為 state 改變而重新渲染。
為什麼要分兩個 Context?
const TodoContext = createContext (null );<TodoContext.Provider value ={{ state , handlers }}> // ✅ 方案 B:分離 Context(推薦) const TodoStateContext = createContext(null); const TodoDispatchContext = createContext(null);
分離的好處:
性能優化 :只訂閱需要的資料,避免不必要的重新渲染
語意清晰 :明確區分「資料」和「操作」
更好的 TypeScript 支援 :類型推斷更精確
完整實作:
src/pages/lesson03/pages/TodoExample/context/TodoContext.jsx import { createContext, useContext, useReducer } from 'react' ;import { todoReducer, initialState } from '../store/reducer' ;import * as actions from '../store/actions' ;const TodoStateContext = createContext (null );const TodoDispatchContext = createContext (null );export function TodoProvider ({ children } ) { const [state, dispatch] = useReducer (todoReducer, initialState); const handlers = { addTodo : (text ) => dispatch (actions.addTodo (text)), deleteTodo : (id ) => dispatch (actions.deleteTodo (id)), toggleTodo : (id ) => dispatch (actions.toggleTodo (id)), }; return ( <TodoStateContext.Provider value ={state} > <TodoDispatchContext.Provider value ={handlers} > {children} </TodoDispatchContext.Provider > </TodoStateContext.Provider > ); } export function useTodoState ( ) { const context = useContext (TodoStateContext ); if (context === null ) { throw new Error ('useTodoState 必須在 TodoProvider 內使用' ); } return context; } export function useTodoDispatch ( ) { const context = useContext (TodoDispatchContext ); if (context === null ) { throw new Error ('useTodoDispatch 必須在 TodoProvider 內使用' ); } return context; }
更新元件使用 Context 建立好 Context 和 Provider 後,我們需要更新主元件和子元件來使用 Context。主元件只需要用 TodoProvider 包覆,子元件則透過自訂 Hook 直接從 Context 讀取資料和操作函式,完全不需要透過 Props 傳遞。
主元件:
src/pages/lesson03/pages/TodoExample/index.jsx import styles from './todoList.module.css' ;import TaskAdd from './components/TaskAdd' ;import TaskList from './components/TaskList' ;import { TodoProvider , useTodoState } from './context/TodoContext' ;function TodoStats ( ) { const todos = useTodoState (); return ( <div className ="todo-stats" > <p > 總共:{todos.length} 項</p > <p > 已完成:{todos.filter((item) => item.checked).length} 項</p > <p > 未完成:{todos.filter((item) => !item.checked).length} 項</p > </div > ); } function TodoContent ( ) { return ( <div className ="todo-example" > <h1 > Todo List:Context + useReducer</h1 > <div className ={styles.header} > <h2 > 我的待辦清單</h2 > {/* 不再需要傳遞 Props */} <TaskAdd /> </div > {/* 不再需要傳遞 Props */} <TaskList /> {/* 新增統計元件 */} <TodoStats /> </div > ); } export default function TodoExample ( ) { return ( <TodoProvider > <TodoContent /> </TodoProvider > ); }
子元件:TaskAdd
src/pages/lesson03/pages/TodoExample/components/TaskAdd.jsx import { useState } from 'react' ;import styles from '../todoList.module.css' ;import { useTodoDispatch } from '../context/TodoContext' ;export default function TaskAdd ( ) { const [text, setText] = useState ('' ); const { addTodo } = useTodoDispatch (); const handleSubmit = ( ) => { if (!text.trim ()) { alert ('請輸入待辦事項!' ); return ; } addTodo (text); setText ('' ); }; return ( <> <input type ="text" placeholder ="輸入新的待辦事項。.." value ={text} onChange ={(e) => setText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); // 支援 Enter 鍵 }} /> <span className ={styles.addBtn} onClick ={handleSubmit} > 新增 </span > </> ); }
子元件:TaskList
src/pages/lesson03/pages/TodoExample/components/TaskList.jsx import styles from '../todoList.module.css' ;import { useTodoState, useTodoDispatch } from '../context/TodoContext' ;export default function TaskList ( ) { const todos = useTodoState (); const { toggleTodo, deleteTodo } = useTodoDispatch (); return ( <ul className ={styles.todoList} > {todos.length === 0 ? ( <li style ={{ textAlign: 'center ', color: '#6c757d ', cursor: 'default ' }}> 沒有待辦事項,新增一個吧! </li > ) : ( todos.map((item) => ( <li key ={item.id} className ={item.checked ? styles.checked : ''} onClick ={() => toggleTodo(item.id)} // 直接呼叫 > {item.text} <span className ={styles.close} onClick ={(e) => { e.stopPropagation(); deleteTodo(item.id); // 直接呼叫 }} > × </span > </li > )) )} </ul > ); }
Context + useReducer 的優勢 Before(Props Drilling):
<TodoExample > {} <TaskAdd onAdd={handleAdd} /> <TaskList items ={list} onToggle ={handleToggle} onDelete ={handleDelete} /> <TodoStats items ={list} /> </TodoExample >
After(Context):
<TodoProvider > {} <TodoExample > <TaskAdd /> {} <TaskList /> {} <TodoStats /> {} </TodoExample > </TodoProvider >
主要優勢:
消除 Props Drilling :子元件直接從 Context 讀取,不需要透過 Props 層層傳遞
關注點分離 :主元件不需要管理所有 handlers,只需負責 UI 渲染
易於擴展 :新增元件時不需要修改父元件,直接使用 Context 即可
更好的封裝 :Context 內部實作可以隨時改變,不影響使用它的元件
提升可測試性 :可以輕鬆 mock Context Provider 進行單元測試
性能優化 :分離 state 和 dispatch Context,避免不必要的重新渲染
總結 本章學習重點 本章學習了四個核心技術,它們可以獨立使用,也可以組合使用來建立更強大的狀態管理方案:
Context API :解決 Prop Drilling 問題,透過 createContext → Provider → useContext 三步驟實現跨元件資料共享。適用於主題、語言、使用者資訊等跨元件共享的資料。注意 Context 巢套時,子層 Provider 會覆蓋父層值。
CSS Modules :避免全域樣式汙染,透過 .module.css 檔案命名和 import styles from './MyComponent.module.css' 使用方式,自動產生唯一 class hash 值,實現局部作用域。
useReducer :管理複雜狀態的更好選擇,由 reducer + initialState → [state, dispatch] 組成。Reducer 是純函式 (state, action) => newState,搭配 Action Creator 可避免手動建立 action 時出錯。適用於多個相關狀態、複雜的狀態更新邏輯。
Context + useReducer :結合兩者優勢,實現跨元件狀態與集中管理。透過分離 StateContext 和 DispatchContext 可優化性能,使用自訂 Hook 提供更友善的 API,完全消除 Props Drilling,大幅提升可維護性。
最佳實踐與選擇指南 Context 使用時機與性能優化 適合使用 Context 的場景:
跨多層元件的共享資料(主題、語言、使用者資訊)
避免 Prop Drilling(Props 需要傳遞超過 3 層)
全域設定(API 端點、功能開關)
不適合使用 Context 的場景:
頻繁變動的資料(會導致大量重新渲染)
元件間的直接通信(考慮使用狀態提升或自訂事件)
簡單的 Props 傳遞(1-2 層,直接用 Props 即可)
性能優化技巧:
<Context .Provider value={{ user, theme }}> const value = useMemo (() => ({ user, theme }), [user, theme]);<Context.Provider value ={value} >
useState vs useReducer 選擇指南
場景
使用 useState
使用 useReducer
簡單狀態(單一值)
✅
❌
複雜狀態(多個相關值)
❌
✅
狀態更新邏輯簡單
✅
❌
狀態更新邏輯複雜
❌
✅
需要測試狀態邏輯
❌
✅
狀態依賴前一個狀態
△
✅
需要向下傳遞多個 setter
❌
✅
專案結構與錯誤處理 建議的專案結構:
src/ ├── contexts/ # 全域 Context │ ├── AuthContext.jsx # 使用者認證 │ ├── ThemeContext.jsx # 主題管理 │ └── AppProviders.jsx # 整合所有 Provider ├── pages/ │ └── TodoPage/ │ ├── index.jsx # 頁面主元件 │ ├── context/ # 頁面專屬 Context │ │ └── TodoContext.jsx │ ├── store/ # Reducer 相關 │ │ ├── actions.js │ │ └── reducer.js │ ├── components/ # 子元件 │ └── styles.module.css
錯誤處理:在自訂 Hook 中檢查 Context 是否存在
export function useTodoState ( ) { const context = useContext (TodoStateContext ); if (context === null ) { throw new Error ( 'useTodoState 必須在 TodoProvider 內使用。' + '請確保元件被 <TodoProvider> 包覆。' ); } return context; }
React 19 相關更新 React 19 對 Context 和 Reducer 帶來了一些改進:
React Compiler(實驗性) :自動優化 Context 的重新渲染,不需要手動使用 useMemo/useCallback。
更好的 DevTools 支援 :Context 的資料流更清晰,Reducer Action 可以在 Timeline 中追蹤。
Server Components(伺服器元件) :Context 在 Server Components 中有使用限制,建議將 Context 用於 Client Components。
Actions(表單處理) :可以結合 useActionState 處理表單提交,與 useReducer 類似,但專為表單設計。
下一步學習方向 完成本章後,建議繼續學習以下主題:
第三方狀態管理 :當專案規模擴大時,可以考慮使用 Zustand(輕量、簡單,推薦初學者)、Redux Toolkit(企業級、生態系完整)或 Jotai(原子化狀態管理)等第三方狀態管理方案。
資料獲取與快取 :學習 TanStack Query(React Query)進行伺服器狀態管理,或使用 SWR 作為輕量的資料獲取 Hook,並與 useReducer 整合處理載入狀態。
進階模式 :深入學習 Context 性能優化技巧、Reducer 的副作用處理(搭配 useEffect),以及使用 Immer 簡化不可變更新。
全端框架 :探索 Next.js 15 的 Server Components + Actions,或 Remix 的 Loader + Action 模式,了解如何在全端框架中使用 Context 和 Reducer。
參考文獻