useContext
實作元件時經常會遇到資料需要向下傳遞的狀況,如:
function ChildComponent({ count, onIncrease, onDecrease }) { return ( <div> <div>這裡是 Child 的 count: {count}</div> <button type="button" onClick={onIncrease}> 增加 </button> <button type="button" onClick={onDecrease}> 減少 </button> </div> );}
function ParentComponent({ count, setCount }) { function handleIncrease() { setCount(count + 1); }
function handleDecrease() { setCount(count - 1); }
return ( <> <div>這裡是 Parent 的 count: {count}</div> <ChildComponent count={count} onIncrease={handleIncrease} onDecrease={handleDecrease} /> </> );}
function App() { const [count, setCount] = useState(0);
return <ParentComponent count={count} setCount={setCount} />;}目前只傳遞兩次,看起來還可以接受,
但再往下傳更多層,或是有更多封裝 setter 的地方,
就會造成過度傳遞的問題(props drilling),
寫的時候麻煩,後續維護和追蹤也很痛苦,
這時就可以考慮使用 useContext 來進行跨元件的資料管理。
用法
createContext
使用 createContext 建立一個 context,
呼叫時可以帶入初始值:
const CounterContext = createContext(null);Context Provider
context 的用法是取出子元件 Provider,在 JSX 中把需要共享這個狀態的元件都包住:
function App() { const [count, setCount] = useState(0);
return ( <CounterContext.Provider value={{ count, setCount }}> <ParentComponent /> </CounterContext.Provider> );}value 是 Provider 的固定 props,可以傳入純值,
或是像上面這樣把 getter 和 setter 等等包成物件傳下去。
useContext
在 ParentComponent 中使用 useContext 取出剛剛在 Provider 的 value 傳入的東西,
因為是包成物件,所以要存取時可以直接解構出來:
function ParentComponent() { // 只取出 count const { count } = useContext(CounterContext);
return ( <> <div>這裡是 Parent 的 count: {count}</div> <ChildComponent /> </> );}ChildComponent 也可以使用這個 hook 取出 count 和 setCount,
這樣就能減少 props 的重複定義和向下傳遞。
但這個問題也和元件的設計方式有關,是否有遵守 邏輯層/展示層 的設計方式(參考),
展示層盡可能維持在單純接收 props 傳入的資料的功能,
因此純 UI 的元件裡通常不太會去操作 useContext。
比較早期的網路教學通常也會提到用 <Context.Consumer> 去取出 context 的內容,
不過此方式已經被官方標註為不推薦。
與 useReducer 搭配
接下來會示範 useContext + useReducer 經典的練習題:登入狀態。
首先可以定義出基本的 reducer 結構:
export const AUTH_ACTION = { SET_SIGN_IN: 'SET_SIGN_IN', SET_SIGN_OUT: 'SET_SIGN_OUT', CHECK_AUTH: 'CHECK_AUTH',};
function createInitialStates() { return { userId: null, token: null, };}
function reducer(state, action) { const { type, payload } = action; const token = localStorage.getItem('token');
switch (type) { case AUTH_ACTION.SET_SIGN_IN: localStorage.setItem('token', payload?.token); return { ...payload };
case AUTH_ACTION.SET_SIGN_OUT: localStorage.removeItem('token'); return createInitialStates();
case AUTH_ACTION.CHECK_AUTH: if (token) { alert('已登入'); return state; }
alert('未登入'); return createInitialStates();
default: console.error('不存在的 action'); return state; }}Provider 元件可以再封裝,裡面可以呼叫 useReducer,把狀態和方法宣告出來,
並傳入 Provider 元件的 value:
export const AuthContext = createContext();
export function AuthProvider({ children }) { // 生成 reducer const [userInfo, dispatch] = useReducer(reducer, createInitialStates());
return ( {/* 傳入 reducer */} <AuthContext.Provider value={{ userInfo, dispatch }}> {children} </AuthContext.Provider> );}引用封裝好的 AuthProvider,裡面有定義好 children,
所以子元件一樣能用 useContext 取得 AuthContext.Provider 的 value:
function UserSignInPage() { const { userInfo, dispatch } = useContext(AuthContext);
const isSignIn = userInfo.userId && userInfo.token;
function handleSignIn() { const userInfo = { userId: 123, token: 'This is test token.', };
dispatch({ type: AUTH_ACTION.SET_SIGN_IN, payload: userInfo, }); }
function handleSignOut() { dispatch({ type: AUTH_ACTION.SET_SIGN_OUT, }); }
function handleCheckAuth() { dispatch({ type: AUTH_ACTION.CHECK_AUTH, }); }
return ( <main> {isSignIn && <div>使用者 ID: {userInfo.userId} 已登入</div>} <button type="button" onClick={handleSignIn}> 登入 </button> <button type="button" onClick={handleSignOut}> 登出 </button> <button type="button" onClick={handleCheckAuth}> 檢查登入狀態 </button> </main> );}
function App() { return ( <AuthProvider> <UserSignInPage /> </AuthProvider> );}優化
getter 和 setter 建議拆成不同的 context:
export const AuthStateContext = createContext();export const AuthActionContext = createContext();
export function AuthProvider({ children }) { const [userInfo, dispatch] = useReducer(reducer, createInitialStates());
return ( //將 userInfo 和 dispatch 分別傳入 <AuthStateContext.Provider value={{ userInfo }}> <AuthActionContext.Provider value={dispatch}>{children}</AuthActionContext.Provider> </AuthStateContext.Provider> );}使用拆分好的 context:
function UserStatusPage() { // 透過 AuthStateContext 只取出 userInfo const { userInfo } = useContext(AuthStateContext);
console.log('UserStatusPage render');
return <div>UserStatusPage {userInfo.userId}</div>;}
function UserSignInPage() { // 透過 AuthActionContext 只取出 dispatch const dispatch = useContext(AuthActionContext);
function handleSignIn() { // 略 }
function handleSignOut() { // 略 }
function handleCheckAuth() { // 略 }
console.log('UserSignInPage render');
return ( <> <button type="button" onClick={handleSignIn}> 登入 </button> <button type="button" onClick={handleSignOut}> 登出 </button> <button type="button" onClick={handleCheckAuth}> 檢查登入狀態 </button> </> );}
function App() { return ( <> <AuthProvider> <UserSignInPage /> <UserStatusPage /> </AuthProvider> </> );}這時會發現不論 UserSignInPage 中操作登入或登出,
只有 console.log('UserStatusPage render'); 會被印出,
得證 getter 和 setter 的 context 拆開後,
存取 setter 的元件不會因為 getter 的狀態變化,導致一起被重新渲染。
setter 和資料計算可以進一步透過 useCallback 和 useMemo 進行 reference 的優化,
這邊就先不深入探討。
建議情境
Provider 的 value 也是一種 state,因此被 Provider 包住的元件中,
只要有使用 useContext 存取 context,也會觸發機制重新渲染。
因此資料狀態經常變動的狀態,就不太適合使用 context 來管理,
而常用的情境有:
- 登入狀態
- i18n 切換
- 主題色切換
- UI 狀態切換,如 modal、側邊欄等
- 表單各步驟的資料暫存