React 19 帶來了許多改進和新功能,特別是在 Hooks 的使用上更加完善。本文將詳細介紹 React 官方推薦的所有 Hooks,包括基礎 Hooks、效能優化 Hooks,以及 React 19 新增的 useActionState 和 useOptimistic,讓您能夠更好地掌握現代 React 開發技巧。
React Hooks 概觀 React Hooks 是 React 16.8 引入的功能,讓我們能在函式元件中使用 state 和其他 React 功能。React 19 進一步優化了現有 Hooks,並新增了兩個實用的 Hooks。
從 Class 元件到 Function 元件的演進 在 React 16.8 之前,開發者主要透過 Class 元件(類別元件)來管理狀態(state)與生命週期(lifecycle methods),例如 constructor
、componentDidMount
、componentDidUpdate
、componentWillUnmount
等。這種寫法雖然功能完整,但語法較為冗長,且在複雜元件中容易出現「生命週期邏輯分散」與「this 綁定」等問題,導致程式碼難以維護與重複利用。
自從 React 16.8 推出 Hooks 之後,Function 元件(函式元件)結合 Hooks 已成為現代 React 的主流開發方式。Hooks 讓我們能在函式元件中直接使用 state、effect、context 等功能,無需再撰寫 class,語法更簡潔、可讀性更高,也更容易進行單元測試與重構。
Function 元件搭配 Hooks 不僅能減少樣板程式碼(boilerplate),還能讓邏輯更容易抽離成自訂 Hook,提升元件的可重用性與維護性。
項目
Class 元件
Function 元件 + Hooks
狀態管理
this.state
/ this.setState
useState
生命週期
componentDidMount
等方法
useEffect
this 綁定
需手動綁定(如箭頭函式或 bind)
無需 this,直接使用變數
程式碼結構
較為冗長,邏輯分散
精簡、邏輯可集中或抽離
可重用邏輯
透過 HOC 或 render props
透過自訂 Hook
學習曲線
較高,需理解 class 與生命週期
較低,貼近 JavaScript 函式式思維
現在的 React 開發已經全面以「函式元件(Function Component)+ Hooks」為主流,Hooks 只能用在函式元件 ,無法在 Class 元件中使用。因此,初學者只需要專注學習函式元件與各種 Hook 的用法即可,不必再花時間學習 Class 元件的生命週期與 this 綁定等舊式寫法。
只有在維護舊專案或需要閱讀舊有程式碼時,才有必要了解 Class 元件的語法與生命週期方法。對於新專案與現代開發,建議完全採用函式元件搭配 Hooks,這樣能寫出更簡潔、易維護且符合官方最佳實踐的 React 程式碼。
Hooks 使用規則 React Hooks 有嚴格的使用規則,必須遵循以下原則:
只在元件頂層調用 Hooks :不能在條件語句、迴圈或巢狀函式中調用
只在 React 函式中調用 :只能在 React 元件或自定義 Hook 中使用
保持調用順序一致 :每次渲染時 Hooks 的調用順序必須相同
這些規則確保 React 能夠正確追蹤 Hooks 的狀態,避免狀態錯亂和不可預期的行為。
以下是官方推薦的所有 Hooks 分類:
graph LR
%% 節點定義
A["React 19 官方 Hooks"]
B["基礎 Hooks"]
C["效能優化 Hooks"]
D["進階 Hooks"]
E["React 19 新增"]
F["特殊用途 Hooks"]
%% 關聯線
A --> B
A --> C
A --> D
A --> E
A --> F
B --> B1["useState"]
B --> B2["useEffect"]
B --> B3["useContext"]
B --> B4["useRef"]
C --> C1["useCallback"]
C --> C2["useMemo"]
C --> C3["useDeferredValue"]
C --> C4["useTransition"]
D --> D1["useReducer"]
D --> D2["useImperativeHandle"]
D --> D3["useSyncExternalStore"]
E --> E1["useActionState"]
E --> E2["useOptimistic"]
F --> F1["useId"]
F --> F2["useDebugValue"]
F --> F3["useInsertionEffect"]
F --> F4["useLayoutEffect"]
%% 配色設定
style B fill:#E3F6FF,stroke:#1890FF,stroke-width:2px
style B1 fill:#E3F6FF,stroke:#1890FF
style B2 fill:#E3F6FF,stroke:#1890FF
style B3 fill:#E3F6FF,stroke:#1890FF
style B4 fill:#E3F6FF,stroke:#1890FF
style C fill:#FFF7E3,stroke:#FFB300,stroke-width:2px
style C1 fill:#FFF7E3,stroke:#FFB300
style C2 fill:#FFF7E3,stroke:#FFB300
style C3 fill:#FFF7E3,stroke:#FFB300
style C4 fill:#FFF7E3,stroke:#FFB300
style D fill:#F3E8FF,stroke:#9C27B0,stroke-width:2px
style D1 fill:#F3E8FF,stroke:#9C27B0
style D2 fill:#F3E8FF,stroke:#9C27B0
style D3 fill:#F3E8FF,stroke:#9C27B0
style E fill:#E8F5E9,stroke:#43A047,stroke-width:2px
style E1 fill:#E8F5E9,stroke:#43A047
style E2 fill:#E8F5E9,stroke:#43A047
style F fill:#FFF0F0,stroke:#F44336,stroke-width:2px
style F1 fill:#FFF0F0,stroke:#F44336
style F2 fill:#FFF0F0,stroke:#F44336
style F3 fill:#FFF0F0,stroke:#F44336
style F4 fill:#FFF0F0,stroke:#F44336
style A fill:#F5F5F5,stroke:#607D8B,stroke-width:2.5px
基礎 Hooks 這些是最常用的 Hooks,幾乎每個 React 應用程式都會使用到。
useState useState
是最基本的 Hook,用於在函式元件中管理 state。它讓 React 能夠追蹤狀態變化並觸發重新渲染。
為什麼需要 useState?
在 React 中,只有當 state 或 props 發生變化時,元件才會重新渲染。如果我們直接修改變數而不使用 useState
,React 無法得知資料已經改變,因此不會觸發重新渲染。useState
提供了:
狀態追蹤 :React 能夠監控狀態變化
重新渲染觸發 :當狀態更新時自動重新渲染元件
狀態持久化 :在元件重新渲染之間保持狀態值
useState 基本語法 import React , { useState } from 'react' ;function CounterComponent ( ) { const [count, setCount] = useState (0 ); return ( <div > <p > 你點擊了 {count} 次</p > <button onClick ={() => setCount(count + 1)}> 點擊我 </button > </div > ); }
語法說明:
useState(initialValue)
回傳一個陣列,包含當前的 state 值和更新函式
使用陣列解構來取得 state 和 setter 函式
state 更新是非同步的,會觸發元件重新渲染
重新渲染機制詳解 React 的重新渲染機制是基於狀態(state)變化的偵測。由於 React 採用虛擬 DOM(virtual DOM, vDOM)來代理實際的 HTML 畫面渲染,即使我們直接修改變數的值,React 也不會主動偵測到這個變化,因此不會觸發 vDOM 的異動與畫面更新。只有透過 useState
提供的 setter 函式(如 setCount
),才能正確通知 React 有狀態變化,進而觸發元件的重新渲染。讓我們透過對比例子來理解:
錯誤做法 - 不會觸發重新渲染 function BadCounter ( ) { let count = 0 ; const handleClick = ( ) => { count = count + 1 ; console .log (count); }; return ( <div > <p > 計數:{count}</p > {/* 永遠顯示 0 */} <button onClick ={handleClick} > 點擊</button > </div > ); }
正確做法 - 會觸發重新渲染 function GoodCounter ( ) { const [count, setCount] = useState (0 ); const handleClick = ( ) => { setCount (count + 1 ); }; return ( <div > <p > 計數:{count}</p > {/* 會正確顯示更新後的值 */} <button onClick ={handleClick} > 點擊</button > </div > ); }
重要概念:
React 無法偵測直接變數修改 :let count = 0; count = count + 1;
不會觸發重新渲染
必須使用 setter 函式 :setCount(newValue)
才會通知 React 狀態已改變
重新渲染是批次處理 :多個狀態更新會合併成一次重新渲染
狀態更新是非同步的 :setCount
不會立即更新 count
的值,而是等待下一次重新渲染讀取新的 count
值
實際範例:重新渲染流程 讓我們透過一個完整的範例來理解 React 的重新渲染流程:
重新渲染流程範例 import React , { useState } from 'react' ;function TodoApp ( ) { const [todos, setTodos] = useState ([]); const [inputValue, setInputValue] = useState ('' ); const addTodo = ( ) => { if (inputValue.trim ()) { setTodos ([...todos, { id : Date .now (), text : inputValue, completed : false }]); setInputValue ('' ); } }; const toggleTodo = (id ) => { setTodos (todos.map (todo => todo.id === id ? { ...todo, completed : !todo.completed } : todo )); }; return ( <div > <h2 > 待辦事項清單</h2 > <div > <input type ="text" value ={inputValue} onChange ={(e) => setInputValue(e.target.value)} placeholder="輸入待辦事項。.." /> <button onClick ={addTodo} > 新增</button > </div > <ul > {todos.map(todo => ( <li key ={todo.id} style ={{ textDecoration: todo.completed ? 'line-through ' : 'none ' }} onClick ={() => toggleTodo(todo.id)} > {todo.text} </li > ))} </ul > </div > ); }
重新渲染流程總結:
使用者操作 :點擊按鈕、輸入文字等
事件處理 :執行對應的事件處理函式
狀態更新 :呼叫 setState
函式更新狀態
React 偵測 :React 偵測到狀態變化
重新渲染 :React 重新執行元件函式
畫面更新 :使用最新的狀態值渲染新的 UI
useState 進階範例 import React , { useState } from 'react' ;function UserProfile ( ) { const [user, setUser] = useState ({ name : '' , email : '' , age : 0 }); const handleInputChange = (field, value ) => { setUser (prevUser => ({ ...prevUser, [field]: value })); }; return ( <form > <input type ="text" placeholder ="姓名" value ={user.name} onChange ={(e) => handleInputChange('name', e.target.value)} /> <input type ="email" placeholder ="電子郵件" value ={user.email} onChange ={(e) => handleInputChange('email', e.target.value)} /> <input type ="number" placeholder ="年齡" value ={user.age} onChange ={(e) => handleInputChange('age', parseInt(e.target.value))} /> </form > ); }
注意事項:
更新物件或陣列時,必須創建新的參考,不能直接修改原始物件
使用函式式更新 setState(prevState => newState)
來避免 stale closure 問題
初始值只在元件第一次渲染時使用
useState 深入機制 理解 useState
的底層運作機制對於避免常見錯誤非常重要。
State 的不可變性 React 中的 state 必須是不可變的 ,這意味著你不能直接修改現有的 state 物件或陣列。
State 不可變性範例 import React , { useState } from 'react' ;function ImmutabilityDemo ( ) { const [user, setUser] = useState ({ name : '張三' , hobbies : ['讀書' , '游泳' ], profile : { age : 25 , city : '台北' } }); const handleWrongUpdate = ( ) => { user.name = '李四' ; user.hobbies .push ('跑步' ); user.profile .age = 26 ; setUser (user); }; const handleCorrectUpdate = ( ) => { setUser ({ ...user, name : '李四' , hobbies : [...user.hobbies , '跑步' ], profile : { ...user.profile , age : 26 } }); }; const handleBestUpdate = ( ) => { setUser (prevUser => ({ ...prevUser, name : '王五' , hobbies : [...prevUser.hobbies , '騎車' ], profile : { ...prevUser.profile , age : prevUser.profile .age + 1 } })); }; return ( <div > <h3 > 用戶資料:{user.name}</h3 > <p > 年齡:{user.profile.age}</p > <p > 居住地:{user.profile.city}</p > <p > 興趣:{user.hobbies.join(', ')}</p > <button onClick ={handleWrongUpdate} > ❌ 錯誤更新</button > <button onClick ={handleCorrectUpdate} > ✅ 正確更新</button > <button onClick ={handleBestUpdate} > ✨ 最佳實踐</button > </div > ); }
為什麼必須保持不可變性?
React 使用 Object.is()
比較 state 的參考來判斷是否需要重新渲染
直接修改物件會導致新舊 state 具有相同記憶體位置的參考,React 認為沒有變化
不可變更新確保了元件的純函式特性和可預測性
批次更新與連續操作問題 這是 React 初學者最常遇到的疑惑之一:為什麼連續呼叫多次 setState,結果卻只更新一次?
React 的批次更新(Batching)機制說明: React 為了提升效能,會將同一事件處理流程中的多個狀態更新「合併批次處理」。這代表:
多次呼叫 setState
,如果都是基於同一個舊值(如 count
的靜態值為 0),React 會將它們排入更新隊列,但每次計算的基礎值都是尚未更新的舊值,並不是重新渲染後從 useState 返回的新值。
React 會在事件處理結束後,統一執行這些狀態更新。最終只會觸發一次重新渲染(re-render)。
在批次處理期間,state(如 count
)的值不會即時改變,仍然維持舊值,直到所有更新完成後才會反映最新結果。
若要正確累加,使用「函式式更新」語法(setState(prev => ...)
),這樣排程時不是要求當下靜態值 0 來計算,而是要求以最新值來計算,這樣每次都會基於最新的 state 計算。
錯誤:直接使用 state 值 import React , { useState } from 'react' ;function WrongBatchUpdate ( ) { const [count, setCount] = useState (0 ); const handleWrongIncrement = ( ) => { console .log ('更新前的 count:' , count); setCount (count + 1 ); setCount (count + 1 ); console .log ('更新後的 count (實際不會馬上變化):' , count); }; return ( <div > <h3 > 計數器:{count}</h3 > <button onClick ={handleWrongIncrement} > ❌ 錯誤:連續 +1 三次 </button > <p > 期望:+3,實際:+1(因為都基於同一個舊值計算)</p > </div > ); }
問題分析:
三次 setCount(count + 1)
都是基於同一個舊值 0 來計算
React 批次處理後,只會執行最後一次更新
結果:count 從 0 變成 1,而不是 3
正確:使用函式式更新 import React , { useState } from 'react' ;function CorrectBatchUpdate ( ) { const [count, setCount] = useState (0 ); const handleCorrectIncrement = ( ) => { console .log ('更新前的 count:' , count); setCount (prevCount => prevCount + 1 ); setCount (prevCount => prevCount + 1 ); }; return ( <div > <h3 > 計數器:{count}</h3 > <button onClick ={handleCorrectIncrement} > ✅ 正確:函式式更新 +3 </button > <p > 正確地增加 3(因為每次都基於前一次結果計算)</p > </div > ); }
解決方案:
使用 setCount(prevCount => prevCount + 1)
函式式更新
每次都是基於前一次的結果來計算
結果:count 正確地增加 3
觀察批次更新機制 import React , { useState } from 'react' ;function BatchMechanismDemo ( ) { const [count, setCount] = useState (0 ); const handleBatchDemo = ( ) => { console .log ('=== 批次更新演示 ===' ); console .log ('開始時 count:' , count); setCount (count + 1 ); console .log ('第一次 setCount 後:' , count); setCount (count + 1 ); console .log ('第二次 setCount 後:' , count); console .log ('所有 setCount 都只是排程,實際更新會在函式執行完後才發生' ); setTimeout (() => { console .log ('setTimeout 中看到的 count:' , count); }, 0 ); }; return ( <div > <h3 > 計數器:{count}</h3 > <button onClick ={handleBatchDemo} > 🔍 觀察批次更新 </button > <p > 打開 Console 查看執行過程(注意 count 值在函式執行期間不會改變)</p > <button onClick ={() => setCount(0)}>重設</button > </div > ); }
機制說明:
所有 setCount
都只是排程,不會立即更新
實際更新會在函式執行完後才發生
在函式執行期間,count
的值不會改變
閉包陷阱 (Stale Closure) 什麼是閉包(Closure)? 閉包是 JavaScript 的一個重要概念,指的是函式能夠「記住」並存取其外部作用域的變數,即使外部函式已經執行完畢。在 React 中,這會導致一個常見問題:非同步操作中看到的 state 值可能是舊的。
閉包陷阱的發生原因:
函式捕獲變數 :當你建立一個函式時,它會「捕獲」當時作用域中的所有變數
非同步執行 :setTimeout、setInterval 等非同步操作會在稍後執行
變數值已改變 :但函式內部看到的仍然是「被捕獲時」的舊值
簡單範例理解閉包:
閉包基本概念 function outerFunction ( ) { let count = 0 ; function innerFunction ( ) { console .log (count); count++; } return innerFunction; } const myFunction = outerFunction ();myFunction (); myFunction (); myFunction ();
在 React 中,閉包陷阱會導致非同步操作中的 state 值不是最新的:
錯誤:非同步操作中的閉包陷阱 import React , { useState, useEffect } from 'react' ;function WrongClosureDemo ( ) { const [count, setCount] = useState (0 ); const handleAsyncWrong = ( ) => { console .log ('點擊時的 count:' , count); setTimeout (() => { console .log ('3 秒後看到的 count:' , count); setCount (count + 1 ); }, 3000 ); }; const [autoCount, setAutoCount] = useState (0 ); const [isRunning, setIsRunning] = useState (false ); useEffect (() => { let intervalId; if (isRunning) { intervalId = setInterval (() => { console .log ('interval 中看到的 autoCount:' , autoCount); setAutoCount (autoCount + 1 ); }, 1000 ); } return () => clearInterval (intervalId); }, [isRunning]); return ( <div > <div style ={{ marginBottom: '20px ' }}> <h3 > 非同步操作閉包陷阱</h3 > <p > 當前計數:{count}</p > <button onClick ={handleAsyncWrong} > ❌ 3 秒後 +1 (錯誤) </button > <button onClick ={() => setCount(0)}>重設</button > </div > <div style ={{ marginBottom: '20px ' }}> <h3 > 自動計數器閉包問題</h3 > <p > 錯誤版本:{autoCount}</p > <button onClick ={() => setIsRunning(!isRunning)}> {isRunning ? '停止' : '開始'} 錯誤計數器 </button > <button onClick ={() => { setAutoCount(0); setIsRunning(false); }}> 重設 </button > </div > </div > ); }
問題分析:
閉包陷阱的詳細過程:
函式建立時 :setTimeout(() => { setCount(count + 1); }, 3000)
建立時,count
的值是 0
函式捕獲變數 :setTimeout 的 callback 函式「捕獲」了當時的 count
值(0)
3 秒後執行 :即使在這 3 秒內 count
可能已經變成 5,callback 函式看到的仍然是 0
錯誤結果 :setCount(0 + 1)
執行,count 變成 1,而不是 6
為什麼會發生這種情況?
JavaScript 的閉包特性:函式會「記住」建立時的外部變數值
React 的重新渲染:每次渲染都會建立新的函式,但非同步操作中的函式仍然持有舊的 state 值
時序問題:非同步操作執行時,元件可能已經重新渲染多次,但函式內部看到的還是舊值
結果:
非同步操作(setTimeout、setInterval)會「捕獲」當前的 state 值
即使 state 後來改變了,非同步操作中看到的仍然是舊值
計數器無法正確累加,永遠基於初始值計算
正確:使用函式式更新 import React , { useState, useEffect } from 'react' ;function CorrectClosureDemo ( ) { const [count, setCount] = useState (0 ); const handleAsyncCorrect = ( ) => { console .log ('點擊時的 count:' , count); setTimeout (() => { setCount (prevCount => { console .log ('3 秒後的實際 count:' , prevCount); return prevCount + 1 ; }); }, 3000 ); }; const [correctAutoCount, setCorrectAutoCount] = useState (0 ); const [isCorrectRunning, setIsCorrectRunning] = useState (false ); useEffect (() => { let intervalId; if (isCorrectRunning) { intervalId = setInterval (() => { setCorrectAutoCount (prevCount => prevCount + 1 ); }, 1000 ); } return () => clearInterval (intervalId); }, [isCorrectRunning]); return ( <div > <div style ={{ marginBottom: '20px ' }}> <h3 > 非同步操作正確處理</h3 > <p > 當前計數:{count}</p > <button onClick ={handleAsyncCorrect} > ✅ 3 秒後 +1 (正確) </button > <button onClick ={() => setCount(0)}>重設</button > </div > <div > <h3 > 正確的自動計數器</h3 > <p > 正確版本:{correctAutoCount}</p > <button onClick ={() => setIsCorrectRunning(!isCorrectRunning)}> {isCorrectRunning ? '停止' : '開始'} 正確計數器 </button > <button onClick ={() => { setCorrectAutoCount(0); setIsCorrectRunning(false); }}> 重設 </button > </div > </div > ); }
解決方案:
函式式更新如何解決閉包陷阱:
延遲取值 :setCount(prevCount => prevCount + 1)
不是立即取得 count
的值
執行時取值 :prevCount
參數會在 setState 執行時才取得最新的 state 值
避免閉包 :函式式更新不會「捕獲」舊的 state 值,而是動態取得最新值
為什麼函式式更新有效?
不依賴外部變數 :prevCount => prevCount + 1
不直接使用 count
變數
React 保證最新值 :React 會確保 prevCount
參數是最新的 state 值
避免時序問題 :無論何時執行,都能取得正確的最新值
實際執行過程:
建立時 :setTimeout(() => { setCount(prevCount => prevCount + 1); }, 3000)
建立
3 秒後執行 :React 會將最新的 state 值(比如 5)傳入 prevCount
參數
正確結果 :setCount(5 + 1)
執行,count 變成 6
結果:
使用 setCount(prevCount => prevCount + 1)
函式式更新
函式式更新會取得最新的 state 值
計數器能正確累加,基於最新值計算
何時會遇到閉包陷阱? 由於每一次重新渲染元件時,React 會建立新的函式實例,但非同步操作(如 setTimeout)中的函式仍然持有舊的閉包,因此閉包所「記住」的 state 值與元件重新渲染後的最新 state 值不同步。
在 setTimeout
、setInterval
等非同步操作中使用 state
在 useEffect
的清理函式中使用 state
在事件處理器中建立非同步操作
如何避免閉包陷阱?
使用函式式更新 :setState(prevState => newState)
使用 useRef :const countRef = useRef(count)
來保存最新值
正確的依賴陣列 :確保 useEffect 的依賴陣列包含所有使用的 state
記住這個原則:
在非同步操作中,永遠不要直接使用 state 變數,而要使用函式式更新或 useRef 來取得最新值。
State 更新的時機與性能考量 理解 React 的狀態更新時機對於寫出高效能的應用程式至關重要。React 18 引入了自動批次處理(Automatic Batching),大幅改善了性能表現。
什麼是批次更新? 當你在同一個事件中多次調用 setState
時,React 不會立即逐一執行每個更新,而是將所有更新「收集」起來,然後一次性處理,這樣只會觸發一次重新渲染。
React 17 vs React 18 的重要差異:
React 17 :只有在事件處理器中的更新才會被批次處理,setTimeout
、Promise
等非同步更新不會批次處理
React 18 :所有更新都會被自動批次處理,包括非同步更新
為什麼要關心這個?
性能提升 :減少不必要的重新渲染次數
避免中間狀態 :防止 UI 顯示不一致的中間狀態
更好的用戶體驗 :UI 更新更加順暢
以下範例將幫助您深入理解這些概念:
State 更新時機演示 import React , { useState, useEffect, useRef } from 'react' ;function UpdateTimingDemo ( ) { const [count, setCount] = useState (0 ); const [renderCount, setRenderCount] = useState (0 ); const renderCountRef = useRef (0 ); useEffect (() => { renderCountRef.current += 1 ; setRenderCount (renderCountRef.current ); }); const handleMultipleUpdates = ( ) => { console .log ('=== 多次更新測試 ===' ); console .log ('更新前渲染次數:' , renderCount); console .log ('更新前 count:' , count); setCount (prev => prev + 1 ); setCount (prev => prev + 1 ); setCount (prev => prev + 1 ); console .log ('三個 setCount 執行完畢,但渲染還未發生' ); console .log ('此時 count 還是:' , count); }; const handleAsyncUpdates = ( ) => { console .log ('=== 非同步更新測試 ===' ); console .log ('更新前 count:' , count); setTimeout (() => { console .log ('setTimeout 內,更新前 count:' , count); setCount (prev => prev + 1 ); console .log ('非同步更新執行完畢' ); }, 100 ); }; const handleConditionalUpdate = ( ) => { const newValue = Math .floor (Math .random () * 5 ); console .log (`隨機產生值:${newValue} ,當前值:${count} ` ); if (newValue !== count) { console .log ('值有變化,執行更新' ); setCount (newValue); } else { console .log ('值相同,跳過更新' ); } }; const handleReact17StyleUpdate = ( ) => { console .log ('=== React 17 風格更新 ===' ); setTimeout (() => { console .log ('非批次更新開始' ); setCount (prev => prev + 1 ); console .log ('第一次更新完成' ); setCount (prev => prev + 1 ); console .log ('第二次更新完成' ); setCount (prev => prev + 1 ); console .log ('第三次更新完成' ); }, 100 ); }; return ( <div > <h3 > State 更新時機演示</h3 > <p > 當前計數:{count}</p > <p > 元件渲染次數:{renderCount}</p > <div style ={{ marginBottom: '10px ' }}> <button onClick ={handleMultipleUpdates} > 同步批次更新 (+3) </button > <span > - 同一事件中的多個更新會被批次處理</span > </div > <div style ={{ marginBottom: '10px ' }}> <button onClick ={handleAsyncUpdates} > 非同步批次更新 (+3) </button > <span > - React 18+ 非同步更新也會批次處理</span > </div > <div style ={{ marginBottom: '10px ' }}> <button onClick ={handleReact17StyleUpdate} > 模擬 React 17 行為 (+3) </button > <span > - 觀察渲染次數差異</span > </div > <div style ={{ marginBottom: '10px ' }}> <button onClick ={handleConditionalUpdate} > 隨機更新 (0-4) </button > <span > - 相同值不會觸發渲染</span > </div > <button onClick ={() => { setCount(0); renderCountRef.current = 0; setRenderCount(0); }}> 完全重設 </button > <div style ={{ marginTop: '15px ', fontSize: '0.9em ', color: '#666 ' }}> <p > 💡 打開瀏覽器 Console 查看詳細執行過程</p > </div > </div > ); }
useState 完整運作機制流程圖 為了幫助您更好地理解 useState
的內部運作機制,以下流程圖展示了從調用 setState
到元件重新渲染的完整過程,並對比了函式式更新 與直接更新 兩種方式的差異:
graph TD
A["用戶調用 setState"] --> B{"React 18+ 批次處理"}
B --> C["收集同一事件中的所有更新"]
C --> D["合併更新"]
D --> E["觸發單次重新渲染"]
E --> F["執行 Effect Hooks"]
G["函式式更新"] --> H["prevState => newState"]
H --> I["基於最新狀態計算"]
I --> D
J["直接更新"] --> K["使用當前閉包中的值"]
K --> L["可能基於過時的狀態"]
L --> D
style G fill:#e8f5e8
style J fill:#ffebee
style E fill:#e1f5fe
流程圖說明:
🔄 主流程(上方) :
用戶調用 setState → 開始狀態更新流程
React 18+ 批次處理 → 判斷是否需要批次處理多個更新
收集更新 → 將同一事件中的所有 setState 調用收集起來
合併更新 → 計算最終的狀態值
觸發重新渲染 → 更新 DOM 並重新渲染元件
執行 Effect Hooks → 執行 useEffect 等副作用
✅ 函式式更新路徑(綠色) :
使用 setState(prev => newValue)
的方式
能夠取得最新的狀態值進行計算
避免閉包陷阱,確保基於正確的狀態更新
❌ 直接更新路徑(紅色) :
使用 setState(directValue)
的方式
可能會使用過時的閉包中的值
在連續更新或非同步操作中容易出現問題
💡 核心重點 :無論使用哪種更新方式,最終都會進入 React 的批次處理機制,但函式式更新能確保計算基於最新的狀態值。
useState 最佳實踐總結:
保持不可變性 :總是創建新的物件/陣列,不要直接修改
使用函式式更新 :當新狀態依賴舊狀態時,使用 setState(prev => ...)
理解批次更新 :多個 setState 在同一事件中會被批次處理
避免閉包陷阱 :在非同步操作中使用函式式更新
條件更新 :避免設置相同的值來減少不必要的渲染
useEffect useEffect
讓你在函式元件中執行副作用操作,類似於類別元件的生命週期方法。它提供了比 Class 元件更靈活的生命週期管理方式。在 React 中,useEffect
可以讓你將「副作用」動作(如事件監聽、資料請求等)與元件渲染邏輯分離,避免每次重新渲染時都重複執行不必要的操作。只要正確設置依賴陣列,這些副作用就能有效「優化重購」(避免重複註冊、重複計算)。
useEffect 基本語法 import React , { useState, useEffect } from 'react' ;function WindowWidth ( ) { const [windowWidth, setWindowWidth] = useState (window .innerWidth ); useEffect (() => { function handleResize ( ) { setWindowWidth (window .innerWidth ); } window .addEventListener ('resize' , handleResize); return () => { window .removeEventListener ('resize' , handleResize); }; }, []); return <div > 視窗寬度:{windowWidth}px</div > ; }
元件重新渲染的機制 重要概念:每次觸發重新渲染時,React 都會重新執行整個元件函式
元件重新渲染的過程 import React , { useState } from 'react' ;function MyComponent ( ) { console .log ('元件函式重新執行' ); const [count, setCount] = useState (0 ); const [name, setName] = useState ('' ); const expensiveValue = calculateExpensiveValue (); const eventHandler = ( ) => { console .log ('按鈕被點擊' ); setCount (count + 1 ); }; function calculateExpensiveValue ( ) { console .log ('執行昂貴的計算。..' ); let result = 0 ; for (let i = 0 ; i < 1000000 ; i++) { result += Math .random (); } return result; } return ( <div > <h3 > 計數器:{count}</h3 > <p > 姓名:{name}</p > <p > 昂貴計算結果:{expensiveValue.toFixed(2)}</p > <button onClick ={eventHandler} > 增加計數 </button > <input type ="text" value ={name} onChange ={(e) => setName(e.target.value)} placeholder="輸入姓名" /> </div > ); } function App ( ) { return ( <div > <h2 > 元件重新渲染演示</h2 > <p > 打開 Console 查看執行過程</p > <MyComponent /> </div > ); }
問題:什麼會導致不必要的重新生成?
昂貴的計算 :每次渲染都重新計算
函式重新建立 :每次渲染都建立新的函式參考
物件重新建立 :每次渲染都建立新的物件
事件監聽器重複註冊 :沒有正確清理
useEffect 的保護作用 useEffect 就像一個「保護區」,讓你可以精確控制副作用的執行時機,避免每次渲染都重複執行不必要的操作。在 React 的 useEffect
中,依賴項(Dependency Array) 是控制副作用執行時機的關鍵。你可以在 useEffect
的第二個參數傳入一個陣列,這個陣列裡的變數就是「依賴項」。React 會根據依賴項的變化來決定何時執行副作用函式。
沒有依賴陣列 :每次元件渲染後都會執行副作用。
空依賴陣列 :持有[]
時,只在元件「掛載」時執行一次(類似 componentDidMount)。
有依賴項 :若持有[dep1, dep2]
時,只有當陣列中的任一依賴項發生變化時才會執行副作用。
useEffect 的保護機制:
獨立執行時機 :不跟隨元件重新渲染
依賴控制 :透過依賴陣列控制何時執行
清理機制 :提供清理函式避免記憶體洩漏
效能優化 :避免不必要的重複操作
useEffect 的保護機制 import React , { useState, useEffect } from 'react' ;function MyComponent ( ) { const [count, setCount] = useState (0 ); const [windowWidth, setWindowWidth] = useState (window .innerWidth ); console .log ('每次渲染都會執行' ); useEffect (() => { console .log ('只在元件掛載時執行一次' ); const handleResize = ( ) => { console .log ('視窗大小改變' ); setWindowWidth (window .innerWidth ); }; window .addEventListener ('resize' , handleResize); return () => { console .log ('清理事件監聽器' ); window .removeEventListener ('resize' , handleResize); }; }, []); useEffect (() => { console .log ('count 改變了,當前值:' , count); document .title = `計數:${count} ` ; }, [count]); return ( <div > <h3 > 計數器:{count}</h3 > <p > 視窗寬度:{windowWidth}px</p > <button onClick ={() => setCount(count + 1)}> 增加計數 </button > <p > 打開 Console 查看執行過程</p > </div > ); } function App ( ) { return ( <div > <h2 > useEffect 保護機制演示</h2 > <MyComponent /> </div > ); }
useEffect 的清理機制 useEffect
還有一個重要的特性:清理函式(Cleanup Function) 。當你在 useEffect
中 return
一個函式時,這個函式會在以下時機執行:
元件卸載時 :元件從 DOM 中移除前執行
依賴項改變前 :在執行新的副作用前,先清理舊的副作用
重新渲染前 :如果沒有依賴項,每次重新渲染前都會執行清理
useEffect 清理函式的時機 useEffect (() => { const timer = setInterval (() => { console .log ('定時器執行' ); }, 1000 ); return () => { console .log ('清理定時器' ); clearInterval (timer); }; }, []);
清理函式的常見用途:
清除定時器(clearInterval
、clearTimeout
)
移除事件監聽器(removeEventListener
)
取消網路請求(AbortController
)
清理訂閱(取消 API 訂閱)
重置狀態或變數
清理函式的實際應用範例 import React , { useState, useEffect } from 'react' ;function DataFetcher ({ userId } ) { const [data, setData] = useState (null ); const [loading, setLoading] = useState (false ); useEffect (() => { const abortController = new AbortController (); const fetchData = async ( ) => { setLoading (true ); try { const response = await fetch (`/api/users/${userId} ` , { signal : abortController.signal }); const userData = await response.json (); setData (userData); } catch (error) { if (error.name !== 'AbortError' ) { console .error ('請求失敗:' , error); } } finally { setLoading (false ); } }; fetchData (); return () => { abortController.abort (); console .log ('取消 API 請求' ); }; }, [userId]); useEffect (() => { const handleKeyPress = (event ) => { console .log ('按鍵:' , event.key ); }; document .addEventListener ('keydown' , handleKeyPress); return () => { document .removeEventListener ('keydown' , handleKeyPress); console .log ('移除鍵盤事件監聽器' ); }; }, []); if (loading) return <div > 載入中。..</div > ; return <div > {data ? JSON.stringify(data) : '無資料'}</div > ; }
不提供第二個參數的 useEffect vs 直接寫在元件內 很多開發者會困惑:「不提供第二個參數的 useEffect」和「直接寫在元件內」有什麼差異?既然都會每次渲染執行,為什麼還要用 useEffect?
迷思澄清:useEffect 的執行時機差異 import React , { useState, useEffect } from 'react' ;function Counter ( ) { const [count, setCount] = useState (0 ); const [message, setMessage] = useState ('' ); console .log ('元件渲染時同步執行' ); document .title = `計數器:${count} ` ; useEffect (() => { console .log ('useEffect 異步執行' ); setMessage (`計數器:${count} ` ); }); useEffect (() => { console .log ('只在掛載時執行一次' ); }, []); return ( <div > <p > {message}</p > <button onClick ={() => setCount(count + 1)}> 點擊增加:{count} </button > </div > ); }
三種情況的差異對比:
情況
語法
執行時機
執行順序
狀態同步
效能影響
適用場景
直接寫在元件內
console.log('*')
每次渲染時同步執行
在渲染過程中
✅ 總是使用最新狀態
❌ 效能差,阻塞渲染
簡單計算,不涉及副作用
不提供第二個參數
useEffect(() => {})
每次渲染後異步執行
在渲染完成後
✅ 總是使用最新狀態
❌ 效能差,重複執行
很少使用,通常會造成問題
空依賴陣列 []
useEffect(() => {}, [])
只在掛載時執行一次
在首次渲染完成後
❌ 使用初始狀態值
✅ 效能好,只執行一次
初始化設定,不依賴狀態
正確依賴項
useEffect(() => {}, [state])
依賴項改變時執行
在依賴項改變後
✅ 使用最新狀態值
✅ 效能好,按需執行
依賴狀態的副作用
執行時機的實際差異 function UserProfile ({ userId } ) { const [user, setUser] = useState (null ); const [loading, setLoading] = useState (false ); console .log ('渲染過程中執行' ); useEffect (() => { console .log ('渲染完成後執行' ); setLoading (true ); fetch (`/api/users/${userId} ` ) .then (res => res.json ()) .then (data => { setUser (data); setLoading (false ); }); }); useEffect (() => { setLoading (true ); fetch (`/api/users/${userId} ` ) .then (res => res.json ()) .then (data => { setUser (data); setLoading (false ); }); }, [userId]); if (loading) return <div > 載入中。..</div > ; return <div > {user?.name}</div > ; }
關鍵差異說明:
執行時機 :
直接寫在元件內:在渲染過程中同步執行
useEffect:在渲染完成後異步執行
渲染阻塞 :
直接寫在元件內:可能阻塞渲染(如同步 API 請求)
useEffect:不會阻塞渲染
副作用管理 :
直接寫在元件內:無法清理副作用
useEffect:可以返回清理函式
useEffect 的設計理念 useEffect
的核心設計理念就是:讓副作用在元件渲染完成後才執行 。這樣做的好處是:
useEffect 的執行時機設計 function MyComponent ( ) { const [count, setCount] = useState (0 ); console .log ('1. 元件函式開始執行' ); console .log ('2. 渲染過程中執行' ); useEffect (() => { console .log ('4. useEffect 執行(渲染完成後)' ); document .title = `計數器:${count} ` ; }); console .log ('3. 返回 JSX' ); return <div > {count}</div > ; }
為什麼要這樣設計?
避免阻塞渲染 :副作用不會影響元件的渲染速度
避免無限迴圈 :副作用不會在渲染過程中觸發新的渲染
更好的使用者體驗 :使用者能立即看到介面,然後再處理副作用
符合 React 的設計哲學 :渲染是純函式,副作用是額外的操作
不提供第二個參數的常見問題:
無限迴圈 :每次渲染都執行,可能觸發新的狀態更新
效能問題 :重複執行昂貴的操作
記憶體洩漏 :重複註冊事件監聽器
API 請求氾濫 :每次渲染都發送請求
重要提醒:
直接寫在元件內 :同步執行,可能阻塞渲染,無法清理副作用
不提供第二個參數 :異步執行,不阻塞渲染,但每次渲染都執行,可能造成無限迴圈
**空依賴項 []
**:只在掛載時執行,狀態改變時不會更新
**正確依賴項 [state]
**:狀態改變時才執行,效能最佳
useEffect 與狀態同步 由於 useEffect
不會主動隨元件渲染而重新執行,如果你需要最新的 state 值作為應用,則還是需要透過依賴項指定 state 來通知 React 需要重新執行該 effect。
依賴項與狀態同步的重要性 import React , { useState, useEffect } from 'react' ;function Counter ( ) { const [count, setCount] = useState (0 ); const [message, setMessage] = useState ('' ); useEffect (() => { console .log ('count 值:' , count); setMessage (`計數器:${count} ` ); }, []); useEffect (() => { console .log ('count 值:' , count); setMessage (`計數器:${count} ` ); }, [count]); return ( <div > <p > {message}</p > <button onClick ={() => setCount(count + 1)}> 點擊增加:{count} </button > </div > ); }
為什麼需要依賴項?
閉包特性 :useEffect
中的函式會「記住」建立時的 state 值
效能考量 :React 不會自動重新執行所有 effect
明確性 :依賴項讓程式碼意圖更清楚
避免錯誤 :防止使用過時的 state 值
依賴項的實際應用場景 function UserProfile ({ userId } ) { const [user, setUser] = useState (null ); const [posts, setPosts] = useState ([]); useEffect (() => { fetchUser (userId).then (setUser); }, [userId]); useEffect (() => { if (user) { fetchUserPosts (user.id ).then (setPosts); } }, [user]); useEffect (() => { if (posts.length > 0 ) { document .title = `${user?.name} 的文章 (${posts.length} 篇)` ; } }, [posts, user?.name ]); return ( <div > <h1 > {user?.name}</h1 > <p > 文章數量:{posts.length}</p > </div > ); }
小技巧:
使用 ESLint 的 exhaustive-deps
規則可以自動檢查遺漏的依賴項
如果 effect 中使用了某個 state 或 prop,記得將它加入依賴項
避免在依賴項中放入會頻繁變化的物件,考慮使用 useCallback
或 useMemo
重要提醒: 如果沒有正確清理副作用,可能會導致:
記憶體洩漏 :事件監聽器持續存在
重複執行 :定時器或請求重複觸發
狀態不一致 :元件卸載後仍更新狀態
效能問題 :不必要的資源消耗
useEffect 的效能優化作用 為什麼需要 useEffect?
沒有 useEffect 的問題 import React , { useState } from 'react' ;function BadComponent ( ) { const [count, setCount] = useState (0 ); const [name, setName] = useState ('' ); window .addEventListener ('resize' , () => { console .log ('視窗大小改變' ); }); const expensiveValue = calculateExpensiveValue (); function calculateExpensiveValue ( ) { console .log ('執行昂貴的計算。..' ); let result = 0 ; for (let i = 0 ; i < 1000000 ; i++) { result += Math .random (); } return result; } return ( <div > <h3 > 計數器:{count}</h3 > <p > 姓名:{name}</p > <p > 昂貴計算結果:{expensiveValue.toFixed(2)}</p > <button onClick ={() => setCount(count + 1)}> 增加計數 </button > <input type ="text" value ={name} onChange ={(e) => setName(e.target.value)} placeholder="輸入姓名" /> <p > ❌ 問題:每次渲染都會重新註冊事件監聽器和重新計算</p > </div > ); }
使用 useEffect 的解決方案 import React , { useState, useEffect, useMemo } from 'react' ;function GoodComponent ( ) { const [count, setCount] = useState (0 ); const [name, setName] = useState ('' ); const [windowWidth, setWindowWidth] = useState (window .innerWidth ); useEffect (() => { const handleResize = ( ) => { console .log ('視窗大小改變' ); setWindowWidth (window .innerWidth ); }; window .addEventListener ('resize' , handleResize); return () => { window .removeEventListener ('resize' , handleResize); }; }, []); const expensiveValue = useMemo (() => { console .log ('執行昂貴的計算。..' ); let result = 0 ; for (let i = 0 ; i < 1000000 ; i++) { result += Math .random (); } return result; }, []); const handleClick = useCallback (() => { console .log ('按鈕被點擊' ); setCount (count + 1 ); }, [count]); return ( <div > <h3 > 計數器:{count}</h3 > <p > 姓名:{name}</p > <p > 視窗寬度:{windowWidth}px</p > <p > 昂貴計算結果:{expensiveValue.toFixed(2)}</p > <button onClick ={handleClick} > 增加計數 </button > <input type ="text" value ={name} onChange ={(e) => setName(e.target.value)} placeholder="輸入姓名" /> <p > ✅ 解決:事件監聽器只註冊一次,昂貴計算只執行一次</p > </div > ); }
useEffect 的效能優化效果:
避免重複操作 :事件監聽器、API 呼叫等只執行一次
記憶體管理 :正確清理資源,避免記憶體洩漏
條件執行 :只在必要時才執行副作用
效能提升 :減少不必要的重複計算和操作
useEffect 依賴項範例 import React , { useState, useEffect } from 'react' ;function UserData ({ userId } ) { const [user, setUser] = useState (null ); const [loading, setLoading] = useState (true ); useEffect (() => { async function fetchUser ( ) { setLoading (true ); try { const response = await fetch (`/api/users/${userId} ` ); const userData = await response.json (); setUser (userData); } catch (error) { console .error ('獲取用戶資料失敗:' , error); } finally { setLoading (false ); } } if (userId) { fetchUser (); } }, [userId]); if (loading) return <div > 載入中。..</div > ; if (!user) return <div > 找不到用戶資料</div > ; return ( <div > <h2 > {user.name}</h2 > <p > Email: {user.email}</p > </div > ); }
useContext 在 React 中,父元件通常會透過 props 將資料傳遞給子元件。然而,當元件樹結構很深時,會遇到「props drilling」(層層傳遞 props)問題:資料必須經過多層中介元件,即使這些中介元件本身並不需要該資料,也必須負責傳遞,導致程式碼冗長且維護困難。
舉例來說,假設有一個多層元件結構(爺爺 → 爸爸 → 兒子 → 孫子),而最底層的孫子元件需要最上層爺爺元件的資料。傳統做法必須:
爺爺將資料傳給爸爸
爸爸再傳給兒子
兒子再傳給孫子
這種方式不僅繁瑣,也容易出錯,這就是所謂的「props drilling」問題。React 的 Context 能有效解決這個困擾,讓資料可以直接由父元件「廣播」給所有需要的子元件,無論層級多深,都能輕鬆取得資料,省去中間層層傳遞的麻煩。
props drilling 的問題 function App ( ) { const [user, setUser] = useState ({ name : 'John' , age : 25 }); return ( <div > <Header user ={user} /> {/* 需要傳遞 user */} </div > ); } function Header ({ user } ) { return ( <header > <Navigation user ={user} /> {/* 不需要 user,但必須傳遞 */} </header > ); } function Navigation ({ user } ) { return ( <nav > <UserProfile user ={user} /> {/* 不需要 user,但必須傳遞 */} </nav > ); } function UserProfile ({ user } ) { return <div > 歡迎,{user.name}!</div > ; {} }
Context 的解決方案 Context 就像 React 應用程式中的「資料廣播電台」,讓父元件可以將資料「廣播」給所有子元件,任何深層的子元件都能直接存取資料,不需要透過 props 層層傳遞。
Context 的基本概念 const UserContext = createContext ();function App ( ) { const [user, setUser] = useState ({ name : 'John' , age : 25 }); return ( <UserContext.Provider value ={user} > <Header /> {/* 不需要傳遞 user */} </UserContext.Provider > ); } function UserProfile ( ) { const user = useContext (UserContext ); return <div > 歡迎,{user.name}!</div > ; }
重要概念:Context 的創建位置 createContext()
不是 Hook,所以它必須放在元件外面(檔案最上方),就像 import 語句一樣。然後在需要的子元件內使用 useContext
這個 Hook 來取得資料。
兩種創建 Context 的方式 Context 的創建有兩種主要方式:
方式一:無參數創建(推薦)
const UserContext = createContext ();
方式二:帶初始值創建
const UserContext = createContext ({ name : 'Guest' , age : 0 });
這兩種方式的主要差異在於「錯誤處理」的嚴謹度。讓我們用實際範例來說明:
方式一:無參數創建(推薦做法) import { createContext, useContext } from 'react' ;const UserContext = createContext ();function App ( ) { return ( <div > {/* ❌ 忘記用 Provider 包裝 */} <UserProfile /> </div > ); } function UserProfile ( ) { const user = useContext (UserContext ); return <div > 歡迎,{user.name}!</div > ; }
方式二:帶初始值創建(容易忽略錯誤) import { createContext, useContext } from 'react' ;const UserContext = createContext ({ name : 'Guest' , age : 0 });function App ( ) { return ( <div > {/* ❌ 忘記用 Provider 包裝 */} <UserProfile /> </div > ); } function UserProfile ( ) { const user = useContext (UserContext ); return <div > 歡迎,{user.name}!</div > ; }
關鍵差異:錯誤的可見性
無參數創建 :忘記 Provider 會立即報錯,強迫你修正,就像「你必須插插頭,不然電器不會動」
帶初始值創建 :忘記 Provider 仍能運作,但使用的是預設值,錯誤被隱藏了。很像是「你忘記插插頭,但電器用備用電池還能動」
正確的使用方式(兩種創建方式都一樣)
不論使用哪種方式創建 Context,實際的資料都必須透過 Provider
的 value
屬性來傳遞:
正確使用 Provider import { createContext, useContext } from 'react' ;const UserContext = createContext (); function App ( ) { const userData = { name : 'Loki' , age : 30 }; return ( {} <UserContext .Provider value={userData}> <UserProfile /> </UserContext .Provider > ); } function UserProfile ( ) { const user = useContext (UserContext ); return <div > 歡迎,{user.name}!</div > ; }
為什麼推薦無參數創建?
強制正確使用 :忘記 Provider 會立即報錯,而不是靜默失敗
避免資料來源混淆 :不會搞不清楚資料是來自 Provider 還是預設值
更好的除錯體驗 :錯誤訊息清楚明確,容易定位問題
養成良好習慣 :強制你學會正確的 Context 架構設計
Context 的使用就像建立一個「資料廣播系統」。首先創建 Context 建立一個「廣播頻道」,然後建立 Provider 作為「廣播站」來管理資料,接著用 Provider 包裝需要資料的元件,最後子元件用 useContext
訂閱資料並在元件中使用取得的資料。
完整的 Context 設定流程 讓我們用一個主題切換的例子,完整展示 Context 的設定流程。這個範例會展示如何建立一個全域的主題管理系統,讓任何元件都能輕鬆取得和切換主題。
實作步驟概覽:
創建 Context :使用 createContext()
創建一個名為 ThemeContext
的 Context
創建 Provider 元件 :封裝 ThemeContext.Provider
,內部管理主題狀態(theme
)和切換函式(toggleTheme
)
創建自定義 Hook :封裝 useContext(ThemeContext)
,提供更好的錯誤處理和使用體驗
創建消費元件 :在 Header
和 Content
元件中使用自定義 Hook 取得主題資料
包裝應用程式 :用 ThemeProvider
包裝整個 App,讓所有子元件都能存取主題
關鍵概念:Provider 元件封裝
我們不會直接在 App 中寫 <ThemeContext.Provider value={...}>
,而是創建一個 ThemeProvider
元件來封裝它。這樣做的好處是:
將狀態管理邏輯集中在一個地方
App 元件更簡潔,只需要 <ThemeProvider>
包裹即可
更容易維護和測試
完整的主題切換範例 import React , { createContext, useContext, useState } from 'react' ;const ThemeContext = createContext ();function ThemeProvider ({ children } ) { const [theme, setTheme] = useState ('light' ); const toggleTheme = ( ) => { setTheme (prevTheme => prevTheme === 'light' ? 'dark' : 'light' ); }; const value = { theme, toggleTheme }; return ( <ThemeContext.Provider value ={value} > {children} </ThemeContext.Provider > ); } function useTheme ( ) { const context = useContext (ThemeContext ); if (!context) { throw new Error ('useTheme 必須在 ThemeProvider 內部使用' ); } return context; } function Header ( ) { const { theme, toggleTheme } = useTheme (); return ( <header style ={{ backgroundColor: theme === 'light' ? '#fff ' : '#333 ', color: theme === 'light' ? '#333 ' : '#fff ', padding: '1rem ', borderBottom: '1px solid #ccc ' }}> <h1 > 我的應用程式</h1 > <button onClick ={toggleTheme} style ={{ padding: '0.5rem 1rem ', border: '1px solid #ccc ', borderRadius: '4px ', cursor: 'pointer ' }} > 切換至 {theme === 'light' ? '深色' : '淺色'} 主題 </button > </header > ); } function Content ( ) { const { theme } = useTheme (); return ( <main style ={{ backgroundColor: theme === 'light' ? '#f5f5f5 ' : '#222 ', color: theme === 'light' ? '#333 ' : '#fff ', padding: '2rem ', minHeight: '200px ' }}> <p > 這是內容區域,目前主題是:<strong > {theme}</strong > </p > <p > Context 讓我們可以在任何深度的子元件中存取主題狀態!</p > </main > ); } function App ( ) { return ( <ThemeProvider > <Header /> <Content /> </ThemeProvider > ); }
Context 解決的問題:
避免 props drilling :不需要透過多層元件傳遞資料
集中管理 :相關的狀態和邏輯集中在一起
易於維護 :修改資料結構時,只需要修改 Provider
效能優化 :只有訂閱的元件會重新渲染
Context 使用建議:
自定義 Hook :為 Context 創建自定義 Hook,提供更好的錯誤處理
邏輯分組 :將相關的 state 和函式群組到同一個 Context 中
避免過度渲染 :避免將經常變化的值放在高層的 Context 中,會導致不必要的重新渲染
適當拆分 :考慮將 Context 拆分,避免單一 Context 過於龐大
預設值設定 :為 Context 提供有意義的預設值,提升開發體驗
常見錯誤:
忘記用 Provider 包裝元件
在 Provider 外部使用 Context
將所有狀態都放在同一個 Context 中
沒有為 Context 提供預設值
useRef useRef
是 React 提供的一個 Hook,用來建立一個「可變的容器物件」。這個物件有一個 .current
屬性,可以用來「保存資料」或「取得 DOM 元素的參考」。 對初學者來說,可以把 useRef
想像成一個「不會因為重新渲染而消失的小盒子」,你可以把任何東西放進去(例如數字、物件、函式、甚至 DOM 元素),而且每次元件重新渲染時,這個盒子裡的內容都會被保留。
主要用途
存取 DOM 元素 :你可以用 useRef
取得 <input>
、<div>
等 DOM 元素的參考,像是自動聚焦、捲動到某個區塊等。
保存資料(不觸發重新渲染) :如果你想在多次渲染之間保存某個值,但又不希望這個值改變時觸發畫面更新(不像 useState
),就可以用 useRef
。
小技巧:useRef 與 useState 差異
useState
:資料改變會觸發元件重新渲染,適合用來顯示在畫面上的狀態
useRef
:資料改變不會 觸發重新渲染,適合保存「暫存值」或「DOM 參考」
DOM 元素存取 在 React 中,useRef
最常見的用途之一就是「存取 DOM 元素」。你可以將 useRef
建立的參考物件(ref)綁定到 JSX 元素的 ref
屬性上,這樣就能在程式中直接操作該 DOM 元素。例如:自動聚焦輸入框、捲動到特定區塊、或直接讀取/修改 DOM 屬性。
重要觀念:為什麼需要用 useRef 綁定 DOM?
很多初學者會有這個疑問:「是不是因為重新渲染時 DOM 會變成新的,所以才需要用 useRef 綁定?」,其實這是錯誤的理解。正確的理解是:
React 會自動管理 DOM 更新 :當元件重新渲染時,React 會「智能更新」現有的 DOM,而不是每次都建立全新的 DOM。同一個 <input>
元素在多次渲染中,通常還是同一個實際的 DOM 節點。
useRef 的真正目的 :不是為了「防止 DOM 變新的」,而是為了「在 React 元件中取得穩定的 DOM 參考」,讓你可以:
在任何時候存取到正確的 DOM 元素
呼叫原生 DOM 方法(如 .focus()
、.scrollIntoView()
)
讀取 DOM 屬性(如 .offsetWidth
、.value
)
為什麼不用 document.querySelector()
?
在 React 中,直接用 document.querySelector()
是不推薦的,因為:
你需要給元素加上 id
或 class
,容易造成命名衝突
在服務端渲染(SSR)時,document
不存在
不符合 React 的「宣告式」設計理念
useRef
提供了一個「React 式」的方式來取得 DOM 參考
比喻說明:
傳統 JS:用「門牌號碼」(id/class)去找房子(DOM)
React useRef:直接拿到房子的「鑰匙」(ref),隨時都能開門進去
ref 物件的 .current
屬性
useRef()
回傳的物件有一個 .current
屬性,這個屬性會指向你綁定的 DOM 元素
在元件首次渲染時,.current
是 null
(或你設定的初始值)
當元素渲染到畫面上後,React 會自動將 DOM 元素賦值給 .current
即使元件重新渲染,.current
仍然會指向同一個 DOM 元素(除非元素被移除)
useRef DOM 存取 import React , { useRef, useEffect } from 'react' ;function FocusInput ( ) { const inputRef = useRef (null ); useEffect (() => { inputRef.current .focus (); }, []); const handleButtonClick = ( ) => { inputRef.current .focus (); }; return ( <div > <input ref ={inputRef} type ="text" placeholder ="點擊按鈕會 focus 到這裡" style ={{ padding: '0.5rem ', margin: '0.5rem ' }} /> <button onClick ={handleButtonClick} style ={{ padding: '0.5rem 1rem ', margin: '0.5rem ' }} > Focus Input </button > </div > ); }
保存數值(不觸發重新渲染) 在 React 中,useRef
不僅可以用來存放 DOM 參考,也可以用來保存「任何可變的資料」而且不會觸發元件重新渲染 。這很適合用來保存像是計時器 ID、前一次的資料、或是其他你不希望影響畫面的狀態。
小技巧:useRef vs useState 差異
useState
:資料變動會觸發元件重新渲染,適合用來管理「畫面要顯示」的狀態
useRef
:資料變動不會 觸發重新渲染,適合保存「不影響畫面」的資料(例如 setInterval 的 ID、前一次的值等)
範例說明: 以下是一個簡單的計時器(Timer)元件,利用 useRef
來保存 setInterval 的 ID,避免每次重新渲染都重設計時器。
intervalRef
用來保存 setInterval 回傳的 ID
當點擊「開始」時,啟動計時器並保存 ID
當點擊「停止」或元件卸載時,清除計時器
這樣做可以避免 setInterval 重複啟動或記憶體洩漏
useRef 保存數值 import React , { useState, useRef, useEffect } from 'react' ;function TimerComponent ( ) { const [seconds, setSeconds] = useState (0 ); const [isRunning, setIsRunning] = useState (false ); const intervalRef = useRef (null ); useEffect (() => { if (isRunning) { intervalRef.current = setInterval (() => { setSeconds (prevSeconds => prevSeconds + 1 ); }, 1000 ); } else { clearInterval (intervalRef.current ); } return () => clearInterval (intervalRef.current ); }, [isRunning]); const handleStart = ( ) => setIsRunning (true ); const handleStop = ( ) => setIsRunning (false ); const handleReset = ( ) => { setSeconds (0 ); setIsRunning (false ); }; return ( <div style ={{ textAlign: 'center ', padding: '2rem ' }}> <h2 > 計時器:{seconds} 秒</h2 > <div > <button onClick ={handleStart} disabled ={isRunning} style ={{ margin: '0.5rem ', padding: '0.5rem 1rem ' }} > 開始 </button > <button onClick ={handleStop} disabled ={!isRunning} style ={{ margin: '0.5rem ', padding: '0.5rem 1rem ' }} > 停止 </button > <button onClick ={handleReset} style ={{ margin: '0.5rem ', padding: '0.5rem 1rem ' }} > 重設 </button > </div > </div > ); }
useRef vs useState 的差異:
useRef :.current
值改變不會觸發重新渲染,適合存儲 DOM 參考、計時器 ID
useState :值改變會觸發重新渲染,適合需要更新 UI 的狀態
使用時機 :當你需要保存值但不希望觸發重新渲染時,使用 useRef
注意事項:
useRef
的 .current
屬性是可變的,可以直接修改
不要在渲染期間讀取或寫入 ref.current
,這會導致不可預測的行為
適合用於保存前一次的 props 或 state 值
效能優化 Hooks 這些 Hooks 主要用於優化應用程式效能,避免不必要的重新計算和重新渲染。
useCallback useCallback
是一個用來「記憶化函式」的 Hook。它可以讓函式在多次渲染之間保持相同的參考(reference),避免不必要的函式重新創建,主要用於效能優化。
前置知識:React.memo 在了解 useCallback
之前,必須先認識 React.memo
。它是 React 提供的效能優化工具,可以避免子元件不必要的重新渲染。
React.memo 的作用 在 React 中,父元件每次重新渲染時,預設所有子元件也會一併重新渲染,無論子元件的 props 是否有變動。React.memo
是一個高階元件(Higher-Order Component, HOC),能夠「記憶」子元件的 props,僅在 props 發生變化時才觸發子元件重新渲染。
你只需將子元件包裹在 React.memo
外層,React 就會自動幫你比較 props,只有當 props 內容真的不同時,才會重新渲染該子元件,從而有效減少不必要的渲染、提升效能。
React.memo 完整對比範例 import React , { useState } from 'react' ;function NormalChild ( ) { console .log ('❌ NormalChild 重新渲染了' ); return ( <div > <h4 > 普通元件(無 memo)</h4 > <p > 我沒有使用 React.memo,每次父元件渲染都會跟著渲染</p > </div > ); } const MemoChildWithoutProps = React .memo (function MemoChildNoProp ( ) { console .log ('✅ MemoChildWithoutProps 重新渲染了' ); return ( <div > <h4 > Memo 元件(無 props)</h4 > <p > 我使用了 React.memo 且沒有 props,父元件渲染時我不會重新渲染</p > </div > ); }); const MemoChildWithProps = React .memo (function MemoChildWithProp ({ userName } ) { console .log ('🔍 MemoChildWithProps 重新渲染了' ); return ( <div > <h4 > Memo 元件(有 props)</h4 > <p > 使用者名稱:{userName}</p > <p > 我使用了 React.memo,只有當 userName 改變時才會重新渲染</p > </div > ); }); function Parent ( ) { const [count, setCount] = useState (0 ); const [userName, setUserName] = useState ('Loki' ); return ( <div > <h2 > 父元件狀態</h2 > <p > 計數:{count}</p > <p > 使用者名稱:{userName}</p > <button onClick ={() => setCount(count + 1)}> 增加計數(不影響 userName) </button > <button onClick ={() => setUserName(userName === 'Loki' ? 'Thor' : 'Loki')}> 切換使用者名稱 </button > <hr /> {/* 1. 普通元件:每次都會重新渲染 */} <NormalChild /> <hr /> {/* 2. Memo 元件(無 props):永遠不會重新渲染 */} <MemoChildWithoutProps /> <hr /> {/* 3. Memo 元件(有 props):只有 userName 改變時才會重新渲染 */} <MemoChildWithProps userName ={userName} /> </div > ); } export default Parent ;
測試步驟與執行結果:
步驟 1:初次載入頁面
❌ NormalChild 重新渲染了 ✅ MemoChildWithoutProps 重新渲染了 🔍 MemoChildWithProps 重新渲染了
說明:所有元件都會進行初次渲染
步驟 2:點擊「增加計數」按鈕
說明:
NormalChild
:沒有使用 memo,跟著父元件一起渲染 ❌
MemoChildWithoutProps
:使用 memo 且無 props,不需要重新渲染 ✅
MemoChildWithProps
:使用 memo,props 沒變(userName
還是 ‘Loki’),不需要重新渲染 ✅
步驟 3:點擊「切換使用者名稱」按鈕
❌ NormalChild 重新渲染了 🔍 MemoChildWithProps 重新渲染了
說明:
NormalChild
:沒有使用 memo,跟著父元件一起渲染 ❌
MemoChildWithoutProps
:使用 memo 且無 props,不需要重新渲染 ✅
MemoChildWithProps
:props 改變了(userName
從 ‘Loki’ 變成 ‘Thor’),需要重新渲染 ⚠️
React.memo 的運作原理:
舊的 props : {} 新的 props : {} 舊的 props : { userName : 'Loki' } 新的 props : { userName : 'Loki' } 'Loki' === 'Loki' 舊的 props : { userName : 'Loki' } 新的 props : { userName : 'Thor' } 'Loki' === 'Thor'
React.memo 的優點:
可以避免子元件不必要的重新渲染
當子元件的渲染成本很高時(複雜的 UI 或大量資料處理),可以顯著提升效能
會自動進行淺比較,只在 props 改變時才重新渲染
對於基本類型的 props(string、number、boolean),淺比較運作良好
children 也是 prop 在 React 中,children
是一個特殊的 prop,代表元件標籤之間的內容。
<MemoChild >Hello </MemoChild > <MemoChild children ="Hello" /> function MemoChild (props ) { console .log (props.children ); }
React.memo 也會檢查 children:
children 作為 prop 的範例 import React , { useState } from 'react' ;const MemoChildWithChildren = React .memo (function MemoChild ({ children } ) { console .log ('MemoChildWithChildren 重新渲染了' ); return <div > {children}</div > ; }); function Parent ( ) { const [count, setCount] = useState (0 ); return ( <div > <button onClick ={() => setCount(count + 1)}>計數:{count}</button > {/* children 是固定的字串,不會重新渲染 */} <MemoChildWithChildren > 固定的文字內容 </MemoChildWithChildren > {/* children 包含 count,會重新渲染 */} <MemoChildWithChildren > 計數:{count} </MemoChildWithChildren > </div > ); }
執行結果:
點擊按鈕時,第一個 MemoChildWithChildren
(固定文字)不會重新渲染
第二個 MemoChildWithChildren
(包含 count)會重新渲染,因為 children 改變了
結論: children
是 prop 的一部分,React.memo 會一併檢查它是否改變。
React.memo 的限制:淺比較問題 React.memo
使用「淺比較」(Shallow Comparison)來檢查 props 是否改變,也就是用 ===
運算子來比較。這在傳遞函式 作為 props 時會產生問題。
函式導致的重新渲染問題 import React , { useState } from 'react' ;const Button = React .memo (({ onClick, children } ) => { console .log (`Button "${children} " 重新渲染了!` ); return <button onClick ={onClick} > {children}</button > ; }); function Counter ( ) { const [count, setCount] = useState (0 ); const [otherState, setOtherState] = useState (0 ); const increment = ( ) => { setCount (count + 1 ); }; return ( <div > <h2 > 計數:{count}</h2 > <h3 > 其他狀態:{otherState}</h3 > {/* 即使用了 React.memo,increment 每次都是新的函式,Button 還是會重新渲染 */} <Button onClick ={increment} > 增加計數</Button > {/* 當這個按鈕被點擊時,otherState 改變,導致 Counter 重新渲染 */} <button onClick ={() => setOtherState(otherState + 1)}> 改變其他狀態 </button > </div > ); } export default Counter ;
當點擊「改變其他狀態」時,Button 仍會重新渲染。讓我們一步步分析為什麼 Button
會重新渲染:
第一次渲染:
const increment = ( ) => { setCount (count + 1 ); }; <Button onClick ={increment} >
點擊「改變其他狀態」後:
const increment = ( ) => { setCount (count + 1 ); }; <Button onClick ={increment} > // React.memo 檢查:0x001 === 0x002 ? → false // 結論:props 改變了,需要重新渲染 Button
核心問題:
每次 Counter
重新渲染時,increment
函式都會被重新創建
雖然函式的程式碼相同,但每次都是新的函式物件 (不同的記憶體位置)
React.memo
使用 ===
進行淺比較:新函式 === 舊函式
結果是 false
所以 React.memo
認為 props 改變了,導致 Button
重新渲染
即使我們沒有點擊「增加計數」按鈕,只是改變了其他狀態,Button
還是會不必要地重新渲染!
你可能會想:「重新渲染一個按鈕有什麼關係?」
在這個簡單範例中確實影響不大,但在實際應用中:
子元件可能包含複雜的 UI 結構(數百個元素)
子元件可能有複雜的計算邏輯
可能有很多個子元件同時重新渲染
會造成頁面卡頓、效能下降
這就是為什麼需要 useCallback
! 它可以讓函式在依賴項不變時保持相同的參考,讓 React.memo
的優化能正常運作。
useCallback 語法 useCallback
可以解決上述問題,讓函式在依賴項不變時保持相同的參考。
語法結構:
const memoizedCallback = useCallback ( () => { }, [依賴項 1 , 依賴項 2 , ...] );
參數說明:
第一個參數 :要記憶化的函式
第二個參數 :依賴項陣列(Dependency Array)
當依賴項改變時,會重新創建函式
當依賴項不變時,會返回上次記憶的函式(相同的參考)
使用 useCallback 讓我們用 useCallback
改善前面的問題:
使用 useCallback 優化 import React , { useState, useCallback } from 'react' ;const Button = React .memo (({ onClick, children } ) => { console .log (`Button "${children} " 重新渲染了!` ); return <button onClick ={onClick} > {children}</button > ; }); function Counter ( ) { const [count, setCount] = useState (0 ); const [otherState, setOtherState] = useState (0 ); const increment = useCallback (() => { setCount (prevCount => prevCount + 1 ); }, []); return ( <div > <h2 > 計數:{count}</h2 > <h3 > 其他狀態:{otherState}</h3 > {/* 現在 increment 的參考不會改變,Button 不會重新渲染 */} <Button onClick ={increment} > 增加計數</Button > <button onClick ={() => setOtherState(otherState + 1)}> 改變其他狀態 </button > </div > ); } export default Counter ;
效果:
點擊「改變其他狀態」按鈕時,Counter
重新渲染
increment
函式保持相同的參考(因為使用了 useCallback
)
Button
子元件不會重新渲染(React.memo
檢查到 onClick
prop 沒變)
重點: 使用 useCallback
+ React.memo
可以有效避免子元件不必要的重新渲染,提升應用程式效能。
理解依賴項陣列 依賴項陣列是 useCallback
最重要的概念,它決定了函式何時需要重新創建。
案例 1:空依賴項陣列 []
空陣列表示「沒有任何依賴項」,函式只會在元件初次渲染時創建一次,之後永遠保持相同的參考。
空依賴項 import React , { useState, useCallback } from 'react' ;function Example1 ( ) { const [count, setCount] = useState (0 ); const handleClick = useCallback (() => { console .log ('按鈕被點擊' ); setCount (prevCount => prevCount + 1 ); }, []); return ( <div > <p > 計數:{count}</p > <button onClick ={handleClick} > 增加</button > </div > ); }
案例 2:有依賴項的情況 當你的函式內部會用到外部的 state 或 props(例如 todos
、filter
),就必須將這些變數加入依賴項陣列。這樣只有當這些依賴發生變化時,useCallback
才會重新產生新的函式,確保每次取得的都是最新的資料,避免出現舊值或預期外的行為。
以下以 Todo 列表為例,說明如何正確設置依賴項。因為函式內部使用了 todos
和 filter
,所以必須將它們加入依賴項陣列。當這些值改變時,函式才需要重新創建以獲取最新的值。
有依賴項 import React , { useState, useCallback } from 'react' ;function TodoList ( ) { const [todos, setTodos] = useState ([]); const [filter, setFilter] = useState ('all' ); const getFilteredTodos = useCallback (() => { console .log ('重新創建 getFilteredTodos 函式' ); if (filter === 'active' ) { return todos.filter (todo => !todo.completed ); } if (filter === 'completed' ) { return todos.filter (todo => todo.completed ); } return todos; }, [todos, filter]); return ( <div > <select value ={filter} onChange ={(e) => setFilter(e.target.value)}> <option value ="all" > 全部</option > <option value ="active" > 進行中</option > <option value ="completed" > 已完成</option > </select > <ul > {getFilteredTodos().map(todo => ( <li key ={todo.id} > {todo.text}</li > ))} </ul > </div > ); }
案例 3:依賴項錯誤的問題 在使用 useCallback
時,常見的錯誤是忘記將函式內部用到的外部變數(如 state 或 props)加入依賴項陣列。這會導致函式「記憶」了舊的變數值,產生預期外的行為。
錯誤的依賴項 import React , { useState, useCallback } from 'react' ;function Counter ( ) { const [count, setCount] = useState (0 ); const showCount = useCallback (() => { alert (`目前計數:${count} ` ); }, []); return ( <div > <p > 計數:{count}</p > <button onClick ={() => setCount(count + 1)}>增加</button > <button onClick ={showCount} > 顯示計數</button > </div > ); }
正確做法是:只要在 callback 內部用到的外部變數,都必須出現在依賴陣列中 。這樣才能確保每次相關變數變動時,callback 也會更新,避免閉包陷阱。
正確的做法 import React , { useState, useCallback } from 'react' ;function Counter ( ) { const [count, setCount] = useState (0 ); const showCount = useCallback (() => { alert (`目前計數:${count} ` ); }, [count]); return ( <div > <p > 計數:{count}</p > <button onClick ={() => setCount(count + 1)}>增加</button > <button onClick ={showCount} > 顯示計數</button > </div > ); }
重要提醒:
函式內使用的所有外部變數(state、props、其他變數)都應該加入依賴項陣列
缺少依賴項會導致閉包陷阱(Stale Closure),函式捕獲的是舊的值
React 開發工具會提示缺少的依賴項,務必注意這些警告
與 useEffect 搭配使用 useCallback
常用於配合 useEffect
,避免 effect 不必要的重新執行。這個範例展示了如何結合 useCallback
與 useEffect
來優化搜尋功能。
useCallback 的作用: 將 searchUsers
這個搜尋函式記憶化,只有當 query
(搜尋關鍵字)改變時才會重新創建。這樣可以確保 searchUsers
的參考在 query
沒變時保持不變,避免因為函式參考改變導致 useEffect
重複執行。
useEffect 的作用: 當 searchUsers
(也就是 query
)改變時,延遲 500ms 執行搜尋(實現防抖效果,減少 API 請求次數)。如果在 500ms 內又輸入新字元,會先清除前一次的計時器,只有使用者停止輸入一段時間才會真正發送請求。
當你需要在 effect 內呼叫一個 callback 函式,且這個函式依賴某些 state/props 時,建議用 useCallback
包裹,並將 callback 作為 effect 的依賴項。這樣可以避免每次渲染都重新創建函式,導致 effect 不斷重複執行,提升效能與可控性。
useCallback 與 useEffect import React , { useState, useCallback, useEffect } from 'react' ;function SearchUser ( ) { const [query, setQuery] = useState ('' ); const [results, setResults] = useState ([]); const searchUsers = useCallback (async () => { if (!query) return ; console .log (`搜尋:${query} ` ); const response = await fetch (`/api/users?q=${query} ` ); const data = await response.json (); setResults (data); }, [query]); useEffect (() => { const timer = setTimeout (searchUsers, 500 ); return () => clearTimeout (timer); }, [searchUsers]); return ( <div > <input value ={query} onChange ={(e) => setQuery(e.target.value)} placeholder="搜尋使用者" /> <ul > {results.map(user => ( <li key ={user.id} > {user.name}</li > ))} </ul > </div > ); }
為什麼需要 useCallback?
如果不用 useCallback
,searchUsers
每次渲染都會重新創建
useEffect
的依賴項 searchUsers
會不斷改變
導致 effect 不斷重新執行,造成效能問題
何時該使用 useCallback? 適合使用的情況:
將函式傳遞給使用 React.memo
優化的子元件
函式作為 useEffect
、useCallback
、useMemo
等 Hook 的依賴項
函式在自訂 Hook 中被返回,並可能被其他元件當作依賴項使用
不需要使用的情況:
函式只在元件內部使用,沒有傳遞給子元件
函式不是任何 Hook 的依賴項
子元件沒有使用 React.memo
優化
注意: 過度使用 useCallback
反而會增加記憶體開銷和複雜度,只在真正需要優化時使用。
useMemo useMemo
是一個用來「記憶化計算結果」的 Hook。它可以讓計算結果在多次渲染之間被重複使用,避免不必要的重複計算,主要用於效能優化。需要注意的是,useMemo
記憶的是「值」而非「元件」,與 React.memo
記憶元件渲染結果的用途不同。
為什麼需要 useMemo? 在 React 中,每次元件重新渲染時,所有的變數和計算都會重新執行。如果有昂貴的計算(複雜運算、大量資料處理),每次渲染都重新計算會造成效能問題。
沒有使用 useMemo 的問題 import React , { useState } from 'react' ;function ProductList ( ) { const [filter, setFilter] = useState ('' ); const [sortOrder, setSortOrder] = useState ('asc' ); const products = Array .from ({ length : 10000 }, (_, i ) => ({ id : i, name : `商品 ${i} ` , price : Math .floor (Math .random () * 1000 ), category : i % 3 === 0 ? '電子' : i % 3 === 1 ? '服飾' : '食品' })); console .log ('開始計算。..' ); const filteredProducts = products .filter (p => p.name .includes (filter)) .sort ((a, b ) => sortOrder === 'asc' ? a.price - b.price : b.price - a.price ); console .log ('計算完成!' ); return ( <div > <input value ={filter} onChange ={(e) => setFilter(e.target.value)} placeholder="搜尋商品" /> <button onClick ={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}> 排序:{sortOrder === 'asc' ? '低到高' : '高到低'} </button > <p > 找到 {filteredProducts.length} 個商品</p > <ul > {filteredProducts.slice(0, 20).map(p => ( <li key ={p.id} > {p.name} - ${p.price}</li > ))} </ul > </div > ); }
問題: 即使只是輸入搜尋關鍵字(改變 filter
),整個 10000 筆資料的過濾和排序運算都會重新執行,造成明顯的卡頓。
核心問題:
每次渲染時,filteredProducts
的計算都會重新執行
即使 products
、filter
、sortOrder
都沒變,還是會重新計算
大量資料的過濾和排序非常耗時,導致輸入延遲、畫面卡頓
useMemo 語法 useMemo
可以解決上述問題,讓計算結果在依賴項不變時被重複使用。
語法結構:
const memoizedValue = useMemo ( () => { return 計算結果; }, [依賴項 1 , 依賴項 2 , ...] );
參數說明:
第一個參數 :計算函式,返回要記憶化的值
第二個參數 :依賴項陣列(Dependency Array)
當依賴項改變時,會重新執行計算
當依賴項不變時,會返回上次記憶的結果(不重新計算)
重要: useMemo
記憶的是「計算結果」(值),而 useCallback
記憶的是「函式」本身。
使用 useMemo 優化 讓我們用 useMemo
改善前面的問題:
使用 useMemo 優化 import React , { useState, useMemo } from 'react' ;function ProductList ( ) { const [filter, setFilter] = useState ('' ); const [sortOrder, setSortOrder] = useState ('asc' ); const products = Array .from ({ length : 10000 }, (_, i ) => ({ id : i, name : `商品 ${i} ` , price : Math .floor (Math .random () * 1000 ), category : i % 3 === 0 ? '電子' : i % 3 === 1 ? '服飾' : '食品' })); const filteredProducts = useMemo (() => { console .log ('開始計算。..' ); const result = products .filter (p => p.name .includes (filter)) .sort ((a, b ) => sortOrder === 'asc' ? a.price - b.price : b.price - a.price ); console .log ('計算完成!' ); return result; }, [products, filter, sortOrder]); return ( <div > <input value ={filter} onChange ={(e) => setFilter(e.target.value)} placeholder="搜尋商品" /> <button onClick ={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}> 排序:{sortOrder === 'asc' ? '低到高' : '高到低'} </button > <p > 找到 {filteredProducts.length} 個商品</p > <ul > {filteredProducts.slice(0, 20).map(p => ( <li key ={p.id} > {p.name} - ${p.price}</li > ))} </ul > </div > ); } export default ProductList ;
效果:
初次渲染時會執行計算
之後只有當 filter
或 sortOrder
改變時才會重新計算
其他原因造成的重新渲染(例如父元件更新)不會觸發計算
輸入更流暢,沒有卡頓
重點: 使用 useMemo
可以避免昂貴的計算在每次渲染時都重新執行,顯著提升效能。
理解依賴項陣列 依賴項陣列決定了何時需要重新計算。
案例 1:空依賴項陣列 []
空依賴項 import React , { useState, useMemo } from 'react' ;function Example ( ) { const [count, setCount] = useState (0 ); const expensiveValue = useMemo (() => { console .log ('執行昂貴的計算' ); let result = 0 ; for (let i = 0 ; i < 1000000000 ; i++) { result += i; } return result; }, []); return ( <div > <p > 計算結果:{expensiveValue}</p > <p > 計數:{count}</p > <button onClick ={() => setCount(count + 1)}>增加計數</button > </div > ); }
說明: 空陣列表示「沒有依賴項」,計算只會在元件初次渲染時執行一次,之後永遠返回同一個結果。
案例 2:有依賴項的情況 有依賴項 import React , { useState, useMemo } from 'react' ;function ShoppingCart ( ) { const [items, setItems] = useState ([ { id : 1 , name : '商品 A' , price : 100 , quantity : 2 }, { id : 2 , name : '商品 B' , price : 200 , quantity : 1 }, { id : 3 , name : '商品 C' , price : 150 , quantity : 3 } ]); const [discount, setDiscount] = useState (0 ); const totalPrice = useMemo (() => { console .log ('計算總價。..' ); const subtotal = items.reduce ((sum, item ) => sum + item.price * item.quantity , 0 ); return subtotal * (1 - discount / 100 ); }, [items, discount]); return ( <div > <h3 > 購物車</h3 > <ul > {items.map(item => ( <li key ={item.id} > {item.name} - ${item.price} x {item.quantity} </li > ))} </ul > <div > <label > 折扣(%):</label > <input type ="number" value ={discount} onChange ={(e) => setDiscount(Number(e.target.value))} /> </div > <h2 > 總價:${totalPrice.toFixed(2)}</h2 > </div > ); }
說明: 因為計算總價需要用到 items
和 discount
,所以必須將它們加入依賴項陣列。當這些值改變時,總價才需要重新計算。
useMemo vs useCallback 這兩個 Hook 容易混淆,讓我們清楚比較:
useMemo(() => 計算結果,[依賴項])
→ 記憶計算結果
useCallback(函式,[依賴項])
→ 記憶函式本身
useCallback(fn, deps)
等於 useMemo(() => fn, deps)
useMemo vs useCallback import React , { useMemo, useCallback } from 'react' ;function Example ( ) { const [count, setCount] = useState (0 ); const expensiveValue = useMemo (() => { return count * 2 ; }, [count]); const handleClick = useCallback (() => { setCount (count + 1 ); }, [count]); console .log (typeof expensiveValue); console .log (typeof handleClick); }
useMemo 與 React.memo 搭配使用 useMemo
可以配合 React.memo
避免子元件不必要的重新渲染:
useMemo 與 React.memo 搭配 import React , { useState, useMemo } from 'react' ;const ProductList = React .memo (({ products } ) => { console .log ('ProductList 重新渲染' ); return ( <ul > {products.map(p => ( <li key ={p.id} > {p.name}</li > ))} </ul > ); }); function Shop ( ) { const [count, setCount] = useState (0 ); const [filter, setFilter] = useState ('' ); const allProducts = [ { id : 1 , name : '蘋果' }, { id : 2 , name : '香蕉' }, { id : 3 , name : '橘子' } ]; const filteredProducts = useMemo (() => { return allProducts.filter (p => p.name .includes (filter)); }, [filter]); return ( <div > <p > 計數:{count}</p > <button onClick ={() => setCount(count + 1)}>增加計數</button > <input value ={filter} onChange ={(e) => setFilter(e.target.value)} placeholder="搜尋商品" /> {/* 因為 filteredProducts 使用 useMemo,只有 filter 改變時才會重新渲染 */} <ProductList products ={filteredProducts} /> </div > ); }
說明:
點擊「增加計數」時,count
改變但 filter
沒變
filteredProducts
返回相同的陣列參考(因為 useMemo
)
ProductList
的 props 沒變,不會重新渲染(因為 React.memo
)
何時該使用 useMemo? 適合使用的情況:
昂貴的計算 :複雜的數學運算、大量資料處理、排序、過濾
避免物件/陣列重新創建 :配合 React.memo
使用
計算依賴項明確 :計算結果只依賴特定的值
不需要使用的情況:
簡單計算 :a + b
、字串拼接等低成本運算
計算很快 :執行時間小於 1ms 的運算
每次都需要最新值 :依賴項頻繁變動
注意: 過度使用 useMemo
反而會增加記憶體開銷和程式碼複雜度。只在真正需要優化時使用,不要為了優化而優化。
重要提醒:
計算函式內使用的所有外部變數(state、props)都應該加入依賴項陣列
缺少依賴項會導致使用舊的值,產生 bug
useMemo
是效能優化,不應該用於保證功能正確性
React 可能會在某些情況下丟棄記憶的值(例如記憶體不足),所以計算函式必須是純函式
useDeferredValue useDeferredValue
是 React 18 引入的 Hook,可以將某個值的更新「延遲」到較不緊急的時機執行。它的主要作用是在快速輸入或頻繁更新時,優先保持 UI 的響應性,延後處理耗時的運算或渲染。
為什麼需要 useDeferredValue? 在某些情況下,我們會遇到「輸入卡頓」的問題:使用者在輸入框打字時,因為每次輸入都觸發大量計算或渲染,導致輸入延遲、體驗變差。
沒有使用 useDeferredValue 的問題 import React , { useState } from 'react' ;function SearchResults ({ query } ) { const results = []; for (let i = 0 ; i < 10000 ; i++) { if (`項目 ${i} ` .includes (query)) { results.push ({ id : i, name : `項目 ${i} ` }); } } console .log (`渲染 ${results.length} 個搜尋結果` ); return ( <ul > {results.slice(0, 100).map(item => ( <li key ={item.id} > {item.name}</li > ))} </ul > ); } function SearchApp ( ) { const [query, setQuery] = useState ('' ); return ( <div > <input type ="text" placeholder ="搜尋。.." value ={query} onChange ={(e) => setQuery(e.target.value)} /> {/* ❌ 問題:每次輸入都立即觸發 10000 筆資料的搜尋和渲染 */} <SearchResults query ={query} /> </div > ); }
問題分析:
使用者在輸入框快速打字:例如輸入 “react”
每打一個字母(r → e → a → c → t),query
state 都會更新
每次更新都立即觸發 SearchResults
重新渲染
搜尋 10000 筆資料非常耗時,導致輸入框卡頓、延遲
核心問題:
輸入框的更新(高優先級)被耗時的搜尋運算(低優先級)阻塞
使用者會感受到明顯的輸入延遲,體驗很差
即使使用 useMemo
也無法解決,因為每次輸入 query
確實改變了,必須重新計算
useDeferredValue 語法 useDeferredValue
可以解決這個問題,它會告訴 React:「這個值的更新可以延後處理,先處理更重要的事情(例如輸入框更新)」。
語法結構:
const deferredValue = useDeferredValue (value);
參數說明:
參數 :要延遲更新的值(通常是 state)
返回值 :延遲版本的值
初次渲染時,deferredValue
等於 value
更新時,deferredValue
會「延遲」更新,優先處理其他更新
運作原理:
const [query, setQuery] = useState ('' );const deferredQuery = useDeferredValue (query);
使用 useDeferredValue 優化 讓我們用 useDeferredValue
改善前面的問題:
使用 useDeferredValue 優化 import React , { useState, useDeferredValue, useMemo } from 'react' ;function SearchResults ({ query } ) { const results = useMemo (() => { const items = []; for (let i = 0 ; i < 10000 ; i++) { if (`項目 ${i} ` .includes (query)) { items.push ({ id : i, name : `項目 ${i} ` }); } } console .log (`計算完成:找到 ${items.length} 個結果` ); return items; }, [query]); return ( <div > <p > 找到 {results.length} 個結果</p > <ul > {results.slice(0, 100).map(item => ( <li key ={item.id} > {item.name}</li > ))} </ul > </div > ); } function SearchApp ( ) { const [query, setQuery] = useState ('' ); const deferredQuery = useDeferredValue (query); return ( <div > <h2 > 搜尋功能</h2 > <input type ="text" placeholder ="搜尋。.." value ={query} onChange ={(e) => setQuery(e.target.value)} /> <div > <p > 當前輸入:{query}</p > <p > 延遲查詢:{deferredQuery}</p > </div > {/* 使用延遲的值進行搜尋,不會阻塞輸入框 */} <SearchResults query ={deferredQuery} /> </div > ); } export default SearchApp ;
效果對比:
沒有使用 useDeferredValue:
快速輸入 “123” 時
每個字元都會立即觸發搜尋
輸入框卡頓、延遲明顯
使用 useDeferredValue:
快速輸入 “123” 時
輸入框立即響應,顯示 “1” → “12” → “123”(流暢)
搜尋結果稍後更新,使用延遲的值
輸入體驗流暢,沒有卡頓
重點: useDeferredValue
讓 React 知道某些更新可以延後處理,優先保持 UI 的響應性,提升使用者體驗。
顯示載入狀態 可以透過比較 value
和 deferredValue
來判斷是否正在延遲更新,顯示載入提示:
顯示載入狀態 import React , { useState, useDeferredValue, useMemo } from 'react' ;function SearchResults ({ query, isPending } ) { const results = useMemo (() => { const items = []; for (let i = 0 ; i < 10000 ; i++) { if (`項目 ${i} ` .includes (query)) { items.push ({ id : i, name : `項目 ${i} ` }); } } return items; }, [query]); return ( <div style ={{ opacity: isPending ? 0.5 : 1 }}> {isPending && <p > 搜尋中。..</p > } <p > 找到 {results.length} 個結果</p > <ul > {results.slice(0, 100).map(item => ( <li key ={item.id} > {item.name}</li > ))} </ul > </div > ); } function SearchApp ( ) { const [query, setQuery] = useState ('' ); const deferredQuery = useDeferredValue (query); const isPending = query !== deferredQuery; return ( <div > <input type ="text" placeholder ="搜尋。.." value ={query} onChange ={(e) => setQuery(e.target.value)} /> <SearchResults query ={deferredQuery} isPending ={isPending} /> </div > ); }
說明:
isPending
為 true
表示延遲更新尚未完成
可以降低透明度或顯示載入提示,讓使用者知道正在處理
不會阻塞輸入,使用者可以繼續打字
useDeferredValue vs 防抖(Debounce) 什麼是防抖(Debounce)?
防抖(Debounce)是一種常見的前端優化技巧,主要用來「限制某個動作的觸發頻率」。舉例來說,當使用者在搜尋框輸入文字時,每輸入一個字元就發送一次 API 請求,會造成伺服器壓力過大。防抖的做法是:只有當使用者停止輸入一段時間後,才真正執行搜尋 。如果在這段時間內又有新的輸入,計時器會重新開始,直到使用者暫停輸入才會觸發。
常見應用場景:
搜尋建議(Autocomplete)
表單驗證
視窗大小調整(resize)事件
簡單來說,防抖就是「等你不動了,我才做事」。
這兩種技術都能優化輸入體驗,但有不同的特點:
防抖(Debounce)的做法 import React , { useState, useEffect } from 'react' ;function SearchWithDebounce ( ) { const [query, setQuery] = useState ('' ); const [debouncedQuery, setDebouncedQuery] = useState ('' ); useEffect (() => { const timer = setTimeout (() => { setDebouncedQuery (query); }, 500 ); return () => clearTimeout (timer); }, [query]); return ( <div > <input value ={query} onChange ={(e) => setQuery(e.target.value)} /> <SearchResults query ={debouncedQuery} /> </div > ); }
useDeferredValue vs Debounce 比較:
特性
useDeferredValue
Debounce
更新時機
根據 React 的調度機制決定
固定延遲時間(例如 500ms)
即時反應
立即開始處理,只是優先級較低
必須等待延遲時間結束
可中斷性
可以被更高優先級的更新中斷
不可中斷,計時器到了就執行
使用複雜度
簡單,一行程式碼
需要 useEffect + setTimeout
適用場景
需要立即回應但可延遲渲染
需要限制執行頻率(如 API 請求)
選擇建議:
使用 useDeferredValue :當你想要優化渲染效能,保持輸入流暢
使用 Debounce :當你想要限制 API 請求次數,減少伺服器負擔
實際應用範例 這個範例展示如何結合 useDeferredValue
與 useMemo
來優化大量資料的搜尋與渲染效能。
useDeferredValue : 當使用者輸入搜尋關鍵字時,searchQuery
會即時更新,但 deferredSearchQuery
會「延遲」更新。這樣可以讓 React 優先處理高優先級的互動(如輸入框的即時回饋),而將大量資料的過濾與渲染延後執行,避免畫面卡頓,提升使用者體驗。
useMemo : 用來記憶化(cache)產品資料的產生與篩選結果。generateProducts()
只會執行一次,避免每次渲染都重新產生 5000 筆資料;而 filteredProducts
只會在 allProducts
、searchQuery
或 category
變動時才重新計算,減少不必要的重複運算。
總結: 這種寫法能確保「輸入體驗流暢」且「大量資料渲染不卡頓」,是 React 18 以後效能優化的推薦做法。
完整的搜尋範例 import React , { useState, useDeferredValue, useMemo } from 'react' ;const generateProducts = ( ) => { return Array .from ({ length : 5000 }, (_, i ) => ({ id : i, name : `產品 ${i} ` , price : Math .floor (Math .random () * 1000 ), category : ['電子' , '服飾' , '食品' , '書籍' ][i % 4 ] })); }; function ProductList ({ searchQuery, category, isPending } ) { const allProducts = useMemo (() => generateProducts (), []); const filteredProducts = useMemo (() => { console .log ('開始篩選產品。..' ); return allProducts.filter (product => { const matchesSearch = product.name .includes (searchQuery); const matchesCategory = category === 'all' || product.category === category; return matchesSearch && matchesCategory; }); }, [allProducts, searchQuery, category]); return ( <div style ={{ opacity: isPending ? 0.6 : 1 , transition: 'opacity 0.2s ' }}> {isPending && <p > 更新中。..</p > } <p > 找到 {filteredProducts.length} 個產品</p > <ul > {filteredProducts.slice(0, 50).map(product => ( <li key ={product.id} > {product.name} - ${product.price} ({product.category}) </li > ))} </ul > </div > ); } function ProductSearchApp ( ) { const [searchQuery, setSearchQuery] = useState ('' ); const [category, setCategory] = useState ('all' ); const deferredSearchQuery = useDeferredValue (searchQuery); const isPending = searchQuery !== deferredSearchQuery; return ( <div > <h2 > 產品搜尋</h2 > <div > <input type ="text" placeholder ="搜尋產品。.." value ={searchQuery} onChange ={(e) => setSearchQuery(e.target.value)} /> <select value ={category} onChange ={(e) => setCategory(e.target.value)}> <option value ="all" > 全部分類</option > <option value ="電子" > 電子</option > <option value ="服飾" > 服飾</option > <option value ="食品" > 食品</option > <option value ="書籍" > 書籍</option > </select > </div > <ProductList searchQuery ={deferredSearchQuery} category ={category} isPending ={isPending} /> </div > ); } export default ProductSearchApp ;
何時該使用 useDeferredValue? 適合使用的情況:
搜尋功能 :大量資料的即時搜尋、過濾
輸入驅動的複雜運算 :圖表繪製、資料視覺化
大量列表渲染 :延遲渲染長列表,保持滾動流暢
複雜表單驗證 :延遲驗證邏輯,保持輸入流暢
不需要使用的情況:
簡單的輸入 :沒有耗時運算或大量渲染
需要精確控制延遲時間 :使用 debounce 更合適
API 請求 :使用 debounce 限制請求頻率更好
並發模式(Concurrent Mode)下作業
在 React 18 之前,React 的渲染是「同步且不可中斷」的。一旦開始渲染,就必須完整執行完畢,無法暫停或中斷。這就像在排隊結帳時,即使後面有人很急,也必須等前面的人全部結完帳才輪到你。
React 18 引入了「並發渲染(Concurrent Rendering)」機制,讓 React 可以:
暫停渲染 :正在渲染複雜元件時,如果有更緊急的更新(如使用者輸入),可以暫停當前渲染
優先級調度 :根據更新的重要性分配優先級,優先處理使用者互動
恢復渲染 :處理完緊急更新後,繼續完成之前暫停的渲染
如何啟用並發模式?
在 React 18 中,只要使用 createRoot
就會自動啟用並發功能:
import { createRoot } from 'react-dom/client' ;const root = createRoot (document .getElementById ('root' ));root.render (<App /> );
import ReactDOM from 'react-dom' ;ReactDOM .render (<App /> , document .getElementById ('root' ));
useDeferredValue
與並發模式的關係:
useDeferredValue
是 React 18 的新功能,需要並發渲染機制才能運作
它利用並發模式的優先級調度能力,將某些更新標記為「低優先級」
如果你的專案還在用 React 17 或 ReactDOM.render
,useDeferredValue
將無法發揮作用
升級到 React 18 並使用 createRoot
即可自動支援
其他重點:
useDeferredValue
不會延遲「時間」,而是延遲「優先級」
React 會根據系統負載自動調整延遲程度
這是一種效能優化手段,不應該用於實現業務邏輯
useTransition useTransition
是 React 18 引入的 Hook,可以將某些狀態更新標記為「過渡(transition)」,讓 React 知道這些更新可以延後處理,優先執行更緊急的互動(如使用者輸入)。它與 useDeferredValue
類似,但提供更細緻的控制。
為什麼需要 useTransition? 當我們需要在使用者操作時同時更新多個狀態,而其中某些更新會觸發耗時的運算或渲染時,就會遇到「輸入卡頓」的問題。
沒有使用 useTransition 的問題 import React , { useState } from 'react' ;function SlowList ({ items } ) { console .log ('渲染列表。..' ); return ( <ul > {items.map(item => ( <li key ={item.id} > {item.name}</li > ))} </ul > ); } function TabSwitcher ( ) { const [activeTab, setActiveTab] = useState ('tab1' ); const tabs = { tab1 : Array .from ({ length : 5000 }, (_, i ) => ({ id : i, name : `項目 ${i} ` })), tab2 : Array .from ({ length : 5000 }, (_, i ) => ({ id : i, name : `文章 ${i} ` })), tab3 : Array .from ({ length : 5000 }, (_, i ) => ({ id : i, name : `圖片 ${i} ` })) }; const handleTabClick = (tab ) => { setActiveTab (tab); }; return ( <div > <div > <button onClick ={() => handleTabClick('tab1')}>Tab 1</button > <button onClick ={() => handleTabClick('tab2')}>Tab 2</button > <button onClick ={() => handleTabClick('tab3')}>Tab 3</button > </div > <p > 當前 Tab:{activeTab}</p > {/* 渲染大量資料,造成卡頓 */} <SlowList items ={tabs[activeTab]} /> </div > ); }
問題分析:
使用者點擊 Tab 按鈕
setActiveTab
觸發狀態更新
立即渲染 5000 筆新資料(非常耗時)
使用者看到按鈕卡住,沒有立即的視覺反饋
體驗很差,感覺應用程式當機了
核心問題:
按鈕的視覺回饋(高優先級)被大量資料的渲染(低優先級)阻塞
使用者點擊按鈕後沒有立即看到反應,會以為沒點到或當機
雖然 useDeferredValue
可以延遲值的更新,但無法直接控制「狀態更新」本身的優先級
useTransition 語法 useTransition
可以解決這個問題,它讓我們明確告訴 React:「這個狀態更新不緊急,可以延後處理」。
語法結構:
const [isPending, startTransition] = useTransition ();
返回值說明:
isPending :布林值,表示是否有過渡更新正在進行中
true
:過渡更新尚未完成
false
:沒有進行中的過渡更新
startTransition :函式,用來包裹「非緊急」的狀態更新
被包裹的更新會被標記為低優先級
React 會優先處理其他緊急更新(如使用者輸入)
使用方式:
const handleClick = ( ) => { setUrgentState (newValue); startTransition (() => { setNonUrgentState (newValue); }); };
使用 useTransition 優化 讓我們用 useTransition
改善前面的問題:
使用 useTransition 優化 import React , { useState, useTransition } from 'react' ;function SlowList ({ items } ) { console .log ('渲染列表。..' ); return ( <ul > {items.map(item => ( <li key ={item.id} > {item.name}</li > ))} </ul > ); } function TabSwitcher ( ) { const [activeTab, setActiveTab] = useState ('tab1' ); const [isPending, startTransition] = useTransition (); const tabs = { tab1 : Array .from ({ length : 5000 }, (_, i ) => ({ id : i, name : `項目 ${i} ` })), tab2 : Array .from ({ length : 5000 }, (_, i ) => ({ id : i, name : `文章 ${i} ` })), tab3 : Array .from ({ length : 5000 }, (_, i ) => ({ id : i, name : `圖片 ${i} ` })) }; const handleTabClick = (tab ) => { startTransition (() => { setActiveTab (tab); }); }; return ( <div > <div > <button onClick ={() => handleTabClick('tab1')} disabled={isPending}> Tab 1 </button > <button onClick ={() => handleTabClick('tab2')} disabled={isPending}> Tab 2 </button > <button onClick ={() => handleTabClick('tab3')} disabled={isPending}> Tab 3 </button > </div > <div > <p > 當前 Tab:{activeTab}</p > {isPending && <p > 載入中。..</p > } </div > <SlowList items ={tabs[activeTab]} /> </div > ); } export default TabSwitcher ;
效果比較:
狀況
沒有使用 useTransition
使用 useTransition
按鈕點擊反應
畫面卡住,無法立即互動
按鈕立即變成 disabled,回饋即時
載入提示
無
顯示「載入中。..」提示
列表渲染
必須等 5000 筆資料渲染完才有反應
列表稍後才更新,不阻塞主要互動
使用者體驗
卡頓、不流暢
流暢、UI 響應性佳
useTransition
可以讓你將「非即時」或「不急迫」的狀態更新標記為過渡(Transition),這樣 React 會優先處理重要的 UI 互動(例如按鈕點擊、輸入回饋),而將大量渲染等較重的工作延後執行。這種做法能有效避免畫面卡頓,讓使用者感受到更即時、流暢的操作體驗。透過 useTransition
,我們能主動告訴 React:「這部分的狀態更新可以等一下再做」,進一步提升整體 UI 響應性。
useTransition vs useDeferredValue 雖然 useTransition
和 useDeferredValue
都能提升 React 應用的效能與互動流暢度,但它們的設計目標與適用情境並不相同:
useTransition : 適合用於「主動標記」某些狀態更新為「非緊急」的過渡(Transition),例如 Tab 切換、大量資料渲染等。你可以決定哪些更新可以延後,讓 React 先處理重要的 UI 互動,提升即時回饋。
useDeferredValue : 適合用於「被動延遲」某個值的更新,常見於輸入框、搜尋等場景。它會自動將值的變化延後處理,讓高優先級的互動(如輸入)不被大量運算或渲染阻塞。
簡單來說:
如果你想「主動控制」狀態更新的優先順序,請用 useTransition
。
如果你想「被動延遲」某個值的更新,讓 UI 更流暢,請用 useDeferredValue
。
useTransition vs useDeferredValue 對比 import React , { useState, useTransition, useDeferredValue } from 'react' ;function ComparisonDemo ( ) { const [tab1, setTab1] = useState ('home' ); const [isPending1, startTransition1] = useTransition (); const handleTabChange1 = (newTab ) => { startTransition1 (() => { setTab1 (newTab); }); }; const [input, setInput] = useState ('' ); const deferredInput = useDeferredValue (input); return ( <div > {/* useTransition:主動控制狀態更新 */} <button onClick ={() => handleTabChange1('profile')}> 切換到 Profile </button > {isPending1 && <span > 切換中。..</span > } {/* useDeferredValue:被動接收延遲的值 */} <input value ={input} onChange ={(e) => setInput(e.target.value)} /> <ExpensiveComponent value ={deferredInput} /> </div > ); }
差異比較:
特性
useTransition
useDeferredValue
控制方式
主動標記狀態更新為過渡
被動接收延遲的值
使用時機
你控制狀態更新的時機
狀態由外部控制(如 props)
isPending 狀態
有,可以顯示載入提示
需自行比較值判斷
典型場景
按鈕點擊、Tab 切換
輸入框、可控元件
狀態數量
可以更新多個狀態
只針對單一值
選擇建議:
使用 useTransition :當你需要在事件處理器中更新狀態,且希望延後某些更新
使用 useDeferredValue :當你需要延遲使用某個值,但不直接控制該值的更新
實際應用範例 這個範例展示了如何在 React 中使用 useTransition
來優化「分頁切換」的體驗。當使用者點擊分頁按鈕時,handlePageChange
會呼叫 startTransition
,將頁面切換的狀態更新標記為「過渡更新」(transition)。這代表 React 會優先處理高優先級的互動(例如按鈕點擊、輸入框輸入),而將大量資料的渲染(如 3000 筆分頁資料)延後執行,避免畫面卡頓。
isPending
會在過渡期間為 true
,可用來顯示「載入中」提示或降低內容透明度,讓使用者知道正在切換分頁。
這種寫法能確保「分頁按鈕點擊即時反應」且「大量資料渲染不卡頓」,大幅提升使用者體驗。
重點:
useTransition
適合用在「你主動控制狀態更新」的場景,例如分頁、Tab 切換、排序等。
它讓你可以把「不重要但很重的更新」延後處理,讓 UI 互動保持流暢。
完整的分頁切換範例 import React , { useState, useTransition } from 'react' ;const fetchPageData = (page ) => { return Array .from ({ length : 3000 }, (_, i ) => ({ id : `${page} -${i} ` , title : `第 ${page} 頁 - 項目 ${i} ` , content : `這是第 ${page} 頁的內容 ${i} ` })); }; function ContentList ({ items, isPending } ) { return ( <div style ={{ opacity: isPending ? 0.6 : 1 , transition: 'opacity 0.2s ' }}> <ul > {items.slice(0, 50).map(item => ( <li key ={item.id} > <h4 > {item.title}</h4 > <p > {item.content}</p > </li > ))} </ul > </div > ); } function PaginationApp ( ) { const [currentPage, setCurrentPage] = useState (1 ); const [isPending, startTransition] = useTransition (); const data = fetchPageData (currentPage); const totalPages = 10 ; const handlePageChange = (newPage ) => { startTransition (() => { setCurrentPage (newPage); }); }; return ( <div > <h2 > 分頁範例</h2 > <div > <button onClick ={() => handlePageChange(currentPage - 1)} disabled={currentPage === 1 || isPending} > 上一頁 </button > <span > 第 {currentPage} / {totalPages} 頁 </span > <button onClick ={() => handlePageChange(currentPage + 1)} disabled={currentPage === totalPages || isPending} > 下一頁 </button > {isPending && <span > (載入中。..)</span > } </div > <ContentList items ={data} isPending ={isPending} /> </div > ); } export default PaginationApp ;
說明:
點擊「下一頁」時,按鈕立即響應(disabled + 顯示載入提示)
頁面內容稍後更新,不會阻塞按鈕的反應
使用 isPending
降低內容透明度,提供視覺回饋
何時該使用 useTransition? 適合使用的情況:
Tab 切換 :切換不同的內容面板
分頁導航 :切換到不同頁面
篩選/排序 :改變資料的顯示方式
路由切換 :導航到不同的路由(配合路由庫)
不需要使用的情況:
簡單的狀態更新 :沒有耗時渲染的更新
必須立即反映的更新 :如表單驗證錯誤提示
API 請求 :startTransition
不會取消網路請求
重要提醒:
useTransition
是 React 18 的新功能,需要並發模式支援(使用 createRoot
)
被 startTransition
包裹的更新會被標記為低優先級,但不是「不執行」
不要在 startTransition
內執行有副作用的操作(如 API 請求)
isPending
只反映過渡更新的狀態,不是非同步操作的狀態
效能優化 Hooks 總結 我們已經學習了三個效能優化相關的 Hook,讓我們總結一下它們的用途:
Hook
優化目標
使用時機
主要作用
useCallback
記憶化函式
函式作為 props 或依賴項
避免函式重新創建
useMemo
記憶化計算結果
昂貴的計算或物件/陣列創建
避免重複計算
useDeferredValue
延遲值的更新
被動接收延遲的值
延遲非緊急的視覺更新
useTransition
標記狀態更新為過渡
主動控制狀態更新優先級
保持 UI 響應性
最佳實踐:
先測量效能瓶頸,再進行優化(不要過早優化)
useCallback
和 useMemo
搭配 React.memo
使用效果更佳
useDeferredValue
適合輸入驅動的場景
useTransition
適合事件驅動的場景
所有優化都有成本,只在真正需要時使用
進階 Hooks 進階型的 React Hooks 主要用於處理「多狀態、複雜邏輯」的情境,讓元件在面對大量資料、複雜互動時,依然能保持程式碼結構清晰、易於維護。這些 Hook 幫助我們將狀態管理、邏輯分離,並提升大型應用的可讀性與可測試性。
useReducer useReducer
是 React 提供的狀態管理 Hook,是 useState
的替代方案。當狀態邏輯變得複雜時(例如多個子狀態、複雜的更新邏輯),useReducer
可以讓程式碼更清晰、更易於維護和測試。
為什麼需要 useReducer? 這個範例展示當我們用多個 useState
來管理購物車的複雜狀態時,會遇到哪些實務上的困難與限制。你可以看到每個相關的狀態(如商品清單、折扣、運費、載入狀態、錯誤訊息)都分散在不同的 state 之中,導致狀態更新邏輯分散、重複且難以維護。這正是 useReducer
可以幫助我們簡化與集中管理的典型場景。
使用 useState 管理複雜狀態的問題 import React , { useState } from 'react' ;function ShoppingCart ( ) { const [items, setItems] = useState ([]); const [discount, setDiscount] = useState (0 ); const [shippingFee, setShippingFee] = useState (0 ); const [loading, setLoading] = useState (false ); const [error, setError] = useState (null ); const addItem = (product ) => { setLoading (true ); setError (null ); const existingItem = items.find (item => item.id === product.id ); if (existingItem) { setItems (items.map (item => item.id === product.id ? { ...item, quantity : item.quantity + 1 } : item )); } else { setItems ([...items, { ...product, quantity : 1 }]); } if (items.length + 1 >= 3 ) { setShippingFee (0 ); } else { setShippingFee (60 ); } setLoading (false ); }; const removeItem = (productId ) => { setLoading (true ); setItems (items.filter (item => item.id !== productId)); const newItems = items.filter (item => item.id !== productId); if (newItems.length >= 3 ) { setShippingFee (0 ); } else { setShippingFee (60 ); } setLoading (false ); }; const applyDiscount = (code ) => { setLoading (true ); setError (null ); if (code === 'SAVE10' ) { setDiscount (10 ); } else if (code === 'SAVE20' ) { setDiscount (20 ); } else { setError ('無效的折扣碼' ); } setLoading (false ); }; }
問題分析:
狀態分散 :items
、discount
、shippingFee
、loading
、error
等相關狀態分散在各處
邏輯重複 :運費的計算邏輯在多個函式中重複出現
難以測試 :狀態更新邏輯分散在各個事件處理器中,難以單獨測試
難以維護 :當需求變更時,要修改多個地方
容易出錯 :忘記更新某個相關狀態,導致狀態不一致
核心問題:
使用多個 useState
管理相關的狀態,導致狀態分散、邏輯混亂
狀態更新邏輯散落在各處,難以追蹤和維護
複雜的業務邏輯難以測試和重用
當狀態間有依賴關係時,容易出現不一致的情況
useReducer 概念 useReducer
的核心概念來自 Redux 等狀態管理庫,採用「單一資料流」的設計模式:
graph LR
State["State<br/>(狀態)"]
Action["Action<br/>(動作)"]
UI["UI<br/>(使用者介面)"]
Reducer["Reducer<br/>(歸納器)"]
%% 流程箭頭
State -- 狀態傳遞 --> UI
UI -- 派發 Action --> Action
Action -- 觸發 --> Reducer
Reducer -- 更新 --> State
核心概念:
State(狀態) :應用程式的資料
Action(動作) :描述「發生了什麼」的物件
Reducer(歸納器) :根據 action 決定如何更新 state 的純函式
Dispatch(派發) :觸發 action 的函式
useReducer 語法 useReducer
適合管理多個彼此有關聯、邏輯較複雜的狀態,能讓狀態更新流程更集中、可預測且易於維護。
語法結構:
const [state, dispatch] = useReducer (reducer, initialState);
參數詳細說明:
reducer : 一個純函式,格式為 (state, action) => newState
。每當你呼叫 dispatch
派發一個 action 時,React 會自動將目前的 state 和 action 傳入 reducer,並根據回傳結果更新 state。reducer 需保證不可直接修改原本的 state,必須回傳新的物件。Reducer 函式結構 function reducer (state, action ) { switch (action.type ) { case 'ACTION_TYPE' : return newState; default : return state; } }
initialState : 初始狀態物件。這是 reducer 第一次執行時的 state 值,通常用來集中管理所有相關狀態欄位。
回傳值詳細說明:
state : 目前最新的狀態資料。每次 reducer 回傳新狀態後,state 也會自動更新,元件會重新渲染。
dispatch : 用來派發 action 的函式。你可以呼叫 dispatch({ type: '動作名稱', payload: 資料 })
來觸發 reducer 執行對應的狀態更新邏輯。
基本用法範例 以下這個範例將帶你一步步學會如何用 useReducer
來重構購物車功能,讓多個購物車相關狀態(如商品清單、折扣、運費、載入狀態等)集中管理,並用 reducer 函式統一處理所有狀態更新。步驟如下:
定義初始狀態 :將所有購物車需要追蹤的資料(商品陣列、折扣、運費、載入狀態、錯誤訊息等)集中在一個物件中,方便管理。
撰寫 reducer 函式 :根據不同的 action(如新增商品、移除商品、套用折扣等),在 reducer 內統一處理狀態的變化,確保每次更新都回傳新的狀態物件。
在元件中使用 useReducer :用 const [state, dispatch] = useReducer(reducer, initialState)
取得目前狀態與派發 action 的函式。
設計觸發行為 :當使用者操作(如加入商品、刪除商品、輸入折扣碼等)時,呼叫 dispatch
派發對應的 action,reducer 會自動處理狀態更新。
渲染 UI :根據 state 內容動態渲染購物車清單、總金額、運費、折扣等資訊。
這種寫法讓複雜的狀態變動更有條理,所有邏輯集中在 reducer,元件本身更簡潔,也方便日後擴充與維護。
useReducer 基本用法 import React , { useReducer } from 'react' ;const initialState = { items : [], discount : 0 , shippingFee : 60 , loading : false , error : null }; function cartReducer (state, action ) { switch (action.type ) { case 'ADD_ITEM' : { const existingItem = state.items .find (item => item.id === action.payload .id ); const newItems = existingItem ? state.items .map (item => item.id === action.payload .id ? { ...item, quantity : item.quantity + 1 } : item ) : [...state.items , { ...action.payload , quantity : 1 }]; const shippingFee = newItems.length >= 3 ? 0 : 60 ; return { ...state, items : newItems, shippingFee, loading : false }; } case 'REMOVE_ITEM' : { const newItems = state.items .filter (item => item.id !== action.payload ); const shippingFee = newItems.length >= 3 ? 0 : 60 ; return { ...state, items : newItems, shippingFee, loading : false }; } case 'APPLY_DISCOUNT' : { const discounts = { 'SAVE10' : 10 , 'SAVE20' : 20 , 'SAVE30' : 30 }; const discount = discounts[action.payload ]; if (discount) { return { ...state, discount, error : null , loading : false }; } else { return { ...state, error : '無效的折扣碼' , loading : false }; } } case 'SET_LOADING' : return { ...state, loading : action.payload }; case 'CLEAR_ERROR' : return { ...state, error : null }; case 'RESET_CART' : return initialState; default : return state; } } function ShoppingCart ( ) { const [state, dispatch] = useReducer (cartReducer, initialState); const addItem = (product ) => { dispatch ({ type : 'SET_LOADING' , payload : true }); dispatch ({ type : 'ADD_ITEM' , payload : product }); }; const removeItem = (productId ) => { dispatch ({ type : 'SET_LOADING' , payload : true }); dispatch ({ type : 'REMOVE_ITEM' , payload : productId }); }; const applyDiscount = (code ) => { dispatch ({ type : 'SET_LOADING' , payload : true }); dispatch ({ type : 'APPLY_DISCOUNT' , payload : code }); }; const total = state.items .reduce ((sum, item ) => sum + item.price * item.quantity , 0 ); const finalTotal = total * (1 - state.discount / 100 ) + state.shippingFee ; return ( <div > <h2 > 購物車</h2 > {state.error && <p style ={{ color: 'red ' }}> {state.error}</p > } {state.loading && <p > 處理中。..</p > } <ul > {state.items.map(item => ( <li key ={item.id} > {item.name} x {item.quantity} - ${item.price * item.quantity} <button onClick ={() => removeItem(item.id)}>移除</button > </li > ))} </ul > <div > <p > 小計:${total}</p > <p > 折扣:{state.discount}%</p > <p > 運費:${state.shippingFee}</p > <p > 總計:${finalTotal}</p > </div > <button onClick ={() => applyDiscount('SAVE10')}>套用折扣碼</button > <button onClick ={() => dispatch({ type: 'RESET_CART' })}>清空購物車</button > </div > ); } export default ShoppingCart ;
優點對比:
使用 useState
使用 useReducer
狀態分散在多個變數
狀態集中管理
更新邏輯散落各處
更新邏輯集中在 reducer
難以測試
reducer 可以單獨測試
邏輯重複
邏輯集中,避免重複
容易出錯
狀態更新可預測
重點: useReducer
將所有狀態和更新邏輯集中管理,讓程式碼更清晰、更易於維護和測試。
實際應用:待辦事項 以下將介紹一個「待辦事項(Todo List)」的完整範例,示範如何使用 useReducer
來集中管理多個相關狀態(如待辦清單、篩選條件),並將所有狀態更新邏輯統一寫在 reducer 裡。這個範例可以幫助你理解:當應用程式的狀態變得複雜時,useReducer
如何讓程式碼更有組織、易於維護與擴充。
待辦事項應用 import React , { useReducer, useState } from 'react' ;const initialState = { todos : [], filter : 'all' }; function todoReducer (state, action ) { switch (action.type ) { case 'ADD_TODO' : return { ...state, todos : [ ...state.todos , { id : Date .now (), text : action.payload , completed : false } ] }; case 'TOGGLE_TODO' : return { ...state, todos : state.todos .map (todo => todo.id === action.payload ? { ...todo, completed : !todo.completed } : todo ) }; case 'DELETE_TODO' : return { ...state, todos : state.todos .filter (todo => todo.id !== action.payload ) }; case 'SET_FILTER' : return { ...state, filter : action.payload }; case 'CLEAR_COMPLETED' : return { ...state, todos : state.todos .filter (todo => !todo.completed ) }; default : return state; } } function TodoApp ( ) { const [state, dispatch] = useReducer (todoReducer, initialState); const [inputValue, setInputValue] = useState ('' ); const filteredTodos = state.todos .filter (todo => { if (state.filter === 'active' ) return !todo.completed ; if (state.filter === 'completed' ) return todo.completed ; return true ; }); const activeCount = state.todos .filter (t => !t.completed ).length ; const completedCount = state.todos .filter (t => t.completed ).length ; const handleAddTodo = (e ) => { e.preventDefault (); if (inputValue.trim ()) { dispatch ({ type : 'ADD_TODO' , payload : inputValue.trim () }); setInputValue ('' ); } }; return ( <div > <h2 > 待辦事項</h2 > {/* 新增待辦 */} <form onSubmit ={handleAddTodo} > <input type ="text" value ={inputValue} onChange ={(e) => setInputValue(e.target.value)} placeholder="輸入待辦事項。.." /> <button type ="submit" > 新增</button > </form > {/* 篩選按鈕 */} <div > <button onClick ={() => dispatch({ type: 'SET_FILTER', payload: 'all' })}> 全部 ({state.todos.length}) </button > <button onClick ={() => dispatch({ type: 'SET_FILTER', payload: 'active' })}> 待完成 ({activeCount}) </button > <button onClick ={() => dispatch({ type: 'SET_FILTER', payload: 'completed' })}> 已完成 ({completedCount}) </button > </div > {/* 待辦列表 */} <ul > {filteredTodos.map(todo => ( <li key ={todo.id} > <input type ="checkbox" checked ={todo.completed} onChange ={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })} /> <span style ={{ textDecoration: todo.completed ? 'line-through ' : 'none ' }}> {todo.text} </span > <button onClick ={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}> 刪除 </button > </li > ))} </ul > {/* 清除已完成 */} {completedCount > 0 && ( <button onClick ={() => dispatch({ type: 'CLEAR_COMPLETED' })}> 清除已完成 </button > )} </div > ); } export default TodoApp ;
說明:
所有狀態更新邏輯都集中在 todoReducer
中
透過 dispatch
派發不同的 action 來更新狀態
reducer 可以單獨測試,不依賴元件
狀態更新邏輯清晰、可預測
useReducer 的測試 useReducer
的一大優勢是 reducer 函式可以進行「單元測試」(unit test),也就是可以獨立於 React 元件之外,針對 reducer 的輸入與輸出進行自動化測試,確保狀態更新邏輯正確無誤。
Reducer 測試範例 describe ('todoReducer' , () => { test ('ADD_TODO 應該新增待辦事項' , () => { const initialState = { todos : [], filter : 'all' }; const action = { type : 'ADD_TODO' , payload : '買牛奶' }; const newState = todoReducer (initialState, action); expect (newState.todos ).toHaveLength (1 ); expect (newState.todos [0 ].text ).toBe ('買牛奶' ); expect (newState.todos [0 ].completed ).toBe (false ); }); test ('TOGGLE_TODO 應該切換完成狀態' , () => { const initialState = { todos : [{ id : 1 , text : '買牛奶' , completed : false }], filter : 'all' }; const action = { type : 'TOGGLE_TODO' , payload : 1 }; const newState = todoReducer (initialState, action); expect (newState.todos [0 ].completed ).toBe (true ); }); test ('DELETE_TODO 應該刪除待辦事項' , () => { const initialState = { todos : [ { id : 1 , text : '買牛奶' , completed : false }, { id : 2 , text : '寫程式' , completed : false } ], filter : 'all' }; const action = { type : 'DELETE_TODO' , payload : 1 }; const newState = todoReducer (initialState, action); expect (newState.todos ).toHaveLength (1 ); expect (newState.todos [0 ].id ).toBe (2 ); }); });
useReducer vs useState 選擇指南 在 React 專案中,useState
和 useReducer
都是常用的狀態管理 Hook,但它們適用的情境有所不同。初學者常常會疑惑:什麼時候該用 useState
,什麼時候又該選擇 useReducer
?本節將從實務角度,幫助你判斷兩者的適用時機,並透過對比表格與範例,讓你快速掌握選擇原則。
適用情境
useState
useReducer
狀態結構
單一值(字串、數字、布林)
複雜物件、陣列、多個子狀態
狀態間關聯
無
有(多個狀態需同時考慮一致性)
更新邏輯
直接設定新值,邏輯簡單
依賴前一狀態、條件分支多、邏輯複雜
狀態管理
分散於多個 useState
集中於一個 reducer
事件處理
單一事件對應單一狀態
多個事件需操作同一組狀態
跨元件共用
不建議
可將 reducer 抽出共用
可測試性
不易針對狀態更新單獨測試
reducer 可獨立單元測試
適合元件規模
小型、簡單元件
中大型、邏輯複雜元件
選擇建議:
狀態簡單、邏輯單純時,優先用 useState
狀態多、邏輯複雜、需集中管理時,建議用 useReducer
可先用 useState
,日後需求變複雜再重構成 useReducer
對比範例:
useState vs useReducer function SimpleCounter ( ) { const [count, setCount] = useState (0 ); return <button onClick ={() => setCount(count + 1)}>Count: {count}</button > ; } function ComplexForm ( ) { const [state, dispatch] = useReducer (formReducer, { name : '' , email : '' , age : 0 , errors : {}, submitting : false }); }
useReducer 最佳實踐 當你開始使用 useReducer
時,遵循一些最佳實踐可以讓程式碼更易讀、更易維護、更不容易出錯。以下將介紹四個重要的實務技巧,幫助你寫出更專業、更穩健的 reducer 程式碼。
Action 類型常數化 將 action 類型常數化,可以有效避免拼寫錯誤(typo)帶來的 bug,並且讓 IDE 能夠自動補全,提升開發效率。這種做法也能集中管理所有 action 類型,讓專案結構更清晰,日後重構或維護時也會更加方便。
Action 類型常數化 dispatch ({ type : 'ADD_TODO' , payload : 'New todo' });dispatch ({ type : 'ADD_TDOO' , payload : 'New todo' }); const ActionTypes = { ADD_TODO : 'ADD_TODO' , TOGGLE_TODO : 'TOGGLE_TODO' , DELETE_TODO : 'DELETE_TODO' , SET_FILTER : 'SET_FILTER' }; dispatch ({ type : ActionTypes .ADD_TODO , payload : 'New todo' });dispatch ({ type : ActionTypes .ADD_TDOO , payload : 'New todo' }); function todoReducer (state, action ) { switch (action.type ) { case ActionTypes .ADD_TODO : case ActionTypes .TOGGLE_TODO : default : return state; } }
使用 Action Creator 使用 Action Creator 可以集中管理 action 的創建邏輯,確保每個 action 的結構一致,也方便在建立 action 時加入預處理邏輯。這樣不僅能提升程式碼的可讀性,也讓維護和擴充 reducer 時更加簡單可靠。
Action Creator dispatch ({ type : 'ADD_TODO' , payload : text });dispatch ({ type : 'ADD_TODO' , paylod : text }); dispatch ({ type : 'ADD_TODO' , data : text }); const actions = { addTodo : (text ) => ({ type : 'ADD_TODO' , payload : text }), toggleTodo : (id ) => ({ type : 'TOGGLE_TODO' , payload : id }), deleteTodo : (id ) => ({ type : 'DELETE_TODO' , payload : id }), setFilter : (filter ) => { const validFilters = ['all' , 'active' , 'completed' ]; if (!validFilters.includes (filter)) { console .warn (`Invalid filter: ${filter} ` ); return { type : 'SET_FILTER' , payload : 'all' }; } return { type : 'SET_FILTER' , payload : filter }; } }; dispatch (actions.addTodo ('Buy milk' ));dispatch (actions.toggleTodo (123 ));dispatch (actions.setFilter ('active' ));
進階用法:結合 TypeScript
type Action = | { type : 'ADD_TODO' ; payload : string } | { type : 'TOGGLE_TODO' ; payload : number } | { type : 'DELETE_TODO' ; payload : number }; const actions = { addTodo : (text : string ): Action => ({ type : 'ADD_TODO' , payload : text }), toggleTodo : (id : number ): Action => ({ type : 'TOGGLE_TODO' , payload : id }) };
Reducer 必須是純函式 簡單來說,純函式(Pure Function)指的是「相同的輸入,永遠會產生相同的輸出,且不會產生任何副作用(不會改變外部狀態)」。在 React 中,reducer 必須保持純淨,因為 React 可能會在渲染或優化過程中多次執行 reducer。如果 reducer 不是純函式,會導致狀態不可預測,容易產生難以追蹤的 bug,也會讓測試、除錯(例如時間旅行除錯)變得困難。因此,務必確保 reducer 不會直接修改傳入的 state,也不應該有任何副作用。
Reducer 保持純淨 function badReducer (state, action ) { switch (action.type ) { case 'ADD_TODO' : state.todos .push (action.payload ); return state; case 'SORT_TODOS' : state.todos .sort (); return state; } } function goodReducer (state, action ) { switch (action.type ) { case 'ADD_TODO' : return { ...state, todos : [...state.todos , action.payload ] }; case 'SORT_TODOS' : return { ...state, todos : [...state.todos ].sort () }; case 'UPDATE_TODO' : return { ...state, todos : state.todos .map (todo => todo.id === action.payload .id ? { ...todo, ...action.payload .updates } : todo ) }; } }
常見陷阱:
case 'UPDATE_USER_ADDRESS' : return { ...state, user : { ...state.user , address : action.payload } }; case 'UPDATE_USER_ADDRESS' : return { ...state, user : { ...state.user , address : { ...state.user .address , ...action.payload } } };
處理未知的 Action 處理未知的 Action 可以幫助我們及早發現錯誤,避免靜默失敗,並提供清楚的錯誤訊息,讓除錯過程更加方便。
處理未知 Action function badReducer (state, action ) { switch (action.type ) { case 'ADD_TODO' : return { ...state, todos : [...state.todos , action.payload ] }; default : return state; } } function goodReducer (state, action ) { switch (action.type ) { case 'ADD_TODO' : return { ...state, todos : [...state.todos , action.payload ] }; default : console .warn (`Unknown action type: ${action.type} ` , action); return state; } } function betterReducer (state, action ) { switch (action.type ) { case 'ADD_TODO' : return { ...state, todos : [...state.todos , action.payload ] }; default : if (process.env .NODE_ENV === 'development' ) { throw new Error (`Unknown action type: ${action.type} ` ); } console .warn (`Unknown action type: ${action.type} ` ); return state; } }
完整範例:結合所有最佳實踐 我們已經學會如何用 useReducer
管理複雜狀態,並介紹了 reducer 的設計原則。接下來,讓我們結合所有最佳實踐,打造一個更完整、可維護性高的 useReducer
範例。這個範例會示範:
如何定義 Action 類型常數,避免字串錯誤
使用 Action Creator 統一產生 action 物件
在 reducer 中處理未知的 action,提升除錯體驗
保持 reducer 純淨,確保每次都回傳新的狀態物件
這些技巧能讓你的 React 狀態管理更安全、可預測,也更容易維護。
最佳實踐完整範例 const ActionTypes = { ADD_TODO : 'ADD_TODO' , TOGGLE_TODO : 'TOGGLE_TODO' , DELETE_TODO : 'DELETE_TODO' }; const actions = { addTodo : (text ) => ({ type : ActionTypes .ADD_TODO , payload : text.trim () }), toggleTodo : (id ) => ({ type : ActionTypes .TOGGLE_TODO , payload : id }), deleteTodo : (id ) => ({ type : ActionTypes .DELETE_TODO , payload : id }) }; function todoReducer (state, action ) { switch (action.type ) { case ActionTypes .ADD_TODO : return { ...state, todos : [ ...state.todos , { id : Date .now (), text : action.payload , completed : false } ] }; case ActionTypes .TOGGLE_TODO : return { ...state, todos : state.todos .map (todo => todo.id === action.payload ? { ...todo, completed : !todo.completed } : todo ) }; case ActionTypes .DELETE_TODO : return { ...state, todos : state.todos .filter (todo => todo.id !== action.payload ) }; default : if (process.env .NODE_ENV === 'development' ) { console .error (`Unknown action type: ${action.type} ` ); } return state; } } function TodoApp ( ) { const [state, dispatch] = useReducer (todoReducer, { todos : [] }); const handleAddTodo = (text ) => { dispatch (actions.addTodo (text)); }; return ( <div > {/* UI */} </div > ); }
總結: 遵循這四個最佳實踐,可以讓你的 useReducer
程式碼:
✅ 更不容易出錯(Action 類型常數化)
✅ 更易於維護(Action Creator)
✅ 更可預測(純函式)
✅ 更易於除錯(處理未知 Action)
useReducer 與 Redux 的關係 你可能會發現 useReducer
的用法跟 Redux 很像,這不是巧合!事實上,useReducer
就是 React 官方參考 Redux 的設計理念,內建到 React 中的狀態管理方案。讓我們來釐清它們的關係。
什麼是 Redux? Redux 是一個獨立的狀態管理庫,在 React 生態系統中非常流行。它採用「單一資料源」和「單向資料流」的設計模式,讓大型應用的狀態管理變得可預測、易於追蹤。
Redux 的核心概念:
Store :全域的狀態容器
Action :描述發生了什麼事的物件
Reducer :決定如何更新狀態的純函式
Dispatch :派發 action 的方法
這些概念是不是跟 useReducer
很像?
useReducer vs Redux 比較 兩者的設計模式和用法看起來非常相似。事實上 useReducer
可以視為 React 內建的「本地狀態管理」方案,而 Redux 則是專為「全域狀態管理」設計的第三方函式庫。如果你的狀態只需要在單一元件或小範圍元件樹中共享,建議優先使用 useReducer
。只有當狀態需要跨多個頁面或元件全域共享時,再考慮導入 Redux 等外部狀態管理工具。
特性
useReducer
Redux
來源
React 內建 Hook
第三方狀態管理庫
安裝
不需要,React 內建
需要安裝 redux
和 react-redux
作用範圍
單一元件或元件樹
整個應用程式的全域狀態
學習曲線
較簡單,概念較少
較複雜,需要學習更多概念
DevTools
無專用工具
有 Redux DevTools(時間旅行除錯)
中間件
無
支援(如 redux-thunk、redux-saga)
非同步處理
需自行實作
透過中間件處理
適用場景
元件內的複雜狀態
跨元件的全域狀態
使用 useReducer(元件內狀態管理):
useReducer 範例 import React , { useReducer } from 'react' ;function counterReducer (state, action ) { switch (action.type ) { case 'INCREMENT' : return { count : state.count + 1 }; case 'DECREMENT' : return { count : state.count - 1 }; default : return state; } } function Counter ( ) { const [state, dispatch] = useReducer (counterReducer, { count : 0 }); return ( <div > <p > Count: {state.count}</p > <button onClick ={() => dispatch({ type: 'INCREMENT' })}>+</button > <button onClick ={() => dispatch({ type: 'DECREMENT' })}>-</button > </div > ); }
使用 Redux(全域狀態管理):
Redux 範例 import { createStore } from 'redux' ;function counterReducer (state = { count: 0 }, action ) { switch (action.type ) { case 'INCREMENT' : return { count : state.count + 1 }; case 'DECREMENT' : return { count : state.count - 1 }; default : return state; } } const store = createStore (counterReducer);export default store;import { Provider } from 'react-redux' ;import store from './store' ;function App ( ) { return ( <Provider store ={store} > <Counter /> </Provider > ); } import { useSelector, useDispatch } from 'react-redux' ;function Counter ( ) { const count = useSelector (state => state.count ); const dispatch = useDispatch (); return ( <div > <p > Count: {count}</p > <button onClick ={() => dispatch({ type: 'INCREMENT' })}>+</button > <button onClick ={() => dispatch({ type: 'DECREMENT' })}>-</button > </div > ); }
useReducer + Context = 簡易版 Redux 如果需要跨元件共享狀態,可以結合 useReducer
和 useContext
,實現類似 Redux 的效果:
useReducer + Context import React , { useReducer, useContext, createContext } from 'react' ;const StoreContext = createContext ();function appReducer (state, action ) { switch (action.type ) { case 'SET_USER' : return { ...state, user : action.payload }; case 'SET_THEME' : return { ...state, theme : action.payload }; default : return state; } } function AppProvider ({ children } ) { const [state, dispatch] = useReducer (appReducer, { user : null , theme : 'light' }); return ( <StoreContext.Provider value ={{ state , dispatch }}> {children} </StoreContext.Provider > ); } function useAppStore ( ) { return useContext (StoreContext ); } function UserProfile ( ) { const { state, dispatch } = useAppStore (); return ( <div > <p > User: {state.user?.name || 'Guest'}</p > <p > Theme: {state.theme}</p > <button onClick ={() => dispatch({ type: 'SET_USER', payload: { name: 'Loki' } })}> Login </button > </div > ); } function App ( ) { return ( <AppProvider > <UserProfile /> </AppProvider > ); }
總結 useReducer 與 Redux 的關係:
useReducer
是 React 內建的狀態管理 Hook,靈感來自 Redux
兩者都使用 Reducer 模式 (State + Action → New State)
Redux 是獨立的全域狀態管理庫,功能更強大但也更複雜
useReducer
適合元件內 的複雜狀態管理
Redux 適合應用程式級 的全域狀態管理
可以用 useReducer
+ useContext
實現簡易版的 Redux
學會 useReducer
後,學習 Redux 會更容易
建議:
先學好 useReducer
,理解 Reducer 模式
簡單應用用 useReducer
+ useContext
就夠了
大型應用或需要進階功能時才考慮 Redux
useImperativeHandle useImperativeHandle
讓你可以自定義暴露給父元件的實例值,通常與 forwardRef
一起使用。
在 React 中,父元件通常透過 props 與子元件溝通(資料向下傳遞),子元件透過 callback 向父元件回報(事件向上傳遞)。這種「單向資料流」的設計讓元件更容易理解和維護。然而,在某些特殊情況下,父元件需要「直接控制」子元件的內部功能(例如讓輸入框聚焦、播放影片、重置表單等),這時候就需要 useImperativeHandle
。
問題案例:父元件無法直接控制子元件 假設我們想製作一個「可重複使用的影片播放器元件」,父元件需要能夠控制播放、暫停、跳轉等功能。如果只用 props,會遇到什麼問題?
問題範例:無法直接控制子元件 import React , { useState, useRef } from 'react' ;function VideoPlayer ({ src } ) { const videoRef = useRef (); const play = ( ) => videoRef.current .play (); const pause = ( ) => videoRef.current .pause (); const reset = ( ) => { videoRef.current .currentTime = 0 ; videoRef.current .pause (); }; return ( <div > <video ref ={videoRef} src ={src} width ="400" /> {/* 只能透過內部按鈕控制 */} <button onClick ={play} > 播放</button > <button onClick ={pause} > 暫停</button > <button onClick ={reset} > 重置</button > </div > ); } function App ( ) { return ( <div > <h2 > 影片 1</h2 > <VideoPlayer src ="video1.mp4" /> <h2 > 影片 2</h2 > <VideoPlayer src ="video2.mp4" /> {/* 如果想在這裡放一個「全部暫停」按鈕,該怎麼做? */} </div > ); }
問題分析:
子元件的控制方法(play、pause、reset)只存在於子元件內部
父元件無法直接呼叫這些方法
如果用 props 傳遞控制訊號,需要複雜的狀態同步邏輯
當有多個子元件時,父元件難以統一控制
前置知識 1:ref 屬性的特殊性 在前面 useRef
章節,我們學過:
使用 useRef()
創建 ref 物件
將 ref 綁定到原生 DOM 元素的 ref
屬性:<input ref={inputRef} />
透過 ref.current
存取 DOM 元素
在原生 HTML 元素(如 <input>
、<video>
等)上直接使用 ref
完全沒問題,父元件可以透過 ref
直接操作這些 DOM 元素。但如果你將 ref
指定給自訂元件(例如函式元件),React 並不會把 ref
當作一般 props 傳遞進去。這是因為 ref
(和 key
一樣)是 React 的特殊保留屬性,它們只會被 React 處理,不會自動出現在子元件的 props 物件中 。因此,父元件無法僅靠 props 傳遞 ref
來直接操作子元件內部的 DOM 或方法。如果想讓自訂元件支援 ref,必須使用 forwardRef
來讓函式元件能夠接收 ref,否則 ref 不會自動傳遞到子元件內部。
讓我們看看會發生什麼:
ref 無法像一般 props 傳遞 import React , { useRef } from 'react' ;function MyInput (props ) { console .log ('props.ref:' , props.ref ); console .log ('props:' , props); return <input type ="text" placeholder ={props.placeholder} /> ; } function App ( ) { const inputRef = useRef (null ); const handleFocus = ( ) => { console .log ('inputRef.current:' , inputRef.current ); inputRef.current ?.focus (); }; return ( <div > <MyInput ref ={inputRef} placeholder ="請輸入文字" /> <button onClick ={handleFocus} > 聚焦輸入框</button > </div > ); }
問題分析:
父元件嘗試傳遞 ref={inputRef}
給 MyInput
但在 MyInput
的 props 中找不到 ref
inputRef.current
始終是 null
無法存取到子元件內部的 DOM 元素
React 這樣設計是有原因的:
避免混淆 :ref
是用來存取 DOM 或元件實例的,不是用來傳遞資料的
保持一致性 :無論是原生 DOM 元素還是自訂元件,ref
的行為應該一致
封裝性 :元件應該透過 props 和 callback 溝通,而不是直接暴露內部結構
有時候,我們會遇到需要讓父元件直接操作子元件內部 DOM 元素的情境,例如讓父元件可以聚焦輸入框、控制影片播放等。這時候,單純傳遞 ref 是無法實現的。這正是 forwardRef
派上用場的時機!forwardRef
是 React 提供的特殊 API,允許函式元件「接收」來自父元件的 ref,並將其「轉發」給內部的 DOM 元素或其他子元件。這樣父元件就能安全地操作子元件內部的 DOM 結構。
使用場景對比 讓我們用一個實際的表單驗證情境,展示三種不同的做法:
情境:製作一個登入表單,提交時驗證輸入框,若為空則聚焦到該輸入框。
情況 1:直接使用原生 DOM 元素(可行) import React , { useRef } from 'react' ;function LoginForm ( ) { const emailRef = useRef (null ); const passwordRef = useRef (null ); const handleSubmit = (e ) => { e.preventDefault (); if (!emailRef.current .value ) { alert ('請輸入電子郵件' ); emailRef.current .focus (); return ; } if (!passwordRef.current .value ) { alert ('請輸入密碼' ); passwordRef.current .focus (); return ; } alert ('登入成功!' ); }; return ( <form onSubmit ={handleSubmit} > <div > <label > 電子郵件:</label > <input ref ={emailRef} // ✅ 原生元素可以直接接收 ref type ="email" placeholder ="請輸入電子郵件" /> </div > <div > <label > 密碼:</label > <input ref ={passwordRef} // ✅ 原生元素可以直接接收 ref type ="password" placeholder ="請輸入密碼" /> </div > <button type ="submit" > 登入</button > </form > ); }
問題:如果我們想把輸入框封裝成可重用的元件呢?
情況 2:封裝成元件後(無法運作) import React , { useRef } from 'react' ;function CustomInput (props ) { return ( <div style ={{ marginBottom: '10px ' }}> <label > {props.label}</label > <input type ={props.type} placeholder ={props.placeholder} /> </div > ); } function LoginForm ( ) { const emailRef = useRef (null ); const passwordRef = useRef (null ); const handleSubmit = (e ) => { e.preventDefault (); console .log ('emailRef.current:' , emailRef.current ); if (!emailRef.current ?.value ) { alert ('請輸入電子郵件' ); emailRef.current ?.focus (); return ; } if (!passwordRef.current ?.value ) { alert ('請輸入密碼' ); passwordRef.current ?.focus (); return ; } alert ('登入成功!' ); }; return ( <form onSubmit ={handleSubmit} > <CustomInput ref ={emailRef} // ❌ ref 不會傳遞給 CustomInput label ="電子郵件:" type ="email" placeholder ="請輸入電子郵件" /> <CustomInput ref ={passwordRef} // ❌ ref 不會傳遞給 CustomInput label ="密碼:" type ="password" placeholder ="請輸入密碼" /> <button type ="submit" > 登入</button > </form > ); }
解決方案:使用 forwardRef
情況 3:使用 forwardRef(成功運作) import React , { useRef, forwardRef } from 'react' ;const CustomInput = forwardRef ((props, ref ) => { return ( <div style ={{ marginBottom: '10px ' }}> <label > {props.label}</label > <input ref ={ref} // ✅ 將 ref 轉發到內部的 input type ={props.type} placeholder ={props.placeholder} /> </div > ); }); function LoginForm ( ) { const emailRef = useRef (null ); const passwordRef = useRef (null ); const handleSubmit = (e ) => { e.preventDefault (); console .log ('emailRef.current:' , emailRef.current ); if (!emailRef.current .value ) { alert ('請輸入電子郵件' ); emailRef.current .focus (); return ; } if (!passwordRef.current .value ) { alert ('請輸入密碼' ); passwordRef.current .focus (); return ; } alert ('登入成功!' ); }; return ( <form onSubmit ={handleSubmit} > <CustomInput ref ={emailRef} // ✅ 透過 forwardRef 可以接收 ref label ="電子郵件:" type ="email" placeholder ="請輸入電子郵件" /> <CustomInput ref ={passwordRef} // ✅ 透過 forwardRef 可以接收 ref label ="密碼:" type ="password" placeholder ="請輸入密碼" /> <button type ="submit" > 登入</button > </form > ); }
三種情況總結:
情況
做法
結果
使用時機
情況 1
直接使用原生 DOM
✅ 可以存取
簡單場景,不需要封裝元件
情況 2
封裝元件但不用 forwardRef
❌ 無法存取
會遇到問題,ref 不會傳遞
情況 3
使用 forwardRef
✅ 可以存取
製作可重用元件時必須使用
重點整理:
ref
在 React 中是保留屬性,不會傳遞到 props
原生 DOM 元素可以直接接收 ref
自訂函式元件無法直接接收 ref
需要使用 forwardRef
才能讓函式元件接收 ref
這是學習 useImperativeHandle
的必要前提
前置知識 2:React.forwardRef 前面我們已經說明過 ref 在 React 中的特殊性:ref 並不會像一般 props 一樣自動傳遞給函式元件。這裡再次強調,若直接將 ref 傳給函式元件,ref 不會進入 props,導致父元件無法取得子元件的 DOM 參考。因此,若要讓函式元件支援 ref,必須使用 forwardRef
。接下來我們會用實際範例說明這個現象與解決方式。
問題:ref 無法直接傳遞給函式元件 import React , { useRef } from 'react' ;function MyInput (props ) { console .log (props.ref ); return <input type ="text" /> ; } function App ( ) { const inputRef = useRef (); const handleFocus = ( ) => { inputRef.current .focus (); }; return ( <div > <MyInput ref ={inputRef} /> <button onClick ={handleFocus} > 聚焦輸入框</button > </div > ); }
問題分析:
ref
在 React 中是保留字,不會傳遞到 props
中
函式元件預設無法接收 ref
父元件無法透過 ref 存取子元件內部的 DOM 元素
forwardRef 解決方案 React.forwardRef
讓函式元件能夠接收 ref,並將它轉發到內部的 DOM 元素或其他元件。
語法結構:
const MyComponent = React .forwardRef ((props, ref ) => { return <div ref ={ref} > ...</div > ; });
使用 forwardRef 修正範例:
forwardRef 基本用法 import React , { useRef, forwardRef } from 'react' ;const MyInput = forwardRef ((props, ref ) => { return <input type ="text" ref ={ref} placeholder ={props.placeholder} /> ; }); function App ( ) { const inputRef = useRef (); const handleFocus = ( ) => { inputRef.current .focus (); }; const handleClear = ( ) => { inputRef.current .value = '' ; }; return ( <div > <MyInput ref ={inputRef} placeholder ="請輸入文字" /> <button onClick ={handleFocus} > 聚焦</button > <button onClick ={handleClear} > 清空</button > </div > ); }
執行結果:
點擊「聚焦」→ 輸入框獲得焦點
點擊「清空」→ 輸入框內容被清空
父元件可以透過 ref 直接操作子元件內部的 DOM
forwardRef 的運作方式 下面的範例示範如何設計一個可被父元件直接操作的 input 元件,並說明相關的實作方式。
forwardRef 詳細說明 const CustomButton = forwardRef ((props, ref ) => { const { children, onClick } = props; return ( <button ref ={ref} // 將 ref 轉發到實際的 DOM 元素 onClick ={onClick} style ={{ padding: '10px 20px ', fontSize: '16px ' }} > {children} </button > ); }); CustomButton .displayName = 'CustomButton' ;function Parent ( ) { const buttonRef = useRef (); const handleClick = ( ) => { console .log ('Button width:' , buttonRef.current .offsetWidth ); buttonRef.current .style .background = 'blue' ; }; return ( <div > <CustomButton ref ={buttonRef} onClick ={handleClick} > 點我 </CustomButton > </div > ); }
為什麼要設定 displayName?
當使用 forwardRef
包裝元件時,如果沒有設定 displayName
,在 React DevTools 中會顯示為通用的 <ForwardRef>
,這會讓除錯變得困難。
效果對比:
❌ 沒有設定:React DevTools 顯示 <ForwardRef>
✅ 設定後:React DevTools 顯示 <CustomButton>
當專案中有多個 forwardRef 元件時(如 CustomInput
、CustomButton
、CustomModal
),設定 displayName 可以幫助你快速識別是哪個元件。
如何安裝 React DevTools:
Chrome 瀏覽器:
Firefox 瀏覽器:
Edge 瀏覽器:
如何使用:
安裝後,打開瀏覽器的開發者工具(按 F12 )
你會看到新增了「⚛️ Components」和「⚛️ Profiler」兩個分頁
在 Components 分頁中可以看到 React 元件樹結構
這時候有設定 displayName
的元件會顯示有意義的名稱
forwardRef 的限制:破壞封裝性 forwardRef
雖然解決了 ref 傳遞的問題,但它有一個嚴重的缺點:父元件可以直接存取整個 DOM 節點,並進行任何操作 。這會破壞元件的封裝性,讓子元件無法控制父元件能做什麼。
舉例來說,我們設計了一個漂亮的輸入框元件,只想讓父元件能夠「聚焦」,但使用 forwardRef
後,父元件卻可以直接修改樣式、移除元素,甚至做出我們不希望的操作。
forwardRef 的封裝性問題 import React , { useRef, forwardRef } from 'react' ;const FancyInput = forwardRef ((props, ref ) => { return ( <div style ={{ padding: '10px ', border: '2px solid blue ', borderRadius: '8px ' }}> <label > {props.label}</label > <input ref ={ref} // 直接將 ref 轉發到 input type ="text" placeholder ={props.placeholder} style ={{ border: 'none ', outline: 'none ', fontSize: '16px ' }} /> </div > ); }); function App ( ) { const inputRef = useRef (); const handleGoodPractice = ( ) => { inputRef.current .focus (); }; const handleBadPractice = ( ) => { inputRef.current .style .display = 'none' ; inputRef.current .style .background = 'red' ; inputRef.current .value = '' ; inputRef.current .disabled = true ; inputRef.current .remove (); }; return ( <div > <FancyInput ref ={inputRef} label ="使用者名稱:" placeholder ="請輸入使用者名稱" /> <button onClick ={handleGoodPractice} > 聚焦(正常使用)</button > <button onClick ={handleBadPractice} > 破壞元件(不當使用)</button > </div > ); }
問題分析:
使用 forwardRef
後,inputRef.current
直接指向子元件內部的 <input>
DOM 元素,這意味著:
無法限制父元件的操作
子元件設計者想:「我只想讓父元件能聚焦」
但實際上:父元件可以做任何 DOM 操作
破壞設計意圖
子元件精心設計了樣式和行為
父元件可以直接修改,破壞一致性
容易引發 bug
父元件可能誤操作(如 .remove()
)
子元件無法防禦這些不當使用
違反封裝原則
好的元件設計應該隱藏內部實作細節
只暴露必要的公開 API
forwardRef
暴露了整個 DOM,失去了控制權
理想的解決方案應該是:
✅ 子元件可以選擇 要暴露哪些方法(如 focus
、clear
)
✅ 父元件只能 呼叫這些暴露的方法
✅ 子元件的內部實作受到保護
✅ 維持良好的封裝性
這就是為什麼需要 useImperativeHandle
!
forwardRef
解決了「能否傳遞 ref」的問題
useImperativeHandle
解決了「暴露什麼內容」的問題
結合兩者,我們可以讓子元件既能接收 ref,又能精確控制父元件可以做什麼,達到安全且靈活的元件設計。
useImperativeHandle 概念 useImperativeHandle
讓你可以「自訂」子元件要暴露給父元件的方法或屬性。它必須搭配 forwardRef
使用,讓父元件能夠透過 ref 來呼叫子元件內部的特定功能,同時保持良好的封裝性。
graph TB
Parent["父元件<br/>Parent Component<br/><br/>const video1Ref = useRef()<br/>const video2Ref = useRef()"]
subgraph Zone1["forwardRef 包裝的子元件"]
direction LR
subgraph Child1Zone[" "]
direction TB
Child1["VideoPlayer 1<br/>接收 ref 參數"]
Child1 --> UseImp1["useImperativeHandle<br/>定義暴露方法"]
UseImp1 --> Methods1["暴露方法:<br/>play / pause<br/>reset / getCurrentTime"]
end
subgraph Child2Zone[" "]
direction TB
Child2["VideoPlayer 2<br/>接收 ref 參數"]
Child2 --> UseImp2["useImperativeHandle<br/>定義暴露方法"]
UseImp2 --> Methods2["暴露方法:<br/>play / pause<br/>reset / getCurrentTime"]
end
end
Parent -- "① 傳遞 ref1" --> Child1
Parent -- "① 傳遞 ref2" --> Child2
Methods1 -. "③ ref1.current.play()" .-> Parent
Methods2 -. "③ ref2.current.play()" .-> Parent
style Parent fill:#ffe6e6
style Child1 fill:#e6f3ff
style Child2 fill:#e6f3ff
style UseImp1 fill:#fff4e6
style UseImp2 fill:#fff4e6
style Methods1 fill:#e6ffe6
style Methods2 fill:#e6ffe6
style Zone1 fill:#f5f5f5
style Child1Zone fill:#e6f3ff
style Child2Zone fill:#e6f3ff
核心概念:
forwardRef :讓子元件能夠接收父元件傳來的 ref
useImperativeHandle :自訂 ref 所能呼叫的方法
ref.current :父元件透過 ref.current 呼叫子元件暴露的方法
useImperativeHandle 語法 useImperativeHandle
讓你自訂當父元件使用 ref 時,子元件要暴露哪些方法或屬性。
語法結構:
useImperativeHandle (ref, createHandle, [dependencies])
參數詳細說明:
ref : 從 forwardRef
接收的 ref 物件。這是父元件傳入的 ref,我們要在這個 ref 上掛載自訂的方法。
createHandle : 一個函式,回傳一個物件,定義要暴露給父元件的方法或屬性。父元件可以透過 ref.current.METHOD_NAME()
來呼叫。
dependencies (可選): 依賴陣列。當依賴項改變時,會重新建立暴露的方法。類似 useCallback
的依賴陣列。
基本結構:
import { forwardRef, useImperativeHandle, useRef } from 'react' ;const MyComponent = forwardRef ((props, ref ) => { const internalRef = useRef (); useImperativeHandle (ref, () => ({ method1 : () => { }, method2 : () => { } })); return <div ref ={internalRef} > ...</div > ; });
解決方案:使用 useImperativeHandle 讓我們用 useImperativeHandle
來解決最早一開始前面的影片播放器問題:
使用 useImperativeHandle 解決 import React , { useRef, useImperativeHandle, forwardRef } from 'react' ;const VideoPlayer = forwardRef ((props, ref ) => { const { src } = props; const videoRef = useRef (); useImperativeHandle (ref, () => ({ play : () => { videoRef.current .play (); }, pause : () => { videoRef.current .pause (); }, reset : () => { videoRef.current .currentTime = 0 ; videoRef.current .pause (); }, getCurrentTime : () => { return videoRef.current .currentTime ; } })); return ( <div > <video ref ={videoRef} src ={src} width ="400" /> </div > ); }); function App ( ) { const video1Ref = useRef (); const video2Ref = useRef (); const handlePlayAll = ( ) => { video1Ref.current .play (); video2Ref.current .play (); }; const handlePauseAll = ( ) => { video1Ref.current .pause (); video2Ref.current .pause (); }; const handleResetAll = ( ) => { video1Ref.current .reset (); video2Ref.current .reset (); }; const handleGetTime = ( ) => { const time1 = video1Ref.current .getCurrentTime (); const time2 = video2Ref.current .getCurrentTime (); alert (`影片 1: ${time1.toFixed(2 )} 秒、n 影片 2: ${time2.toFixed(2 )} 秒` ); }; return ( <div > {/* 統一控制區 */} <div > <button onClick ={handlePlayAll} > 全部播放</button > <button onClick ={handlePauseAll} > 全部暫停</button > <button onClick ={handleResetAll} > 全部重置</button > <button onClick ={handleGetTime} > 查看播放時間</button > </div > <h2 > 影片 1</h2 > <VideoPlayer ref ={video1Ref} src ="video1.mp4" /> <h2 > 影片 2</h2 > <VideoPlayer ref ={video2Ref} src ="video2.mp4" /> </div > ); }
執行結果:
點擊「全部播放」→ 兩個影片同時播放
點擊「全部暫停」→ 兩個影片同時暫停
點擊「全部重置」→ 兩個影片回到 0 秒並暫停
點擊「查看播放時間」→ 顯示兩個影片的當前播放時間
優點:
✅ 父元件可以直接控制子元件的內部功能
✅ 子元件只暴露必要的方法,保持封裝性
✅ 適合製作可重複使用的元件庫
✅ 避免複雜的 props 和狀態同步
理解依賴陣列 useImperativeHandle
的第三個參數是依賴陣列,用來控制何時重新建立暴露的方法。
依賴陣列範例 const Counter = forwardRef ((props, ref ) => { const [count, setCount] = useState (0 ); const [step, setStep] = useState (1 ); useImperativeHandle (ref, () => ({ increment : () => { setCount (count + step); }, getCurrentCount : () => { return count; } }), [count, step]); return ( <div > <p > Count: {count}</p > <p > Step: {step}</p > <button onClick ={() => setStep(step + 1)}>增加步進值</button > </div > ); });
注意: 如果暴露的方法中使用了狀態或 props,記得將它們加入依賴陣列,否則方法會使用到過時的值(閉包陷阱)。
實際應用:表單控制 讓我們看一個更實用的例子:製作一個可從外部控制的表單元件。
表單控制範例 import React , { useState, useRef, useImperativeHandle, forwardRef } from 'react' ;const UserForm = forwardRef ((props, ref ) => { const [formData, setFormData] = useState ({ username : '' , email : '' , age : '' }); const [errors, setErrors] = useState ({}); useImperativeHandle (ref, () => ({ validate : () => { const newErrors = {}; if (!formData.username ) { newErrors.username = '請輸入使用者名稱' ; } if (!formData.email ) { newErrors.email = '請輸入電子郵件' ; } else if (!/\S+@\S+\.\S+/ .test (formData.email )) { newErrors.email = '電子郵件格式錯誤' ; } if (!formData.age ) { newErrors.age = '請輸入年齡' ; } else if (formData.age < 18 ) { newErrors.age = '年齡必須大於 18 歲' ; } setErrors (newErrors); return Object .keys (newErrors).length === 0 ; }, getData : () => { return formData; }, reset : () => { setFormData ({ username : '' , email : '' , age : '' }); setErrors ({}); }, setData : (data ) => { setFormData (data); setErrors ({}); }, focusFirstError : () => { const firstErrorField = Object .keys (errors)[0 ]; if (firstErrorField) { document .querySelector (`[name="${firstErrorField} "]` )?.focus (); } } }), [formData, errors]); const handleChange = (e ) => { const { name, value } = e.target ; setFormData (prev => ({ ...prev, [name]: name === 'age' ? Number (value) : value })); setErrors (prev => ({ ...prev, [name]: '' })); }; return ( <div > <div > <label > 使用者名稱:</label > <input type ="text" name ="username" value ={formData.username} onChange ={handleChange} /> {errors.username && <span style ={{ color: 'red ' }}> {errors.username}</span > } </div > <div > <label > 電子郵件:</label > <input type ="email" name ="email" value ={formData.email} onChange ={handleChange} /> {errors.email && <span style ={{ color: 'red ' }}> {errors.email}</span > } </div > <div > <label > 年齡:</label > <input type ="number" name ="age" value ={formData.age} onChange ={handleChange} /> {errors.age && <span style ={{ color: 'red ' }}> {errors.age}</span > } </div > </div > ); }); function RegistrationPage ( ) { const formRef = useRef (); const handleSubmit = ( ) => { if (formRef.current .validate ()) { const data = formRef.current .getData (); console .log ('提交資料:' , data); alert ('註冊成功!' ); formRef.current .reset (); } else { formRef.current .focusFirstError (); } }; const handleReset = ( ) => { formRef.current .reset (); }; const handleFillTestData = ( ) => { formRef.current .setData ({ username : 'testuser' , email : 'test@example.com' , age : 25 }); }; return ( <div > <h2 > 使用者註冊</h2 > <UserForm ref ={formRef} /> <div style ={{ marginTop: '20px ' }}> <button onClick ={handleSubmit} > 提交</button > <button onClick ={handleReset} > 重置</button > <button onClick ={handleFillTestData} > 填入測試資料</button > </div > </div > ); }
何時使用 useImperativeHandle? 讓我們先比較不同的父子元件溝通方式:
方法
適用場景
優點
缺點
Props
大部分情況
單向資料流、易於理解
無法讓父元件主動呼叫子元件方法
Callback
子元件通知父元件
符合 React 慣例
只能被動接收事件
useImperativeHandle
需要主動控制子元件
父元件可直接呼叫方法
打破單向資料流、容易濫用
Context
跨層級共享狀態
避免 props drilling
不適合頻繁更新的狀態
在選擇父子元件溝通方式時,建議依照以下優先順序進行:
優先使用 Props
適用於大多數情境,父元件透過 props 傳遞資料與狀態給子元件,維持單向資料流,易於理解與維護。
需要子元件主動通知父元件時,使用 Callback(回呼函式)
讓子元件在特定事件發生時,呼叫父元件傳遞下來的函式,達到事件上報的效果。
僅在必要時使用 useImperativeHandle
適用於以下特殊場景:
需要讓父元件主動控制子元件的 DOM 行為(如 focus、scroll 等)useImperativeHandle (ref, () => ({focus : () => inputRef.current .focus (),select : () => inputRef.current .select ()}));
整合第三方函式庫時需暴露特定方法useImperativeHandle (ref, () => ({ updateChart : (data ) => chartInstance.update (data), resetZoom : () => chartInstance.resetZoom () }));
製作可重複使用的元件庫,需提供程式化控制介面useImperativeHandle (ref, () => ({ open : () => setIsOpen (true ), close : () => setIsOpen (false ), toggle : () => setIsOpen (prev => !prev) }));
不適合使用 useImperativeHandle 的場景:
❌ 可以用 props 解決的情況
❌ 簡單的狀態傳遞
❌ 頻繁的資料同步
❌ 複雜的業務邏輯
依照上述順序選擇,能確保元件設計既符合 React 的最佳實踐,也能兼顧彈性與封裝性。
最佳實踐 在學習 useImperativeHandle
的實作細節之前,讓我們先了解一些實務上的最佳實踐。useImperativeHandle
雖然能讓父元件主動呼叫子元件的方法,但如果使用不當,容易造成元件耦合度過高、維護困難。因此,建議遵循以下原則來設計你的元件介面,確保彈性與封裝性兼具。
小技巧:設計 useImperativeHandle 介面時的思考重點
只暴露必要的操作方法,避免直接暴露 DOM 節點
方法名稱要語意明確,方便團隊協作與維護
適當加入錯誤處理,提升元件健壯性
若使用 TypeScript,記得定義好 ref 的型別
不要暴露整個 DOM 節點 useImperativeHandle (ref, () => inputRef.current );useImperativeHandle (ref, () => ({ focus : () => inputRef.current .focus (), clear : () => inputRef.current .value = '' }));
使用描述性的方法名稱 useImperativeHandle (ref, () => ({ do : () => { }, fn : () => { } })); useImperativeHandle (ref, () => ({ play : () => { }, pause : () => { }, reset : () => { } }));
加入錯誤處理 useImperativeHandle (ref, () => ({ play : () => { if (!videoRef.current ) { console .error ('Video element not ready' ); return ; } videoRef.current .play ().catch (error => { console .error ('Play failed:' , error); }); } }));
提供 TypeScript 型別定義 interface VideoPlayerHandle { play : () => void ; pause : () => void ; reset : () => void ; getCurrentTime : () => number ; } const VideoPlayer = forwardRef<VideoPlayerHandle , VideoPlayerProps >((props, ref ) => { useImperativeHandle (ref, () => ({ play : () => { }, pause : () => { }, reset : () => { }, getCurrentTime : () => videoRef.current .currentTime })); return <video /> ; });
總結 useImperativeHandle
必須搭配 forwardRef
使用,讓父元件可以呼叫子元件內部自訂的方法,例如 focus、reset 等。這種做法會打破 React 的單向資料流,因此建議僅在必要時(如控制 DOM、整合第三方函式庫、設計可重用元件 API)才使用。
使用時,應只暴露必要的方法,不要直接暴露整個 DOM 節點。方法名稱要清楚,並記得處理錯誤情況。如果方法中用到 state 或 props,務必加入依賴陣列,避免閉包陷阱。大多數情況下,優先考慮用 props 與 callback 溝通,只有在無法用 props 解決時才考慮 useImperativeHandle
。
重點整理:
必須與 forwardRef
搭配
只暴露必要的命令式方法
適合用於 DOM 操作、第三方函式庫整合、元件 API 封裝
避免用於一般狀態管理
優先考慮 props/callback,命令式控制為輔
useSyncExternalStore 在 React 應用中,我們通常用 useState
或 useReducer
來管理元件內部狀態。但有時候,我們需要訂閱「外部資料來源」,例如:
瀏覽器 API(如視窗大小 window.innerWidth
、網路狀態 navigator.onLine
)
第三方狀態管理庫(如 Redux、Zustand、MobX)
WebSocket 連線
localStorage/sessionStorage
在 React 18 之前,訂閱這些外部資料會遇到一個嚴重問題:在並發模式(Concurrent Mode)下可能會出現「撕裂」(Tearing)現象 。
問題案例:外部資料訂閱的撕裂(Tearing)問題 假設我們想顯示瀏覽器視窗的寬度,並在視窗大小改變時更新顯示:
問題:用 useEffect 訂閱外部資料(舊做法) import React , { useState, useEffect } from 'react' ;function useWindowWidth ( ) { const [width, setWidth] = useState (window .innerWidth ); useEffect (() => { const handleResize = ( ) => setWidth (window .innerWidth ); window .addEventListener ('resize' , handleResize); return () => window .removeEventListener ('resize' , handleResize); }, []); return width; } function Header ( ) { const width = useWindowWidth (); return <div > Header - 視窗寬度:{width}px</div > ; } function Sidebar ( ) { const width = useWindowWidth (); return <div > Sidebar - 視窗寬度:{width}px</div > ; } function Content ( ) { const width = useWindowWidth (); return <div > Content - 視窗寬度:{width}px</div > ; } function App ( ) { return ( <> <Header /> <Sidebar /> <Content /> </> ); }
問題:為什麼會出現「撕裂」?
撕裂問題的本質在於:每個元件在渲染時分別取得外部資料,導致同一畫面上顯示出彼此不一致的數據 。以下以並發模式為例,說明當使用者快速調整視窗大小時,會出現什麼狀況:
timeline
title 撕裂現象
section 使用者持續調整視窗(1200px → 800px)
0ms
: 捕獲 window.innerWidth = 1200px
: 渲染 Header 元件
10ms
: 捕獲 window.innerWidth = 1000px
: 渲染 Sidebar 元件
20ms
: 捕獲 window.innerWidth = 800px
: 渲染 Content 元件
section 提交到 DOM,畫面顯示
30ms
: Header = 1200px(❌ 舊數據)
: Sidebar = 1000px(❌ 中間數據)
: Content = 800px(✓ 最新數據)
撕裂現象說明:
每個元件在不同時間點 各自捕獲視窗寬度,導致讀取到不同的值
最終畫面同時出現三種不同的寬度 → 這就是「撕裂」(Tearing)!
使用者看到的是「不一致」的 UI 狀態
為什麼 Header 沒有跟著更新到 1000px 或 800px? 在這個例子中,Header 沒有跟著更新到 1000px 或 800px,主要原因在於 React 並發模式下的渲染流程設計。當使用者快速調整視窗大小時,每個元件(如 Header、Sidebar、Content)在各自被渲染的時間點,分別讀取到當下的 window.innerWidth
。React 為了提升效能,會優先讓畫面盡快顯示出來,因此 Header 可能在視窗寬度還是 1200px 時就已經完成渲染,接著 Sidebar 和 Content 則在後續不同的時間點分別取得 1000px 和 800px 的寬度。
這種設計下,React 不會在渲染進行到一半時就插入新的狀態更新,而是將像 setWidth(1000)
這類的狀態變更排到下一個渲染週期。也就是說,當前的渲染流程會持續到底,不會被中斷來處理新的外部資料變化。因此,Header 只會顯示它最初渲染時取得的寬度值(1200px),而 Sidebar 和 Content 則分別顯示各自渲染時取得的寬度(1000px、800px),導致同一畫面上出現不一致的數據,也就是所謂的「撕裂」現象。
總結來說,撕裂的產生是因為每個元件在不同的渲染時機各自讀取外部資料,React 又不會在渲染過程中即時同步所有元件的外部狀態,最終造成畫面短暫或持續的不一致。
撕裂現象是短暫的嗎? 不一定!這取決於兩個關鍵因素:
外部資料變化的頻率
如果外部資料(如 window.innerWidth
)持續快速變化 ,撕裂現象會一直存在
例如:使用者持續拖曳視窗調整大小時,每次渲染都可能讀到不同的值
即使渲染週期完成,下一次變化又會產生新的撕裂
使用 useEffect + useState 的方式
即使外部資料穩定下來,各元件的 useEffect
執行時機仍然不同
React 無法保證所有元件在同一時間點「快照」相同的值
在並發模式下,渲染可能被中斷和恢復,進一步加劇不一致問題
問題點
說明
各自為政
每個元件都有自己的 useState
,無法共享「快照時間點」
讀取時機不同
Header、Sidebar、Content 在不同時間點呼叫 window.innerWidth
React 無法追蹤
React 不知道這些狀態來自同一個外部資料源(window.innerWidth
)
並發模式的本質
渲染可以被中斷,恢復時外部資料可能已經變了
結論:
❌ 在快速變化的場景下(如視窗調整、滾動事件),撕裂會持續發生
❌ 即使資料穩定,也可能在某些渲染週期出現短暫撕裂
✅ 使用 useSyncExternalStore
可以完全避免撕裂,確保所有元件讀取到一致的快照值
useSyncExternalStore 語法 useSyncExternalStore
是 React 18 推出的官方 Hook,專為安全訂閱外部資料來源 而設計,並在資料變化時自動重新渲染元件,能在並發模式下徹底解決「撕裂」(Tearing)問題。
三大核心機制:
機制
說明
作用
同步快照(Sync)
所有元件在同一時間點讀取相同的外部資料
避免各元件取得不同版本的數據
外部資料來源(External Store)
訂閱 React 狀態以外的資料源
追蹤瀏覽器 API、全域物件、第三方庫等
訂閱通知(Subscribe)
外部資料變動時自動通知 React
觸發所有相關元件重新渲染
語法結構: useSyncExternalStore 需要提供指定參數來設定外部資料來源和同步快照,並返回當前外部資料的快照值。
const snapshot = useSyncExternalStore (subscribe, getSnapshot, getServerSnapshot?)
重要注意事項:
React 會在每次渲染時呼叫 subscribe
和 getSnapshot
,如果這些函式在元件內部定義,會導致每次渲染都建立新的函式參考,造成重複訂閱(re-subscribe)與效能浪費。
建議將 subscribe
和 getSnapshot
定義在元件外部,或用 useCallback
包裝,確保函式參考穩定,避免不必要的重購與重新渲染。
這樣可以提升效能、減少記憶體洩漏風險,並讓 UI 更加穩定。
參數詳細說明:
subscribe (必填): 訂閱函式,當外部資料變化時,React 會呼叫這個函式來訂閱變化。它接收一個 callback
參數,當資料變化時需要呼叫這個 callback 通知 React。必須回傳一個「取消訂閱」的函式。
const subscribe = (callback ) => { externalStore.addEventListener ('change' , callback); return () => { externalStore.removeEventListener ('change' , callback); }; };
subscribe 函式必須回傳清理函式
元件卸載時會自動呼叫清理函式
忘記取消訂閱會造成記憶體洩漏
getSnapshot (必填): getSnapshot 是一個取得快照的函式,負責回傳目前外部資料的最新值。React 在渲染時會自動呼叫這個函式,來取得最新快照。只要資料沒變,getSnapshot 必須回傳同一個值的參考 ,這樣 React 才能正確判斷是否需要重新渲染。
const getSnapshot = ( ) => { return externalStore.getCurrentValue (); };
getSnapshot 必須回傳「穩定且一致」的值
如果外部資料沒有變化,每次呼叫都要回傳完全相同的參考(React 會用 Object.is
來判斷是否相同)
切勿每次都回傳新的物件或陣列,否則會造成元件不必要的重新渲染
建議:可利用 memoization(記憶化)或快取機制,確保回傳值的參考穩定
getServerSnapshot (可選): 伺服器端快照函式,用於伺服器端渲染(SSR)。因為伺服器端沒有瀏覽器 API,需要提供一個預設值。
const getServerSnapshot = ( ) => { return defaultValue; };
SSR 注意事項
如果使用 SSR,務必提供 getServerSnapshot
伺服器端和客戶端的初始值應該一致,避免 hydration 錯誤
解決方案:使用 useSyncExternalStore 讓我們用 useSyncExternalStore
來重寫前面的視窗寬度範例:
錯誤:函式定義在 Hook 內部 import React , { useSyncExternalStore } from 'react' ;function useWindowWidth ( ) { const width = useSyncExternalStore ( (callback ) => { window .addEventListener ('resize' , callback); return () => window .removeEventListener ('resize' , callback); }, () => window .innerWidth , () => 1024 ); return width; } function WindowWidth ( ) { const width = useWindowWidth (); return <div > 視窗寬度:{width}px</div > ; } function App ( ) { return ( <div > <WindowWidth /> </div > ); }
問題分析:
每次 WindowWidth
元件渲染時,都會呼叫 useWindowWidth()
每次呼叫都建立新的 subscribe、getSnapshot、getServerSnapshot 函式
React 發現函式參考變了,就會重新訂閱 (取消舊訂閱,建立新訂閱)
造成不必要的效能浪費和事件監聽器的重複註冊
正確:函式定義在元件外部 import React , { useSyncExternalStore } from 'react' ;const subscribe = (callback ) => { window .addEventListener ('resize' , callback); return () => window .removeEventListener ('resize' , callback); }; const getSnapshot = ( ) => window .innerWidth ;const getServerSnapshot = ( ) => 1024 ;function useWindowWidth ( ) { const width = useSyncExternalStore ( subscribe, getSnapshot, getServerSnapshot ); return width; } function WindowWidth ( ) { const width = useWindowWidth (); return <div > 視窗寬度:{width}px</div > ; } function App ( ) { return ( <div > <WindowWidth /> </div > ); }
優點:
✅ 函式參考永遠相同,不會重新訂閱
✅ 效能最佳
✅ 程式碼簡潔
正確:使用 useCallback 包裝 import React , { useSyncExternalStore, useCallback } from 'react' ;function useWindowWidth ( ) { const subscribe = useCallback ((callback ) => { window .addEventListener ('resize' , callback); return () => window .removeEventListener ('resize' , callback); }, []); const getSnapshot = useCallback (() => window .innerWidth , []); const getServerSnapshot = useCallback (() => 1024 , []); const width = useSyncExternalStore ( subscribe, getSnapshot, getServerSnapshot ); return width; } function WindowWidth ( ) { const width = useWindowWidth (); return <div > 視窗寬度:{width}px</div > ; } function App ( ) { return ( <div > <WindowWidth /> </div > ); }
適用情境:
當函式需要存取元件的 props 或 state 時
無法將函式定義在元件外部時
需要動態產生訂閱邏輯時
執行結果說明:
當你調整瀏覽器視窗大小時,三個 WindowWidth
元件會同時顯示相同的寬度數值。由於使用了 useSyncExternalStore
,不會出現撕裂(tearing)問題,確保 UI 始終保持一致,所有元件都能正確同步顯示最新的視窗寬度。
優點:
✅ 解決並發模式下的撕裂問題
✅ 所有元件讀取到相同的值
✅ 自動處理訂閱和取消訂閱
✅ 支援伺服器端渲染(SSR)
實際應用:常見的外部資料訂閱 在實務開發中,useSyncExternalStore
主要用於訂閱「外部可觀察資料來源」的狀態,例如:網路連線狀態、localStorage、WebSocket、瀏覽器 API 等。這些外部來源的資料變動不受 React 控制,若直接用一般 state 可能會導致 UI 不一致或撕裂(tearing)問題。useSyncExternalStore
能確保 React 在並發渲染(Concurrent Rendering)下,所有訂閱該資料的元件都能取得最新且一致的狀態,特別適合「多個元件需要同步外部資料」的場景,並自動處理訂閱與取消訂閱,避免重複監聽或記憶體洩漏。
以下將介紹幾個常見的外部資料訂閱應用範例,幫助你快速上手。
注意事項
以下所有範例都沒有將 subscribe
、getSnapshot
等函式放在外部或用 useCallback
包覆,因此每次渲染都會產生新函式 ,導致效能問題(如重複訂閱、記憶體洩漏)。實務上應將這些函式宣告在元件外部,或用 useCallback
包裹,確保函式參考穩定,避免不必要的副作用。
1. 訂閱網路狀態 訂閱網路狀態 import React , { useSyncExternalStore } from 'react' ;function useOnlineStatus ( ) { const isOnline = useSyncExternalStore ( (callback ) => { window .addEventListener ('online' , callback); window .addEventListener ('offline' , callback); return () => { window .removeEventListener ('online' , callback); window .removeEventListener ('offline' , callback); }; }, () => navigator.onLine , () => true ); return isOnline; } function NetworkStatus ( ) { const isOnline = useOnlineStatus (); return ( <div style ={{ padding: '10px ', background: isOnline ? '#4caf50 ' : '#f44336 ', color: 'white ' }}> 網路狀態:{isOnline ? '線上 🟢' : '離線 🔴'} </div > ); }
2. 訂閱 localStorage 訂閱 localStorage import React , { useSyncExternalStore } from 'react' ;function useLocalStorage (key, defaultValue ) { const value = useSyncExternalStore ( (callback ) => { window .addEventListener ('storage' , callback); return () => window .removeEventListener ('storage' , callback); }, () => { const item = localStorage .getItem (key); return item ? JSON .parse (item) : defaultValue; }, () => defaultValue ); const setValue = (newValue ) => { localStorage .setItem (key, JSON .stringify (newValue)); window .dispatchEvent (new Event ('storage' )); }; return [value, setValue]; } function ThemeSwitcher ( ) { const [theme, setTheme] = useLocalStorage ('theme' , 'light' ); return ( <div > <p > 當前主題:{theme}</p > <button onClick ={() => setTheme('light')}>淺色</button > <button onClick ={() => setTheme('dark')}>深色</button > </div > ); }
3. 訂閱第三方狀態管理庫 訂閱外部 Store import React , { useSyncExternalStore } from 'react' ;class CounterStore { constructor ( ) { this .count = 0 ; this .listeners = []; } getSnapshot = () => { return this .count ; }; subscribe = (listener ) => { this .listeners .push (listener); return () => { this .listeners = this .listeners .filter (l => l !== listener); }; }; increment = () => { this .count += 1 ; this .listeners .forEach (listener => listener ()); }; decrement = () => { this .count -= 1 ; this .listeners .forEach (listener => listener ()); }; } const counterStore = new CounterStore ();function useCounter ( ) { const count = useSyncExternalStore ( counterStore.subscribe , counterStore.getSnapshot ); return { count, increment : counterStore.increment , decrement : counterStore.decrement }; } function Counter ( ) { const { count, increment, decrement } = useCounter (); return ( <div > <h3 > 計數器:{count}</h3 > <button onClick ={decrement} > -1</button > <button onClick ={increment} > +1</button > </div > ); } function CounterDisplay ( ) { const { count } = useCounter (); return ( <div > <p > 當前計數:{count}</p > <p > 是否為偶數:{count % 2 === 0 ? '是' : '否'}</p > </div > ); } function App ( ) { return ( <div > <Counter /> <CounterDisplay /> {/* 兩個元件共享同一個 store,計數永遠同步 */} </div > ); }
優點:
✅ 多個元件共享同一個外部狀態
✅ 避免並發模式下的撕裂問題
✅ 所有訂閱者同時更新
何時使用 useSyncExternalStore? 讓我們比較兩種訂閱外部資料的方式:
特性
useEffect + useState
useSyncExternalStore
並發模式安全
❌ 可能出現撕裂
✅ 完全安全
多元件同步
❌ 可能不一致
✅ 保證一致
SSR 支援
⚠️ 需額外處理
✅ 內建支援
使用複雜度
✅ 較簡單
⚠️ 稍複雜
適用場景
React 17 及以下
React 18+ 並發模式
適合使用:
✅ 訂閱瀏覽器 API(window、navigator 等)
✅ 整合第三方狀態管理庫(Redux、Zustand、Jotai 等)
✅ 訂閱 WebSocket 或其他即時資料
✅ 需要在多個元件間共享外部狀態
✅ 使用 React 18+ 並發模式
不需要使用:
❌ React 內部狀態管理(用 useState / useReducer)
❌ 父子元件溝通(用 props / callback)
❌ 跨元件狀態共享(用 Context)
❌ 伺服器資料獲取(用 React Query / SWR)
總結 useSyncExternalStore
是 React 18 專門為解決並發模式下外部資料訂閱問題而設計的 Hook。它透過「同步讀取快照」的機制,確保所有元件在同一時間點讀取到相同的外部資料,避免 UI 不一致的問題。
核心價值:
解決並發模式下的撕裂(Tearing)問題
確保多個元件訂閱相同外部資料時的一致性
提供官方的外部資料訂閱標準做法
使用要點:
必須提供 subscribe
函式(訂閱機制)
必須提供 getSnapshot
函式(取得當前值)
SSR 時需提供 getServerSnapshot
(預設值)
適合訂閱瀏覽器 API 和第三方狀態管理庫
最佳實踐:
將訂閱邏輯封裝成自訂 Hook(如 useWindowWidth
、useOnlineStatus
)
確保 getSnapshot
回傳的值在未變化時保持相同
記得在 subscribe
中回傳取消訂閱函式
SSR 應用一定要提供 getServerSnapshot
記住: useSyncExternalStore
是為「外部資料源」設計的,不要用它來管理 React 內部狀態。大部分情況下,useState
、useReducer
和 useContext
就足夠了。只有在需要訂閱 React 之外的資料時,才使用這個 Hook。
React 19 新增 Hooks React 19 引入了兩個全新的 Hooks,進一步提升了表單處理和樂觀更新的開發體驗。
useActionState 在傳統的表單處理中,我們需要手動管理各種狀態:表單提交中(loading)、錯誤訊息、成功訊息、表單資料等。這會導致:
需要多個 useState
來管理不同狀態
需要 useEffect
來處理非同步提交
需要手動處理 loading 狀態和錯誤處理
程式碼複雜且容易出錯
useActionState
是 React 19 新增的 Hook,專門用於簡化表單處理和非同步狀態管理 ,將表單提交、pending 狀態、錯誤處理整合在一起。
問題案例:傳統表單處理的複雜性 假設我們要建立一個聯絡表單,需要處理提交、驗證、錯誤和成功訊息:
問題:傳統做法(需要管理多個狀態) import React , { useState } from 'react' ;function ContactForm ( ) { const [name, setName] = useState ('' ); const [email, setEmail] = useState ('' ); const [isLoading, setIsLoading] = useState (false ); const [error, setError] = useState (null ); const [success, setSuccess] = useState (false ); const handleSubmit = async (e ) => { e.preventDefault (); setIsLoading (true ); setError (null ); setSuccess (false ); try { if (!name || name.length < 2 ) { throw new Error ('姓名至少需要 2 個字元' ); } if (!email || !email.includes ('@' )) { throw new Error ('請輸入有效的電子郵件' ); } await new Promise (resolve => setTimeout (resolve, 1000 )); setSuccess (true ); setName ('' ); setEmail ('' ); } catch (err) { setError (err.message ); } finally { setIsLoading (false ); } }; return ( <div > <h3 > 聯絡表單</h3 > <form onSubmit ={handleSubmit} > <div > <label > 姓名: <input type ="text" value ={name} onChange ={(e) => setName(e.target.value)} disabled={isLoading} /> </label > </div > <div > <label > 電子郵件: <input type ="email" value ={email} onChange ={(e) => setEmail(e.target.value)} disabled={isLoading} /> </label > </div > <button type ="submit" disabled ={isLoading} > {isLoading ? '提交中。..' : '提交'} </button > </form > {error && <p style ={{ color: 'red ' }}> 錯誤:{error}</p > } {success && <p style ={{ color: 'green ' }}> 表單提交成功!</p > } </div > ); }
問題分析:
問題
說明
狀態管理複雜
需要 5 個 useState
:name、email、isLoading、error、success
重複的邏輯
每次提交都要手動設定 loading、清除錯誤、處理 try-catch
受控元件
需要為每個欄位寫 value
和 onChange
程式碼冗長
簡單的表單就需要 50+ 行程式碼
傳統做法的痛點:
❌ 需要手動管理多個相關狀態
❌ 需要手動處理 loading 和錯誤狀態
❌ 需要在 try-catch-finally
中管理狀態
❌ 程式碼冗長且容易出錯
useActionState 語法 useActionState
將表單動作(Action) 和狀態管理 整合在一起,簡化表單處理流程:
核心概念
說明
優勢
Action 函式
處理表單提交的非同步函式
集中處理邏輯,易於測試
State 管理
自動管理表單狀態(成功/錯誤/資料)
不需要多個 useState
Pending 狀態
自動追蹤非同步操作進行中
不需要手動管理 loading
非受控表單
使用 FormData
,不需要 value
/onChange
減少重新渲染,提升效能
基本語法:
const [state, formAction, isPending] = useActionState (action, initialState, permalink?)
參數說明:
1. action(必填)— Action 函式
處理表單提交的非同步函式,接收兩個參數:
async function action (previousState, formData ) { const name = formData.get ('name' ); return { success : true , message : '提交成功!' }; }
參數
說明
previousState
上一次的狀態,可用於累積資料或錯誤處理
formData
表單資料,使用 formData.get('欄位名')
取值
回傳值
新的狀態物件,可以是任何形狀的物件
2. initialState(必填)— 初始狀態
表單的初始狀態物件:
const initialState = { success : false , error : null , message : null , data : {} };
3. permalink(可選)— 永久連結
用於 Server Actions,指定表單提交後的 URL(進階功能,通常不需要)。
回傳值:
const [state, formAction, isPending] = useActionState (action, initialState);
回傳值
說明
state
當前狀態,由 action 函式回傳
formAction
綁定到 <form action={formAction}>
的函式
isPending
布林值,表示 action 是否正在執行中
解決方案:使用 useActionState 讓我們用 useActionState
重寫前面的聯絡表單,讓它成為正確的表單提交流程:
前端驗證 → 檢查資料格式(例如:欄位不可為空、email 格式正確)
呼叫 API → 驗證通過後,才送資料到後端
處理回應 → 根據 API 回應顯示成功或錯誤訊息
這樣可以避免無效的 API 請求,節省網路資源和伺服器負擔。
解決方案:使用 useActionState(簡潔) import React , { useActionState } from 'react' ;async function submitForm (previousState, formData ) { const name = formData.get ('name' ); const email = formData.get ('email' ); if (!name || name.length < 2 ) { return { success : false , error : '姓名至少需要 2 個字元' }; } if (!email || !email.includes ('@' )) { return { success : false , error : '請輸入有效的電子郵件' }; } await new Promise (resolve => setTimeout (resolve, 1000 )); return { success : true , message : '表單提交成功!' }; } function ContactForm ( ) { const [state, formAction, isPending] = useActionState (submitForm, { success : false , error : null , message : null }); return ( <div > <h3 > 聯絡表單</h3 > <form action ={formAction} > <div > <label > 姓名: <input type ="text" name ="name" disabled ={isPending} /> </label > </div > <div > <label > 電子郵件: <input type ="email" name ="email" disabled ={isPending} /> </label > </div > <button type ="submit" disabled ={isPending} > {isPending ? '提交中。..' : '提交'} </button > </form > {state.error && <p style ={{ color: 'red ' }}> 錯誤:{state.error}</p > } {state.success && <p style ={{ color: 'green ' }}> {state.message}</p > } </div > ); }
對比傳統做法的改進:
項目
傳統做法
useActionState
狀態管理
5 個 useState
1 個 useActionState
Loading 狀態
手動管理 isLoading
自動提供 isPending
錯誤處理
手動 try-catch
Action 函式直接回傳錯誤
表單控制
受控元件(需要 value
/onChange
)
非受控(使用 FormData
)
程式碼行數
~50 行
~30 行
useActionState 的優勢:
✅ 不需要多個 useState
,狀態集中管理
✅ 自動處理 isPending
,不需要手動管理 loading
✅ 使用 FormData
,不需要受控元件
✅ 程式碼更簡潔、易維護
實際應用:常見場景 應用 1:多欄位驗證表單 處理多個欄位的驗證錯誤:
多欄位驗證 import React , { useActionState } from 'react' ;async function registerUser (previousState, formData ) { const username = formData.get ('username' ); const email = formData.get ('email' ); const password = formData.get ('password' ); const errors = {}; if (!username || username.length < 3 ) { errors.username = '用戶名至少需要 3 個字元' ; } if (!email || !email.includes ('@' )) { errors.email = '請輸入有效的電子郵件' ; } if (!password || password.length < 6 ) { errors.password = '密碼至少需要 6 個字元' ; } if (Object .keys (errors).length > 0 ) { return { success : false , errors }; } await new Promise (resolve => setTimeout (resolve, 1000 )); return { success : true , message : '註冊成功!' }; } function RegisterForm ( ) { const [state, formAction, isPending] = useActionState (registerUser, { success : false , errors : {}, message : null }); return ( <form action ={formAction} > <div > <label > 用戶名: <input type ="text" name ="username" disabled ={isPending} /> </label > {state.errors?.username && ( <p style ={{ color: 'red ' }}> {state.errors.username}</p > )} </div > <div > <label > 電子郵件: <input type ="email" name ="email" disabled ={isPending} /> </label > {state.errors?.email && ( <p style ={{ color: 'red ' }}> {state.errors.email}</p > )} </div > <div > <label > 密碼: <input type ="password" name ="password" disabled ={isPending} /> </label > {state.errors?.password && ( <p style ={{ color: 'red ' }}> {state.errors.password}</p > )} </div > <button type ="submit" disabled ={isPending} > {isPending ? '註冊中。..' : '註冊'} </button > {state.success && <p style ={{ color: 'green ' }}> {state.message}</p > } </form > ); }
應用 2:搜尋表單 實現即時搜尋功能:
搜尋表單 import React , { useActionState } from 'react' ;async function searchAction (previousState, formData ) { const query = formData.get ('query' ); if (!query || query.length < 2 ) { return { results : [], error : '請輸入至少 2 個字元' }; } await new Promise (resolve => setTimeout (resolve, 500 )); const mockResults = [ { id : 1 , name : `結果:${query} 1` }, { id : 2 , name : `結果:${query} 2` }, { id : 3 , name : `結果:${query} 3` } ]; return { results : mockResults, error : null }; } function SearchForm ( ) { const [state, formAction, isPending] = useActionState (searchAction, { results : [], error : null }); return ( <div > <form action ={formAction} > <input type ="text" name ="query" placeholder ="搜尋。.." disabled ={isPending} /> <button type ="submit" disabled ={isPending} > {isPending ? '搜尋中。..' : '搜尋'} </button > </form > {state.error && <p style ={{ color: 'red ' }}> {state.error}</p > } <ul > {state.results.map(item => ( <li key ={item.id} > {item.name}</li > ))} </ul > </div > ); }
應用 3:使用 previousState 累積資料 利用 previousState
來保留歷史資料:
累積留言 import React , { useActionState } from 'react' ;async function addComment (previousState, formData ) { const comment = formData.get ('comment' ); if (!comment) { return { ...previousState, error : '請輸入留言' }; } await new Promise (resolve => setTimeout (resolve, 500 )); return { comments : [ ...previousState.comments , { id : Date .now (), text : comment } ], error : null }; } function CommentForm ( ) { const [state, formAction, isPending] = useActionState (addComment, { comments : [], error : null }); return ( <div > <h3 > 留言板</h3 > <form action ={formAction} > <textarea name ="comment" disabled ={isPending} /> <button type ="submit" disabled ={isPending} > {isPending ? '送出中。..' : '送出留言'} </button > </form > {state.error && <p style ={{ color: 'red ' }}> {state.error}</p > } <ul > {state.comments.map(comment => ( <li key ={comment.id} > {comment.text}</li > ))} </ul > </div > ); }
總結 useActionState
是 React 19 專為簡化表單處理 而設計的 Hook,將 Action 函式、狀態管理、Pending 狀態整合在一起。
重點回顧:
項目
說明
核心功能
處理表單提交和非同步狀態管理
解決問題
傳統表單需要多個 useState
和手動管理 loading
三個回傳值
state
(狀態)、formAction
(綁定表單)、isPending
(pending 狀態)
關鍵優勢
使用 FormData
,不需要受控元件,減少重新渲染
最佳實踐:
✅ 使用 FormData
取得表單資料,避免受控元件
✅ 在 Action 函式中處理驗證和錯誤
✅ 使用 isPending
禁用表單元素,提升 UX
✅ 利用 previousState
累積或保留歷史資料
✅ Action 函式可獨立測試,易於維護
適用場景:
適合
說明
✅ 表單提交
聯絡表單、註冊表單、搜尋表單
✅ 非同步操作
API 呼叫、資料驗證
✅ 狀態累積
留言板、待辦事項
✅ Server Actions
Next.js 等框架的伺服器端操作
不適合
說明
❌ 複雜表單邏輯
多步驟表單、複雜的條件邏輯(建議用表單庫)
❌ 即時驗證
需要即時反饋的欄位(用 onChange
更適合)
❌ 非表單場景
不涉及表單提交的狀態管理(用 useState
)
與傳統做法的對比: useActionState
最大的價值在於減少狀態管理的複雜度 和使用非受控表單提升效能 。如果你的表單需要即時驗證或複雜的欄位互動,可能需要結合受控元件或使用專門的表單庫(如 React Hook Form、Formik)。
useOptimistic useOptimistic
讓你可以樂觀地更新 UI,在等待非同步操作完成時先顯示預期的結果。
useOptimistic 基本用法 import React , { useState, useOptimistic, useRef } from 'react' ;async function sendMessage (message ) { await new Promise (resolve => setTimeout (resolve, 1000 )); if (Math .random () < 0.1 ) { throw new Error ('網路錯誤,訊息發送失敗' ); } return { id : Date .now (), text : message, timestamp : new Date (), status : 'sent' }; } function ChatApp ( ) { const [messages, setMessages] = useState ([]); const [optimisticMessages, addOptimisticMessage] = useOptimistic ( messages, (state, newMessage ) => [...state, { ...newMessage, status : 'sending' }] ); const [input, setInput] = useState ('' ); const formRef = useRef (); const handleSendMessage = async (formData ) => { const messageText = formData.get ('message' ); if (!messageText.trim ()) return ; const optimisticMessage = { id : `temp-${Date .now()} ` , text : messageText, timestamp : new Date (), status : 'sending' }; addOptimisticMessage (optimisticMessage); setInput ('' ); formRef.current .reset (); try { const sentMessage = await sendMessage (messageText); setMessages (prevMessages => [...prevMessages, sentMessage]); } catch (error) { console .error ('發送失敗:' , error); alert ('訊息發送失敗,請重試' ); } }; return ( <div style ={{ maxWidth: '500px ', margin: '0 auto ' }}> <h3 > 即時聊天</h3 > <div style ={{ height: '300px ', border: '1px solid #ccc ', padding: '10px ', overflowY: 'scroll ', marginBottom: '10px ' }} > {optimisticMessages.map((message, index) => ( <div key ={message.id || index } style ={{ padding: '8px ', margin: '4px 0 ', backgroundColor: message.status === 'sending' ? '#f0f8ff ' : '#f5f5f5 ', borderRadius: '8px ', opacity: message.status === 'sending' ? 0.7 : 1 }} > <div > {message.text}</div > <small style ={{ color: '#666 ' }}> {message.timestamp.toLocaleTimeString()} {message.status === 'sending' && ' (發送中。..)'} </small > </div > ))} </div > <form action ={handleSendMessage} ref ={formRef} > <input type ="text" name ="message" value ={input} onChange ={(e) => setInput(e.target.value)} placeholder="輸入訊息。.." style={{ width: '70%', padding: '8px' }} /> <button type ="submit" style ={{ width: '25 %', padding: '8px ', marginLeft: '5 %' }} > 發送 </button > </form > </div > ); }
useOptimistic 複雜範例 import React , { useState, useOptimistic } from 'react' ;async function toggleLike (postId, currentLiked ) { await new Promise (resolve => setTimeout (resolve, 800 )); if (Math .random () < 0.1 ) { throw new Error ('網路連接失敗' ); } return !currentLiked; } function SocialPost ({ post } ) { const [likes, setLikes] = useState ({ count : post.likes , isLiked : post.isLiked }); const [optimisticLikes, updateOptimisticLikes] = useOptimistic ( likes, (state, { type, value } ) => { switch (type) { case 'toggle_like' : return { count : state.count + (state.isLiked ? -1 : 1 ), isLiked : !state.isLiked }; default : return state; } } ); const handleLike = async ( ) => { updateOptimisticLikes ({ type : 'toggle_like' }); try { const newLikedState = await toggleLike (post.id , likes.isLiked ); setLikes (prevLikes => ({ count : prevLikes.count + (newLikedState ? 1 : -1 ), isLiked : newLikedState })); } catch (error) { console .error ('點讚失敗:' , error); alert ('點讚失敗,請重試' ); } }; return ( <div style ={{ border: '1px solid #ddd ', borderRadius: '8px ', padding: '16px ', margin: '16px 0 ' }}> <h4 > {post.title}</h4 > <p > {post.content}</p > <div style ={{ display: 'flex ', alignItems: 'center ', gap: '8px ' }}> <button onClick ={handleLike} style ={{ background: optimisticLikes.isLiked ? '#ff6b6b ' : '#f1f1f1 ', color: optimisticLikes.isLiked ? 'white ' : 'black ', border: 'none ', borderRadius: '4px ', padding: '8px 16px ', cursor: 'pointer ' }} > {optimisticLikes.isLiked ? '❤️ 已讚' : '🤍 讚'} </button > <span > {optimisticLikes.count} 個讚 </span > </div > </div > ); } function SocialFeed ( ) { const posts = [ { id : 1 , title : '學習 React 19' , content : '今天學會了 useOptimistic Hook,太酷了!' , likes : 5 , isLiked : false }, { id : 2 , title : '樂觀更新的魅力' , content : 'useOptimistic 讓 UI 反應更加即時,用戶體驗大幅提升。' , likes : 12 , isLiked : true } ]; return ( <div style ={{ maxWidth: '600px ', margin: '0 auto ' }}> <h3 > 社群動態</h3 > {posts.map(post => ( <SocialPost key ={post.id} post ={post} /> ))} </div > ); }
graph TD
A["用戶點擊"] --> B["樂觀更新 UI"]
B --> C["發送 API 請求"]
C --> D{"API 成功?"}
D -->|是| E["更新真實狀態"]
D -->|否| F["恢復原始狀態"]
E --> G["UI 保持更新"]
F --> H["顯示錯誤訊息"]
style B fill:#e8f5e8
style E fill:#e1f5fe
style F fill:#ffebee
React 19 新功能的優勢:
useActionState
簡化了表單狀態管理,提供更好的用戶體驗
useOptimistic
讓 UI 反應更加即時,減少用戶等待時間
兩者都與 Server Components 和 Server Actions 完美整合
提供更直覺的錯誤處理和恢復機制
特殊用途 Hooks 這些 Hooks 在特定場景下非常有用,雖然不常用但很重要。
useId useId
產生唯一的 ID,主要用於可訪問性屬性和服務端渲染。
useId 基本用法 import React , { useId } from 'react' ;function LoginForm ( ) { const id = useId (); return ( <form > <label htmlFor ={ `${id }-email `}> 電子郵件:</label > <input id ={ `${id }-email `} type ="email" /> <label htmlFor ={ `${id }-password `}> 密碼:</label > <input id ={ `${id }-password `} type ="password" /> <button type ="submit" > 登入</button > </form > ); } function App ( ) { return ( <div > <h2 > 登入區域</h2 > <LoginForm /> <h2 > 註冊區域</h2 > <LoginForm /> </div > ); }
useId 複雜範例 import React , { useId, useState } from 'react' ;function FormField ({ label, type = 'text' , required = false , helpText } ) { const id = useId (); const [value, setValue] = useState ('' ); const [showError, setShowError] = useState (false ); const handleBlur = ( ) => { if (required && !value.trim ()) { setShowError (true ); } else { setShowError (false ); } }; return ( <div style ={{ marginBottom: '16px ' }}> <label htmlFor ={id} style ={{ display: 'block ', marginBottom: '4px ', fontWeight: 'bold ' }} > {label} {required && <span style ={{ color: 'red ' }}> *</span > } </label > <input id ={id} type ={type} value ={value} onChange ={(e) => setValue(e.target.value)} onBlur={handleBlur} aria-describedby={helpText ? `${id}-help` : undefined} aria-invalid={showError} style={{ width: '100%', padding: '8px', border: showError ? '1px solid red' : '1px solid #ccc', borderRadius: '4px' }} /> {helpText && ( <div id ={ `${id }-help `} style ={{ fontSize: '0.8em ', color: '#666 ', marginTop: '4px ' }} > {helpText} </div > )} {showError && ( <div style ={{ fontSize: '0.8em ', color: 'red ', marginTop: '4px ' }} role ="alert" > 此欄位為必填 </div > )} </div > ); } function AccessibleForm ( ) { return ( <form > <h3 > 用戶資料表單</h3 > <FormField label ="姓名" required helpText ="請輸入您的真實姓名" /> <FormField label ="電子郵件" type ="email" required helpText ="我們會使用此信箱與您聯繫" /> <FormField label ="電話號碼" type ="tel" helpText ="格式:0912-345-678" /> <button type ="submit" > 提交</button > </form > ); }
useId 重要特性:
在服務端和客戶端產生相同的 ID,避免 hydration 不匹配
每次呼叫都會產生唯一的 ID
不應該用作 key 屬性,請用於可訪問性屬性
ID 格式可能在不同版本間變化,不要依賴特定格式
useDebugValue useDebugValue
在 React DevTools 中顯示自定義 Hook 的標籤,僅在開發模式下生效。
useDebugValue 基本用法 import React , { useState, useEffect, useDebugValue } from 'react' ;function useCounter (initialValue = 0 ) { const [count, setCount] = useState (initialValue); useDebugValue (count); const increment = ( ) => setCount (prev => prev + 1 ); const decrement = ( ) => setCount (prev => prev - 1 ); const reset = ( ) => setCount (initialValue); return { count, increment, decrement, reset }; } function useLocalStorage (key, initialValue ) { const [storedValue, setStoredValue] = useState (() => { try { const item = window .localStorage .getItem (key); return item ? JSON .parse (item) : initialValue; } catch (error) { console .error (`Error reading localStorage key "${key} ":` , error); return initialValue; } }); useDebugValue (storedValue, value => { return `${key} : ${JSON .stringify(value)} ` ; }); const setValue = (value ) => { try { const valueToStore = value instanceof Function ? value (storedValue) : value; setStoredValue (valueToStore); window .localStorage .setItem (key, JSON .stringify (valueToStore)); } catch (error) { console .error (`Error setting localStorage key "${key} ":` , error); } }; return [storedValue, setValue]; } function DebugExample ( ) { const { count, increment, decrement, reset } = useCounter (0 ); const [name, setName] = useLocalStorage ('userName' , '' ); return ( <div > <h3 > 調試範例</h3 > <p > 計數:{count}</p > <button onClick ={increment} > +1</button > <button onClick ={decrement} > -1</button > <button onClick ={reset} > 重設</button > <div style ={{ marginTop: '20px ' }}> <input value ={name} onChange ={(e) => setName(e.target.value)} placeholder="輸入姓名" /> <p > 儲存的姓名:{name}</p > </div > </div > ); }
useInsertionEffect useInsertionEffect
在所有 DOM 變更之前觸發,主要用於 CSS-in-JS 函式庫。
useInsertionEffect 基本用法 import React , { useInsertionEffect, useLayoutEffect, useEffect } from 'react' ;function CSSInjector ({ styles, className } ) { useInsertionEffect (() => { const style = document .createElement ('style' ); style.textContent = styles; document .head .appendChild (style); console .log ('useInsertionEffect: 樣式已插入' ); return () => { document .head .removeChild (style); console .log ('useInsertionEffect: 樣式已移除' ); }; }, [styles]); useLayoutEffect (() => { console .log ('useLayoutEffect: DOM 已更新,但尚未繪製' ); }); useEffect (() => { console .log ('useEffect: DOM 已繪製完成' ); }); return ( <div className ={className} > 檢查 Console 看看執行順序 </div > ); } function useDynamicStyles ( ) { const [styleId] = React .useState (() => `style-${Math .random().toString(36 )} ` ); useInsertionEffect (() => { const existingStyle = document .getElementById (styleId); if (!existingStyle) { const style = document .createElement ('style' ); style.id = styleId; document .head .appendChild (style); } return () => { const style = document .getElementById (styleId); if (style) { document .head .removeChild (style); } }; }, [styleId]); const addCSS = (css ) => { const style = document .getElementById (styleId); if (style) { style.textContent = css; } }; return { addCSS, styleId }; } function StyledComponent ( ) { const { addCSS } = useDynamicStyles (); const [color, setColor] = React .useState ('blue' ); React .useEffect (() => { const css = ` .dynamic-style { color: ${color} ; font-weight: bold; padding: 10px; border: 2px solid ${color} ; border-radius: 5px; } ` ; addCSS (css); }, [color, addCSS]); return ( <div > <div className ="dynamic-style" > 這個元素的樣式是動態注入的! </div > <select value ={color} onChange ={(e) => setColor(e.target.value)}> <option value ="blue" > 藍色</option > <option value ="red" > 紅色</option > <option value ="green" > 綠色</option > </select > </div > ); }
useLayoutEffect useLayoutEffect
在所有 DOM 變更後但在瀏覽器繪製前同步執行,用於需要讀取 DOM 佈局的操作。
useLayoutEffect 基本用法 import React , { useState, useLayoutEffect, useRef } from 'react' ;function MeasureComponent ( ) { const [height, setHeight] = useState (0 ); const divRef = useRef (); useLayoutEffect (() => { if (divRef.current ) { const { height } = divRef.current .getBoundingClientRect (); setHeight (height); } }); return ( <div > <div ref ={divRef} style ={{ padding: '20px ', border: '1px solid #ccc ', fontSize: '18px ' }} > 這個 div 的高度是:{height}px </div > </div > ); } function Tooltip ({ children, content, visible } ) { const [position, setPosition] = useState ({ top : 0 , left : 0 }); const tooltipRef = useRef (); const targetRef = useRef (); useLayoutEffect (() => { if (visible && tooltipRef.current && targetRef.current ) { const targetRect = targetRef.current .getBoundingClientRect (); const tooltipRect = tooltipRef.current .getBoundingClientRect (); let top = targetRect.bottom + 5 ; let left = targetRect.left + (targetRect.width - tooltipRect.width ) / 2 ; if (left < 0 ) left = 5 ; if (left + tooltipRect.width > window .innerWidth ) { left = window .innerWidth - tooltipRect.width - 5 ; } if (top + tooltipRect.height > window .innerHeight ) { top = targetRect.top - tooltipRect.height - 5 ; } setPosition ({ top, left }); } }, [visible]); return ( <div style ={{ position: 'relative ', display: 'inline-block ' }}> <div ref ={targetRef} > {children} </div > {visible && ( <div ref ={tooltipRef} style ={{ position: 'fixed ', top: position.top , left: position.left , backgroundColor: 'black ', color: 'white ', padding: '8px 12px ', borderRadius: '4px ', fontSize: '14px ', zIndex: 1000 , pointerEvents: 'none ' }} > {content} </div > )} </div > ); } function TooltipDemo ( ) { const [showTooltip, setShowTooltip] = useState (false ); return ( <div style ={{ padding: '50px ', textAlign: 'center ' }}> <Tooltip content ="這是一個智能定位的工具提示!" visible ={showTooltip} > <button onMouseEnter ={() => setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} style={{ padding: '12px 24px', fontSize: '16px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }} > 懸停顯示工具提示 </button > </Tooltip > </div > ); }
效能考量:
useLayoutEffect
會阻塞瀏覽器繪製,影響效能
只在真正需要同步讀取 DOM 時使用
優先考慮 useEffect
,除非確實需要同步執行
避免在 useLayoutEffect
中進行昂貴的計算
總結與最佳實踐 經過本文的詳細介紹,我們已經完整了解了 React 19 官方推薦的所有 Hooks。以下是使用這些 Hooks 的總結和最佳實踐:
graph TD
A["選擇合適的 Hook"]
B["基礎需求"]
C["效能優化"]
D["複雜狀態"]
E["特殊用途"]
B --> B1["useState<br/>useEffect<br/>useContext<br/>useRef"]
C --> C1["useCallback<br/>useMemo<br/>useDeferredValue<br/>useTransition"]
D --> D1["useReducer<br/>useImperativeHandle<br/>useSyncExternalStore"]
E --> E1["useId<br/>useDebugValue<br/>useInsertionEffect<br/>useLayoutEffect"]
A --> B
A --> C
A --> D
A --> E
F["React 19 新增"] --> F1["useActionState<br/>useOptimistic"]
A --> F
style B1 fill:#e8f5e8
style C1 fill:#e1f5fe
style D1 fill:#fff3e0
style E1 fill:#f3e5f5
style F1 fill:#ffebee
選擇指南 基礎開發:
狀態管理:useState
副作用處理:useEffect
跨元件通信:useContext
DOM 操作或變數保存:useRef
效能優化:
函式記憶化:useCallback
值記憶化:useMemo
非緊急更新延遲:useDeferredValue
過渡狀態:useTransition
複雜場景:
複雜狀態邏輯:useReducer
父子元件互動:useImperativeHandle
外部資料同步:useSyncExternalStore
現代開發(React 19):
表單處理:useActionState
樂觀更新:useOptimistic
結合 Server Components 獲得最佳體驗
開發建議
由簡入繁 :先用基礎 Hooks 實現功能,再考慮優化
測量優化 :使用 React DevTools Profiler 測量效能瓶頸
適度使用 :不要過度優化,記憶化也有成本
保持更新 :關注 React 官方文件和最新最佳實踐
遵循 Hooks 規則 :確保 Hooks 在元件頂層調用,避免條件調用
透過掌握這些 Hooks 和規則,您將能夠構建高效、現代的 React 應用程式!
跟著做: 試著在您的專案中逐步導入這些 Hooks,從基礎 Hooks 開始,再根據需求添加優化和進階功能。記住,最好的學習方法就是實際應用!