# [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> ) } ``` ![](https://hackmd.io/_uploads/rkWvyfXPh.png) ## `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。 ![](https://hackmd.io/_uploads/BJ7x5GXDn.png) ## `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>; }); ``` ![CleanShot 2023-11-12 at 22.49.23](https://hackmd.io/_uploads/HJnZLDRQT.gif) (即便已經使用 `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> ); }; ``` ![CleanShot 2023-11-12 at 22.57.35](https://hackmd.io/_uploads/S1jxODRXp.gif) (當使用 `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>; }; ``` ![CleanShot 2023-11-12 at 23.52.16](https://hackmd.io/_uploads/SJk0EO0ma.gif) (會發現 `<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)