跳至主要内容

useContext

12/2/2023 發布

實作元件時經常會遇到資料需要向下傳遞的狀況,如:

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>
);
}

valueProvider 的固定 props,可以傳入純值,
或是像上面這樣把 getter 和 setter 等等包成物件傳下去。

useContext

ParentComponent 中使用 useContext 取出剛剛在 Providervalue 傳入的東西,
因為是包成物件,所以要存取時可以直接解構出來:

function ParentComponent() {
// 只取出 count
const { count } = useContext(CounterContext);

return (
<>
<div>這裡是 Parent 的 count: {count}</div>
<ChildComponent />
</>
);
}

ChildComponent 也可以使用這個 hook 取出 countsetCount
這樣就能減少 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.Providervalue

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 和資料計算可以進一步透過 useCallbackuseMemo 進行 reference 的優化,
這邊就先不深入探討。


建議情境

Providervalue 也是一種 state,因此被 Provider 包住的元件中,
只要有使用 useContext 存取 context,也會觸發機制重新渲染。

因此資料狀態經常變動的狀態,就不太適合使用 context 來管理,
而常用的情境有:

  1. 登入狀態
  2. i18n 切換
  3. 主題色切換
  4. UI 狀態切換,如 modal、側邊欄等
  5. 表單各步驟的資料暫存

參考資料