---
# System prepended metadata

title: 'React 學習筆記(4) - Context, Reducers'
tags: [React Reducers, React Context, React]

---

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)

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

