# [React] `useContext` 解決 props drilling 的問題
###### tags: `React` `前端筆記` `Udemy 課程筆記`
## 什麼是 props drilling?
> drill (v.) / (n.) 鑽
React 最大的特色就是單向資料流,所以資料流只會是往下,不是往下。所以當元件層數比較複雜時,會發現 `state` 有時候會往下傳遞很多。
以下方的程式碼為例,因為透過點擊個別 todo 刪除,所以必須橫跨兩層傳遞 pointer function:
```javascript!
const App = () => {
// 因為單向流,找到 children 共同的 parent 將 state 設在這一層
const [todos, setTodos] = useState([
{ name: 'learn react', id: 1 },
{ name: 'workup', id: 2 },
]);
const handleRemoveTodoById = (id) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
};
return (
<div>
<Todos todosList={todos} onRemoveTodo={handleRemoveTodoById} />
</div>
)
}
const TodoItem = ({ name, id, onRemoveTodo }) => {
return (
// 橫跨 <Todos /> 取得刪除 todo 的 pointer function
<li onClick={(id) => onRemoveTodo(id)}>{name}</li>
)
}
const Todos = ({ todoList, onRemoveTood }) => {
return (
<ul>
// 因為要點擊個別的 li 刪除 todo,因此必須額外再傳遞一層
{todoList.map((todo) => {
return (
<TodoItem
key={todo.id}
name={todo.name}
onRemoveTood={onRemoveTood}
id={todo.id}
/>)
})}
</ul>
)
}
```

## `useContext` 解決了 props drilling 的問題
> 建立 `Context` + `Provider` -> `Provider` 包覆想要讀取其 state 的 children,打破只能透過 `props` 傳遞的限制。
以上的範例只有傳遞兩層,所以其實還好。但是當層數繼續往上增加的時候就會非常痛苦,所以可以使用 `useContext` 解決 props 過多傳遞的問題:
### 該如何使用 `useContext`?
#### STEP 1:建立 `Context`
課程中建議是建立另一個資料夾統一管理 context:
```shell!
src/
components/ -> 放 components
store/ -> 放 app-wide state redux
contexts/ -> 放 contexts
```
```javascript!
import { createContext } from 'react'
// 透過 createContext 建立 Context
const TodoContext = createContext({
todo: [],
onAddTodo: (newTodo) => {},
onDeleteTodo: (id) => {},
})
```
#### STEP 2:建立 `Provider`
- 將原本寫在 App 元件內的 state 拉出來,建立 Context 及 `Provider` 元件讓其 children 可以直接拿 state,不是只透過 props 接收
- 拉出來的 Provider 不會有 template
- `Provider` 必須帶 `value`,讓其 children 可以取得設定的值
```javascript!
import { createContext, useMemo } from 'react'
const TodoContext = createContext({
todo: [],
onAddTodo: (newTodo) => {},
onDeleteTodo: (id) => {},
})
// 建立 Provider component
export const TodosContextProvider = ({ children }) => {
const [todos, setTodos] = useState([]);
const handleAddTodo = (newTodo) => {
setTodos((prev) => [newTodo, ...prev]);
};
const handleRemoveTodo = (id) => {
setTodos((prev) => {
return prev.filter((todo) => todo.id !== id);
});
};
// 用 useMemo 比對 todos 的 refs 再決定是否要重新建立
const contextValue = useMemo(() => {
return {
todos,
onAddTodo: handleAddTodo,
onDeleteTodo: handleRemoveTodo,
};
}, [todos]);
return (
<TodosContext.Provider value={contextValue}>
{children}
</TodosContext.Provider>
);
}
export default TodoContext
```
#### STEP 3:children 透過 `useContext` 取得 `Provider` 中的 state,而不用 `props`
- `Provider` 要包覆在使用其 state 的 children 之外
- children 要拿 state 時必須透過 `useContext(Context)` 傳入定義好的 `Context`,就可以取得 `Context` 內的 state
- 當 `Provider` 重新 re-render 時,有使用到其 state 的 children 都會重新 re-render
```javascript!
import { useContext } from 'react'
// 記得用 Provider 包覆住其 children
// 記得把 TodosContext, 放入 useContext 內
import { TodosContextPr
const App = () => {
return (
<div>
<TodosContextProvider>
<Todos/>
</TodosContextProvider>
</div>
)
}
// 不同多傳 pointer function,直接從 Provider 拿 pointer function
const TodoItem = ({ name, id }) => {
const todosContext = useContext(TodosContext);
const { onDeleteTodo } = todosContext;
return (
// 橫跨 <Todos /> 取得刪除 todo 的 pointer function
<li onClick={(id) => onDeleteTodo(id)}>{name}</li>
)
}
// 不用 Props 傳遞資料,直接用 useContext 讀取 Provider 內的 state
const Todos = () => {
const todosContext = useContext(TodosContext);
const { todos } = todosContext
return (
<ul>
{todos.map((todo) => {
return (
<TodoItem
key={todo.id}
name={todo.name}
id={todo.id}
/>)
})}
</ul>
)
}
```
可以發現透過 `useContext` 的協助,開發者不用從 `<App />` -> `<Todos />` -> `<Todo />` 傳遞 state 及其 pointer function。

## `useContext` 可以取代 redux 管理 app-wide state 嗎?
不太建議,`useContext` 發明出來主要是解決 `props drilling` 的問題,redux 則是專門處理 app-wide state management 的,所以兩者要處理的問題是不太一樣的,因此也不是誰要取代誰。
另一方面來看 `useContext` 本身也不像 `redux` 具備狀態管理機制(`store` -> `subscribe -> dispatch(action)` -> `reducer` ),所以在確認 state 是必須橫跨許多元件的情況下,還是將該 state 放在 redux 統一用單個 `store` 管理才是比較合適的。
## `Provider` 除了用 `useState` 建立 state,當然也可以用 `useReducer` 建立
建立 `Context` 及 `Provider`:
```typescript!
// context
import React, { createContext, useMemo, useReducer } from "react";
export interface Todo {
name: string;
id: number;
}
/* 定義 useReducer 的 Action */
type RemoveTodo = {
type: "REMOVE";
id: number;
};
type AddTodo = {
type: "ADD";
todo: Todo;
};
// type alias with union type
type TodoReducerAction = RemoveTodo | AddTodo;
interface TodosWithUseReducerContextInterface {
todos: Todo[];
onRemoveTodo: (id: number) => void;
onAddTodo: (newTodo: Todo) => void;
}
interface Props {
children: React.ReactNode;
}
const initialTods: Todo[] = [];
/* createContext<T> 丟入型別至泛型 T 中,藉此定義 Context 的型別 */
/* 注意:<型別>(建立 Context) 不要搞混 */
const TodosWithUseReducerContext = createContext<
TodosWithUseReducerContextInterface
>({
todos: [],
onRemoveTodo: (id: number) => {},
onAddTodo: (newTodo: Todo) => {}
});
/* 建立 reducer */
const todosReducer = (state: Todo[], action: TodoReducerAction) => {
/* 因為有定義 Action 的型別,所以 . 的出來 */
switch (action.type) {
case "REMOVE":
return state.filter((todo) => todo.id !== action.id);
case "ADD":
return [action.todo, ...state];
default:
return state;
}
};
export const TodosWithUseReducerContextProvider = ({ children }: Props) => {
console.log("context render");
const [todos, dispatchTodosFunc] = useReducer(todosReducer, initialTods);
const handleAddTodo = (newTodo: Todo) => {
dispatchTodosFunc({ type: "ADD", todo: newTodo });
};
const handleRemoveTodo = (id: number) => {
dispatchTodosFunc({ type: "REMOVE", id });
};
// const contextValue: TodosWithUseReducerContextInterface = {
// todos,
// onAddTodo: handleAddTodo,
// onRemoveTodo: handleRemoveTodo
// };
const contextValue = useMemo(() => {
return {
todos,
onAddTodo: handleAddTodo,
onRemoveTodo: handleRemoveTodo
};
}, [todos]);
return (
<TodosWithUseReducerContext.Provider value={contextValue}>
{children}
</TodosWithUseReducerContext.Provider>
);
};
export default TodosWithUseReducerContext;
```
於共同 parent 引入 `Provider`(所以其 children 都有該 `Provider` 的讀取範疇)
```typescript!
// App
import Todos from "./components/Todos";
import AddTodo from "./components/AddTodo";
import { TodosWithUseReducerContextProvider } from "./contexts/todos-context-with-use-reducer";
export default function App() {
console.log("App render");
return (
<div className="App">
// 讓其 children 可以藉由 useContext 讀取 Provider state
<TodosWithUseReducerContextProvider>
<Todos />
<AddTodo />
</TodosWithUseReducerContextProvider>
</div>
);
}
```
個別 children 透過 `useContext` 取得 `Provider`:
```typescript!
// AddTodo
import React, { useContext, useRef } from "react";
import TodosWithUseReducerContext from "../contexts/todos-context-with-use-reducer";
const AddTodo = () => {
// 有引用到 context 的元件都會在 provider state 更新後 re-render
console.log("add todo");
const inputRef = useRef<HTMLInputElement>(null);
// 透過 useContext 取得 method
const todosCxt = useContext(TodosWithUseReducerContext);
const handleSumbit = (event: React.FormEvent) => {
event.preventDefault();
todosCxt.onAddTodo({ name: inputRef.current!.value, id: Date.now() });
inputRef.current!.value = "";
};
return (
<form onSubmit={handleSumbit}>
<label>Todo:</label>
<input ref={inputRef} />
<button type="submit">Submit</button>
</form>
);
};
export default AddTodo;
```
```typescript!
// Todos
import { useContext } from "react";
import TodosWithUseReducerContext from "../contexts/todos-context-with-use-reducer";
import TodoItem from "./TodoItem";
const Todos = () => {
// 直接拿 Provider state
const todosCxt = useContext(TodosWithUseReducerContext);
return (
<ul>
{todosCxt.todos.map((todo) => {
return <TodoItem key={todo.id} name={todo.name} id={todo.id} />;
})}
</ul>
);
};
export default Todos;
```
```typescript!
// TodoItem
import { useContext } from "react";
import TodosWithUseReducerContext from "../contexts/todos-context-with-use-reducer";
interface Props {
name: string;
id: number;
}
const TodoItem = ({ name, id }: Props) => {
// 拿 Provider method
const todosCxt = useContext(TodosWithUseReducerContext);
const handleRemoveTodo = () => {
todosCxt.onRemoveTodo(id);
};
return <li onClick={handleRemoveTodo}>{name}</li>;
};
export default TodoItem;
```
[完整程式範例](https://codesandbox.io/s/usecontext-with-typescript-and-usereducer-x2hm2c?file=/src/App.tsx)
## 注意!我們是丟物件給 `value` 的,所以每一次元件重新被叫用都會建立獨立的範疇 = 新的 value
因為 `value` 是丟入物件,所以每次 `Provider` 的上層元件重新叫用時,`Provider` 也會被叫用,所以又會重新建立一個 scope(==所以 React 就會覺得 `value` 是新的,進而導致使用該 `context` 的元件也同步被重新被叫用==)
```typescript=
// CurrentUserContext.tsx
type CurrentUserCxt = {
userName: string;
setCurrentUser: Dispatch<SetStateAction<string>>;
};
export const CurrentUserContext = createContext<CurrentUserCxt | null>(null);
type CurrentUserProviderProps = {
children: ReactNode;
};
const CurrentUserProvider = ({ children }: CurrentUserProviderProps) => {
console.log("context provider render");
const [currentUser, setCurrentUser] = useState("");
// 因為上層 App 重新 re-render,所以這個 Provider 也會重新叫用
const contextValue = {
userName: currentUser,
setCurrentUser
};
return (
<CurrentUserContext.Provider value={contextValue}>
{children}
</CurrentUserContext.Provider>
);
};
export const useCurrentUser = () => {
const ctx = useContext(CurrentUserContext);
if (!ctx) {
throw new Error("useCurrentUser 必須在 Provider 之下才可使用");
}
return ctx;
};
export default CurrentUserProvider;
```
```typescript=
// 結構
default function App() {
console.log("[app render]");
const [counter, setCounter] = useState(0);
return (
<div className="App">
<button onClick={() => setCounter((prev) => prev + 1)}>Add</button>
<p>Current counter: {counter}</p>
<CurrentUserProvider>
<UserWithoutCutsomHook />
</CurrentUserProvider>
</div>
);
}
/* 用 memo 阻擋因上層 App re-render 而導致 UserInfo 也重新 re-render */
const UserWithoutCutsomHook = memo(() => {
console.log("userWithoutCustomHook re-render");
const cxt = useContext(CurrentUserContext);
return <h1>Current Username: {cxt?.userName}</h1>;
});
```

(即便已經使用 `React.memo`,但是因為有使用到 `CurrentUserContext`,當 `<App />` 元件重新被叫用時,導致子層 `<CurrentUserProvider />` 也重新被叫用,進而導致 `value` 重新被建立。所以當 React 使用 `Object.is` 比對時就會發現兩個物件的參考不同,所以會判定 state 更新,造成 `<UserWithoutCutsomHook />` 也重新被叫用)
### 使用 `useMemo` 及 `useCallback`,讓 React 不要因為範疇的問題傳遞新的 `value`
可以使用 `useMemo` 藉由此 hook 的 dependency 讓 React 知道什麼時候直接回傳舊的範疇,不需傳遞新的範疇:
```typescript=
const CurrentUserProvider = ({ children }: CurrentUserProviderProps) => {
console.log("context provider render");
const [currentUser, setCurrentUser] = useState("");
// 只有 currentUser 更新時才需要傳遞新的 value
const contextValue = useMemo(() => {
console.log("call");
return {
userName: currentUser,
setCurrentUser
};
}, [currentUser]);
return (
<CurrentUserContext.Provider value={contextValue}>
{children}
</CurrentUserContext.Provider>
);
};
```

(當使用 `useMemo` memoized `value` 後,即便 `<CurrentUserProvider />` 因上層 `<App />` 重新被叫用而一同被叫用,但是不會重新建立新的 `value`,所以 `<UserWithoutCutsomHook />` 並不會重新被叫用)
### 即便我只在元件中拿取其中一個屬性,元件會像使用 Redux 的 `useSelector` 會幫忙額外比對不同嗎?
:::danger
不會,只要更新 `context`,其底下有取用相同 `context` 的元件都會重新被叫用,即便只拿一個不相干(未被更新的屬性)也是一樣。
:::
> When an action is dispatched, `useSelector()` will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render.
> *[ref. React Redux - Hooks](https://react-redux.js.org/api/hooks)*
當使用 Redux 時,在元件中使用 `useSelector` 取得 `stored state` 時,`useSelector` 會額外比對當前 selector 與上一次 selector 回傳的值有無不同,有的話才會觸發元件重新叫用。所以若只更新其中的一個屬性時,其餘沒必要更新的 selector 也不會讓元件重新被叫用。
可以想像成這樣子的操作:
```javascript=
// 原來的 stored state
{
age: 1,
name: ''
}
// 若使用者透過 dispatch 呼叫 action -> reducer 再透過 action 呼叫對應的 case 更新 stored state 後
{
aga: 2, // 透過 mutable 更新 age 屬性的值
name: ''
}
// 只取用 name 的元件並不會重新被叫用,因為 useSelector 會額外比對取出的屬性
const SomeComponent = () => {
const { name } = useSelector((state) => state.someStoredState)
}
```
當使用 `useContext` 時,只要 `context` 被更新後,有使用其 `context` 的子層元件就會重新被叫用。對元件來說,只要 `context` 更新了,就會被重新叫用,沒有 `useSelector` 那種還會額外比對被取用值的機制。
```typescript=
const CurrentUserProvider = ({ children }: CurrentUserProviderProps) => {
console.log("context provider render");
const [currentUser, setCurrentUser] = useState("");
const [age, setAge] = useState(0);
// 額外建立 age 的 state
const contextValue = useMemo(() => {
return {
userName: currentUser,
setCurrentUser,
age,
setAge
};
}, [currentUser, age]);
return (
<CurrentUserContext.Provider value={contextValue}>
{children}
</CurrentUserContext.Provider>
);
};
```
```typescript=
function App() {
console.log("[app render]");
const [counter, setCounter] = useState(0);
return (
<div className="App">
<button onClick={() => setCounter((prev) => prev + 1)}>Add</button>
<p>Current counter: {counter}</p>
<CurrentUserProvider>
<User />
<UpdateUserAage />
</CurrentUserProvider>
</div>
);
}
const UpdateUserAage = memo(() => {
const { age, setAge } = useCurrentUser();
return (
<div>
<button onClick={() => setAge((prev) => prev + 1)}>Add age</button>
<p>{age}</p>
</div>
);
});
const User = () => {
console.log("user re-render");
const { userName } = useCurrentUser();
return <h1>Current Username: {userName}</h1>;
};
```

(會發現 `<User />` 元件中即便只拿取 `userName`,當我透過 `<UpdateUserAge />` 更新 `age` 時,`contextValue` 會重新建立一次 `value`,所以對 `<User />` 來說 `context` 更新了,所以我也要重新被叫用,因為 `useContext` 只會比對上一次跟這一次的 `context`,不會額外比對被取用的屬性)
## 可以搭配 custom hook 額外先封裝 `context`,之後在元件只引入其 custom hook 即可
*[ref. Always Use a Custom Hook for Context API, Not useContext (React Context API, TypeScript)](https://www.youtube.com/watch?v=I7dwJxGuGYQ)*
```typescript=
import { useContext } from 'react'
import { CurrentUserContext } from '...'
export const useCurrentUser = () => {
const ctx = useContext(CurrentUserContext);
if (!ctx) {
throw new Error("useCurrentUser 必須在 Provider 之下才可使用");
}
return ctx;
};
export default CurrentUserProvider;
// 這樣子在元件內只需要引入 useCurrentUser 即可
const SomeComponent = () => {
const { userName } = useCurrentUser()
}
```
## 可以更優雅地把 `state` 及 `updater / dispatch` 拆成兩個 `contexts`
- 定義一個 wrapper component,裡面可以引用不同的 `Providers` 讓包袱的子層元件可以不用透過 `props` 取得 `state`
- 同一個模組的 `state` 可以分成數個 `Providers` 及 `Contexts`,可以讓程式碼職責區分的更乾淨
```javascript=
// ref. [Scaling Up with Reducer and Context](https://react.dev/learn/scaling-up-with-reducer-and-context)
import { createContext, useContext, useReducer } from 'react';
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
/* wrapper Provider */
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
return (
/* 只處理 state getter */
<TasksContext.Provider value={tasks}>
/* 只處理 state setter */
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}
/* 再用 custom hook 封裝,讓元件引用時更乾淨 */
export function useTasks() {
return useContext(TasksContext);
}
export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, {
id: action.id,
text: action.text,
done: false
}];
}
case 'changed': {
return tasks.map(t => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
const initialTasks = [
{ id: 0, text: 'Philosopher’s Path', done: true },
{ id: 1, text: 'Visit the temple', done: false },
{ id: 2, text: 'Drink matcha', done: false }
];
```
當然,一般的 `useState` 的 `[getter, setter]` 也可以拆成兩個 `context` 處理:
```typescript=
type CurrentUserCxt = {
userName: string;
age: number;
};
type CurrentUserCxtSetter = {
setCurrentUser: Dispatch<SetStateAction<string>>;
setAge: Dispatch<SetStateAction<number>>;
};
export const CurrentUserContext = createContext<CurrentUserCxt | null>(null);
export const CurrentUserContextSetter = createContext<CurrentUserCxtSetter | null>(
null
);
type CurrentUserProviderProps = {
children: ReactNode;
};
const CurrentUserProvider = ({ children }: CurrentUserProviderProps) => {
console.log("context provider render");
const [currentUser, setCurrentUser] = useState("");
const [age, setAge] = useState(0);
const contextValue = useMemo(() => {
console.log("call");
return {
userName: currentUser,
age
};
}, [currentUser, age]);
const contextSetterValue = useMemo(() => {
return {
setCurrentUser,
setAge
};
}, []);
return (
// 只處理 getter 的 context
<CurrentUserContext.Provider value={contextValue}>
// 只處理 setter 的 context
<CurrentUserContextSetter.Provider value={contextSetterValue}>
{children}
</CurrentUserContextSetter.Provider>
</CurrentUserContext.Provider>
);
};
export const useCurrentUser = () => {
const ctx = useContext(CurrentUserContext);
if (!ctx) {
throw new Error("useCurrentUser 必須在 Provider 之下才可使用");
}
return ctx;
};
export const useCurrentUserSetter = () => {
const ctx = useContext(CurrentUserContextSetter);
if (!ctx) {
throw new Error(
"useCuseCurrentUserSetterurrentUser 必須在 Provider 之下才可使用"
);
}
return ctx;
};
export default CurrentUserProvider;
```
[完整程式範例](https://codesandbox.io/s/note-usecontext-with-custom-hook-and-optimize-with-usememo-v5sxk7)
## Recap
1. `useContext` 解決了 prop drilling 的問題(因為 React 的資料流是單向移動的,只有往下,沒有往上)
2. 把原本共同 parent 中存放的 state 額外拉出來(`Context` + `Provider`)取消 `props` 的讀取(`useContext`)
3. `useContext` 的使用方式(建立 `Context` + `Provider` -> 找到共同 parent 引入 `Provider` -> 其 children 可以透過 `useContext` 取得 `Provider` 的 state)
4. 並不是出現用來完全取代 redux,而是各司其職(redux: app-wide state, `useContext`: 垂直線的群組)
5. 當 `Provider` state 更新時,只要有使用到 `Provider` state 的元件都會重新 re-render
6. 定義 `Provider` 時務必放 `props: { value: {...} }`,因為 `value` 是 children 會拿到的資料
7. 可以參照需求,拆分不同的 `contexts`(如:`useState` 的 `[getter, setter]`
8. 只要 `context` 更新了,其有使用相同的 `context` 的元件就會更新,==不像 `useSelector` 還有額外比對取用 `stored state` 的機制==
9. 可以搭配 custom hook 封裝,讓元件可以更乾淨地引入模組
10. 記得是在 `<Provider />` 子層才可以讀取 `context`,同一層是無法讀取的
## 參考資料
1. [redux vs useContext, useReducer,該怎麼選擇?](https://blog.typeart.cc/redux-vs-use-congtext-use-reducer-and-which-one/)
2. [React Context, Provider and useContext](https://pjchender.dev/react/react-context-provider-api/#usecontext--usereducer)
3. [useContext](https://react.dev/reference/react/useContext#my-component-doesnt-see-the-value-from-my-provider)