React 學習筆記(4) - Context, Reducers === ![](https://i.imgur.com/ZLirEbH.png) ###### tags: `React`, `React Context`, `React Reducers` --- ## Prop Drilling - 該網頁有一些 props 給其他元件使用,例如 user 的部分,我們希望建立虛擬頭像+ user name,此時如果只是 `App` 下一層的 `Avatar`,可以透過 prop 直接傳入。 - 但隨著專案功能越來越多,我們想要加入 `comments` 的功能,被讓每一個留言都能顯示虛擬頭像跟 user name,但 React 的元件如同樹一樣,如果我們想要將 user 的資料傳到 `comments`,則必須一層一層傳遞。 ![](https://i.imgur.com/cuwxi1v.png) --- ## 解決 prop drilling 的方法:Context, contextProvider - [useContext 官方資料](https://zh-hant.reactjs.org/docs/hooks-reference.html#usecontext) - 透過 Context 建立一個全域的物件 / 值,讓其他元件可以使用 ![](https://i.imgur.com/GYLNJtw.png) - 或者就某一層級,透過 Context 建立全域物件 / 值,這裡的話,只能黃線匡起來的部分可以引用 ![](https://i.imgur.com/0sWaBCN.png) - 透過 context 建立的全域物件 / 值,直接讓 `Avatar` 引用,就不需要一層層傳遞下去 ![](https://i.imgur.com/oZKdpsE.png) - 建立好 Context 之後,使用 contextProvider 傳遞數值 ![](https://i.imgur.com/P43uZZF.png) --- ## 使用 firebase auth, context, Reducer 建立使用者註冊及登入的功能 - 使用者註冊及登入必須要提供給其他元件使用,因此透過`createContext` 建立一個 `AuthContext` 的全域物件,再透過 `useReducer` 來做狀態管理,再透過 Context.Provider 中的 value 屬性,可以把想要的值傳入內部的每個元件,: ```javascript= // 建立一個產生 context 物件和管理狀態(useReducer)的檔案 AuthContex.js // 引入 useReducer, createContext import { useReducer, creaContext} from "react" // define initial state const initState = { user: null }; // create a global context object const AuthContext = createContext(); // create reducer func // 這個函式主要管理 dispatch 內的 type of action const reducer = (state, action) => { // create actions switch (action.type) { case ACTIONS.LOGIN: return { ...state, user: action.payload }; case ACTIONS.LOGOUT: return { ...state, user: null }; default: return state; } } export const AuthContextProvider = ({childern}) => { // useReducer for managing states const [state, dispatch] = useReducer(reducer, initState) // use value property from provider to share state and dispatch func return ( <AuthContext.Provider value={{ ...state, dispatch }}> {children} </AuthContext.Provider> ) } ``` ```javascript= //index.js import React from "react"; import { createRoot } from "react-dom/client"; window.React = React; import App from "./App"; import { AuthContextProvide } from "./context/authContext"; const container = document.getElementById("root"); const root = createRoot(container); root.render( <AuthContextProvide> <App tab="home" /> </AuthContextProvide> ); ``` ```javascript= // useAuthContext.js import { AuthContext } from "../context/authContext"; import { useContext } from "react"; export const useAuthContext = () => { // useContext 只接受 context 物件本身 const context = useContext(AuthContext); // console.log(context); if (!context) { throw Error("useAuthContext must be used inside an AuthContextProvider"); } return context; }; ``` **以下步驟釐清:** - 透過 `createContext` 建立一個全域的 `context` 物件,稱為 `AuthContext` - 由於每一個 context 都具備一個 provider 元件,該元件可以接受 props 的傳遞,所以我們要怎麼在其他元件確認使用者是登入或登出的狀態呢? - 透過設定 `value={}`,將 useReducer 管的狀態傳遞給其他元件使用 - 要怎麼傳遞呢? - 透過建立一個 context.provider 的函式 (`AuthContextProvider`),我們將 `{children}` 作為參數帶入,而這個參數就是被這組函式包覆住的元件,如上述的 <App />,這樣一來 <App /> 裡頭的元件等於也被共享了整個 context - 其他子元件該如何 access 來讓 `useReducer` 來偵測到狀態或行為呢? - 透過 `useContext` 這個 hook,我們將建立好的 `authContext` 物件傳遞進去,建立自己 authentication context hook(`useAuthContext`),透過這個 hook,我們可以取得稍早透過 `value={}` 傳遞的 `state` 和 `dispatch` >參考資料: >[React - useContext](https://reactjs.org/docs/hooks-reference.html#usecontext) >[React - usereducer](https://reactjs.org/docs/hooks-reference.html#usereducer) >[React - createContext](https://reactjs.org/docs/context.html#reactcreatecontext) >[React Context API 以及 useContext Hook 的使用](https://pjchender.blogspot.com/2019/07/react-react-context-api.html) >[後 Redux 時代!?留意那些過去常被視為理所當然的事](https://pjchender.dev/webdev/note-without-redux/#usecontext--usereducer) --- ### 建立 signup hook 並完成一次註冊 - 使用 firebase auth 前,須先在 `src` 資料夾內建立一個 `firebase` 的資料夾,內建立一個 `config.js` 的檔案,並且將 firebase 專案取得的 SDK 貼上,並引入需要的模組 ```javascript= import { initializeApp } from "firebase/app"; import { getAuth } from "firebase/auth"; import { getFirestore } from "firebase/firestore"; const firebaseConfig = { // your SDK }; // init firebase const app = initializeApp(firebaseConfig); // init firebase auth and get a reference to the service const auth = getAuth(app); const db = getFirestore(app); export { auth, db }; ``` > 參考資料: > [Authenticate with Firebase using Password-Based Accounts using Javascript ](https://firebase.google.com/docs/auth/web/start) - 透過 `useAuthContext`,我們可以取得 `state` 和偵測行為的 `dispatch`,來取得回傳資料 - 這邊我們需要把使用者輸入的信箱和密碼作為參數,透過 firebase auth 及`createUserWithEmailAndPassword()` 來建立新的使用者 - 將`displayName` 透過 `updateProfile()` 來更新使用者資訊 ```javascript= // useSignup.js import { useState } from "react"; import { useAuthContext } from "./useContext"; import { ACTIONS } from "../context/authContext"; // import auth modules import { auth } from "../firebase/config"; import { createUserWithEmailAndPassword } from "firebase/auth"; export const useSignup = () => { // need error and pending to detect state changing const [error, setError] = useState(null); const [pending, setIspending] = useState(false); // Need dispatch to detect action type in order to trigger reducer func to get data const { dispatch } = useAuthContext(); // signup func takes 3 params const signup = async (email, password, displayName) => { // Initialize the error states is nothing and also makes the loading status become true setError(null); setIspending(true); // use try catch block to make sure the error is detectable try { // get response from firebase auth const res = await createUserWithEmailAndPassword( auth, email, password ); console.log(res); // if there's no res, we throw an error if (!res) { throw new Error("Could not complete signup"); } // update user profile by sending displayname await updateProfile(auth.currentUser, { displayName: displayName, }); // fire login action dispatch({ type: ACTIONS.LOGIN, payload: res.user, }); } catch (error) { setError(error.message); console.log(error.message); // since there's an error, we stoping loading anything setIspending(false); } }; }; ``` - 將 `useSignup` hook 應用到 signup page 裡面, AuthContext 初始狀態 `user: null` ![](https://i.imgur.com/R38aG5W.png) - 以下註冊成功後,AuthContext 的更新狀態 ![](https://i.imgur.com/uwQGyik.png) >參考資料: >[說明文件:透過信箱密碼建立帳戶](https://firebase.google.com/docs/auth/web/password-auth#web-version-8) >[說明文件:createUserWithEmailAndPassword()](https://firebase.google.com/docs/reference/js/auth.md#createuserwithemailandpassword) >[firebase auth 密碼條件](https://firebase.google.com/docs/reference/android/com/google/firebase/auth/FirebaseAuthWeakPasswordException) >[更新用戶的個人資料](https://firebase.google.com/docs/auth/web/manage-users) --- ### 更新註冊時網頁的狀態 - 現在 signup 功能已經順利完成,需要到 signup 頁面在註冊期間,將 `Signup` 按鈕的狀態顯示 `Loading` ,讓使用者知道說目前在進行註冊 ```javascript= // signup.js import { useState } from "react"; import { useSignup } from "../hooks/useSignup"; export default function SignupPage() { // init email, password, displayname states const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [displayName, setDisplayName] = useState(""); const { signup, pending, error } = useSignup(); // create handleSubmit func const handleSubmit = (e) => { e.preventDefault(); signup(email, password, displayName); }; return ( <FormContainer> <form onSubmit={handleSubmit}> <h2> <span>S</span>ignup </h2> {/* DisplayName */} <label> <span>Display Name:</span> <input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} /> </label> {/* Email */} <label> <span>Email:</span> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> </label> {/* Password */} <label> <span>Password:</span> <input type="password" value={password} placeholder="At least 6 digits" onChange={(e) => setPassword(e.target.value)} /> </label> <FlexWrapper> // 如果 pending 狀態為 false,顯示 Signup btn {!pending && <button>Signup</button>} // 如果 pending 狀態為 true,顯示 Registering btn {pending && <button disabled>Registering</button>} <p> Already a member? <NavLink to="/login">Login</NavLink> here </p> </FlexWrapper> // 顯示錯誤訊息 {error && <p>{error}</p>} </form> </FormContainer> ); } ``` - email 重覆的錯誤訊息 ![](https://i.imgur.com/e0EV9aq.png) - 密碼少於 6 個字的錯誤訊息 ![](https://i.imgur.com/mzeSqLo.png) --- ### 建立登入功能 - 建立一個 custom login hook - 基本上與 signup hook 一樣,只是 login 的部分我們需要引入 firebase auth 的 `signInWithEmailAndPassword()` ```javascript= // useLogin.js import { useState } from "react"; import { useAuthContext } from "./useContext"; import { ACTIONS } from "../context/authContext"; import { auth } from "../firebase/config"; import { signInWithEmailAndPassword } from "firebase/auth"; export const useLogin = () => { // setup initial state const [error, setError] = useState(null); const [pending, setPending] = useState(false); const { dispatch } = useAuthContext(); // create login func const login = async (email, password) => { setError(null); setPending(true); try { const res = await signInWithEmailAndPassword(auth, email, password); if (!res) { throw new Error("Could not login, try after 1 min"); } dispatch({ type: ACTIONS.LOGIN, payload: res.user, }); } catch (error) { setError(error.message); console.log(error.message); setPending(false); } }; return { login, pending, error }; }; ``` - 建立好 `useLogin` hook 後,就可以在登入頁面使用了 ```javascript= // login.js import { useLogin } from "../hooks/useLogin"; import { useState } from "react"; export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const { login, pending, error } = useLogin(); const handleSubmit = (e) => { e.preventDefault(); login(email, password); }; return ( <FormContainer> <form onSubmit={handleSubmit}> <h2> <span>L</span>ogin </h2> {/* Email */} <label> <span>Email:</span> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> </label> {/* Password */} <label> <span>Password:</span> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> </label> <FlexWrapper> {!pending && <button>Login</button>} {pending && <button>Loading</button>} <p> Not a member? <NavLink to="/signup">Signup</NavLink> here </p> </FlexWrapper> {error && <p style={{ color: "red" }}>{error}</p>} </form> </FormContainer> ); } ``` - 以下為錯誤的密碼顯示訊息 ![](https://i.imgur.com/SEtlsNd.png) - 以下為錯誤的信箱顯示訊息 ![](https://i.imgur.com/cyUln2A.png) --- ### 建立登出功能 - 建立一個 logout 的 hook,讓整個網頁都可以使用 - 透過 firebase auth 的 `signOut()` ```javascript= // useLogout.js import { useState } from "react"; import { useAuthContext } from "./useContext"; import { ACTIONS } from "../context/authContext"; import { auth } from "../firebase/config"; import { signOut } from "firebase/auth"; export const useLogout = () => { const [error, setError] = useState(null); const [pending, setPending] = useState(false); const { dispatch } = useAuthContext(); const logout = async () => { setError(null); setPending(true); try { await signOut(auth); dispatch({ type: ACTIONS.LOGOUT, }); } catch (error) { setError(error.message); console.log(error.message); setPending(false); } }; return { logout, pending, error }; }; ``` - 在導覽列透過 `useLogout` 來顯示 `Logout` 按鈕並且透過 `useAuthContext` 的 `user` 來顯示登入名稱 ```javascript= // Navbar.js import React from "react"; import { NavLink } from "react-router-dom"; import { useLogout } from "../hooks/useLogout"; import { useAuthContext } from "../hooks/useContext"; import { NavWrapper } from "../styles/Nav.styled"; export default function Navbar() { const { logout } = useLogout(); const { user } = useAuthContext(); return ( <NavWrapper> <nav> <NavLink exact="true" to="/"> easyTodo </NavLink> <p>manage your daily tasks</p> {!user && ( <div> <NavLink to="/login">Login</NavLink> <NavLink to="/signup">Signup</NavLink> </div> )} {user && ( <div> <span>Hello, {user.displayName}</span> <button onClick={logout}>Logout</button> </div> )} </nav> </NavWrapper> ); } ``` --- ### protect routes 建立好 `login`, `logout` 和 `signup` 後,就可以針對路徑做調整: - 如果路徑為"/",檢查使用者是否有登入,若有,導到首頁;若無,則導到 login 頁面 ```javascript= <Route path="/" element={user ? <HomePage /> : <Navigate to="/login" replace />} /> ``` - 如果路徑為"/lists",檢查使用者是否登入,若有,導到 lists 頁面;若無,導到 login 頁面 ```javascript= <Route path="/lists" element={user ? <ListPage /> : <Navigate to="/login" replace />} /> ``` - 如果路徑為"/login",檢查使用者是否登入,若無,導到 login 頁面;若有,導到首頁 ```javascript= <Route path="/login" element={!user ? <LoginPage /> : <Navigate to="/" replace />} /> ``` - 如果路徑為"/signup",檢查使用者是否登入,若無,導到 signup 頁面;若有,導到首頁 ```javascript= <Route path="/signup" element={!user ? <SignupPage /> : <Navigate to="/" replace />} /> ``` --- ### 與 firebase 同步 - 目前即使登入後,重新打開網頁仍會回到需要登入的狀態,但實際狀況是,當使用者登入後,只要沒有按登出,瀏覽器應該都要維持登入狀態 - 透過 `useEffect()` 做一次性網頁的 render,同時確認是否有使用者登入,如果有就直接顯示首頁 - 作法: - 在 global context 內透過 `useEffect` 和 `onAuthStateChanged()` ([onauthstatechanged](https://firebase.google.com/docs/reference/js/v8/firebase.auth.Auth#onauthstatechanged))做狀態的檢查 - 在 `useReducer` initial state 的部分增加 `alreadyLogin: false` - 透過 `dispatch` 觸發 `reducer` 內的 action `checkLogin`,以及回傳的值 `user` - 在 `reducer` 函式內加入 `wait` 的 case 及須回傳的物件,在該物件內加入一個類似開關的 `alreadyLogin: true` - 將這個 `alreadyLogin` 放進 <App /> 的最上層 --- ### 使用 firestore 儲存 todos - 打開 firebase 專案,點擊 `Firestore Database` ![](https://i.imgur.com/kM8PEGJ.png) - 進入 firestore 之後,要先設定 rule,這裡限制使用者需要登入才能進行讀寫動作 ![](https://i.imgur.com/1KCysG4.png) ![](https://i.imgur.com/GIV7mRG.png) - 設定完後按 `publish` 後就可以開始使用 - 回到 vscode 內,引入 firestore 的相關模組 ```javascript= // ListPage.js // 引入 firestore database and modules // db 在之前的 .config.js 內已經設定好,可以參考[建立 signup hook] import { db } from "../firebase/config"; import { collection, query, onSnapshot, doc, deleteDoc, updateDoc, } from "firebase/firestore"; export default function ListPage() { const [todos, setTodos] = useState([]); const { user } = useAuthContext(); useEffect(() => { const q = query(collection(db, "todos")); const unsub = onSnapshot(q, (querySnapshot) => { let todosArr = []; querySnapshot.forEach((doc) => { todosArr.push({ ...doc.data(), id: doc.id }); }); setTodos(todosArr); }); return () => unsub(); }, []); const addTodo = (text) => { let id = 1; if (todos.length > 0) { id = todos[0].id + 1; } let todo = { id: id, text: text, completed: false, }; let newTodos = [todo, ...todos]; setTodos(newTodos); }; const removeTodo = async (id) => { let updateTodo = [...todos].filter((todo) => todo.id !== id); await deleteDoc(doc(db, "todos", id)); setTodos(updateTodo); }; const completedTodo = (id) => { let updateTodo = todos.map((todo) => { const toDoRef = doc(db, "todos", todo.id); updateDoc(toDoRef, { completed: true }); if (todo.id === id) { todo.completed = !todo.completed; } return todo; }); setTodos(updateTodo); }; return ( <AddTodoWrapper> <h2>{user.displayName}, add some todos</h2> <TodoForm addTodo={addTodo} /> {todos.map((todo) => { return ( <TodoItems todo={todo} key={todo.id} removeTodo={removeTodo} completedTodo={completedTodo} isCompleted={false} /> ); })} <NavLink to="/"> <span>&larr;</span> Go back </NavLink> </AddTodoWrapper> ); } ``` > collection:資料寫入的集合(資料夾) > [query](https://firebase.google.com/docs/firestore/query-data/get-data): 提供篩選 > [onSnapshop](https://firebase.google.com/docs/reference/js/v8/firebase.firestore.CollectionReference#onsnapshot): 提供即時資料監聽 > doc: 集合(資料夾)內的文件 > deleteDoc: 刪除文件 > updateDoc: 更新文件 **以下為步驟解析** - 當使用者確實登入後,進入到 todo list 頁面,頁面載入觸發 `useEffect` - `useEffect` 會與 firestore 確認資料狀態 - `const q = query(collection(db, "todos"));`,透過 `query` ,選取集合(資料夾名稱)並將結果存為變數 `q`(物件) ![](https://i.imgur.com/pyNWjDv.png) - 要如何獲取即時資料?需要加一個 `onSnapshot` 的即時資料監聽,並傳遞`q` 和 `querySnapshot` 參數 - 監聽 `query` 取得的集合(collection)內的文件(document) - `querySnapshot` 這裡指 `query` 的結果,零或多個物件,裡面包含 - 建立一個空陣列,代表在渲染後要顯示的結果 - 由於`querySnapshot` 為零或多個物件,裡面包含從 `query` 獲得的部分資料稱 `QueryDocumentSnapshot`(這邊直接用 doc),我們可以逐一 loop 並透過 [data()](https://firebase.google.com/docs/reference/node/firebase.firestore.DocumentSnapshot)取得每一個 文件(document)的細節資料以及 id ```javascript= onSnapshot(q, (querySnapshot) => { let todosArr = []; querySnapshot.forEach((doc) => { todosArr.push({ ...doc.data(), id: doc.id }); }); ``` > 為了方便自己理解,這裡以 snapshot 的原意來模擬整個過程,為了取得即時資料,我們使用 firestore 提供的 **onSnapshot** 監聽資料,這裡可以想成一個人拿著照相機(onSnapshot),他透過這個照相機,對這些資料(q)進行快拍,這個快拍之下得到的結果就是 **querySnapshot**,接著打開 **querySnapshot**,又對裡面的每一個文件進行快拍,而每一個文件叫做 **QueryDocumentSnapshot**(即 doc),他將每一份文件(doc)透過 **data()** 的工具,取得文件編號(id)。 > 使用者在介面不論新增、刪除、編輯或勾選完成,都會透過 **onSnapshot** 進行快拍 - 透過 `data()` 解析 `QueryDocumentSnapshot`,就會取得文件內的細部資料 ![](https://i.imgur.com/JVqMA1Z.png) - 基本上只要資料有所變動就會進行快拍