React 學習筆記(4) - Context, Reducers
===

###### tags: `React`, `React Context`, `React Reducers`
---
## Prop Drilling
- 該網頁有一些 props 給其他元件使用,例如 user 的部分,我們希望建立虛擬頭像+ user name,此時如果只是 `App` 下一層的 `Avatar`,可以透過 prop 直接傳入。
- 但隨著專案功能越來越多,我們想要加入 `comments` 的功能,被讓每一個留言都能顯示虛擬頭像跟 user name,但 React 的元件如同樹一樣,如果我們想要將 user 的資料傳到 `comments`,則必須一層一層傳遞。

---
## 解決 prop drilling 的方法:Context, contextProvider
- [useContext 官方資料](https://zh-hant.reactjs.org/docs/hooks-reference.html#usecontext)
- 透過 Context 建立一個全域的物件 / 值,讓其他元件可以使用

- 或者就某一層級,透過 Context 建立全域物件 / 值,這裡的話,只能黃線匡起來的部分可以引用

- 透過 context 建立的全域物件 / 值,直接讓 `Avatar` 引用,就不需要一層層傳遞下去

- 建立好 Context 之後,使用 contextProvider 傳遞數值

---
## 使用 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`

- 以下註冊成功後,AuthContext 的更新狀態

>參考資料:
>[說明文件:透過信箱密碼建立帳戶](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 重覆的錯誤訊息

- 密碼少於 6 個字的錯誤訊息

---
### 建立登入功能
- 建立一個 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>
);
}
```
- 以下為錯誤的密碼顯示訊息

- 以下為錯誤的信箱顯示訊息

---
### 建立登出功能
- 建立一個 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`

- 進入 firestore 之後,要先設定 rule,這裡限制使用者需要登入才能進行讀寫動作


- 設定完後按 `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>←</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`(物件)

- 要如何獲取即時資料?需要加一個 `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`,就會取得文件內的細部資料

- 基本上只要資料有所變動就會進行快拍