【譯】僅使用 Context 和 Hooks 來管理 React 應用的狀態

原文: How to manage state in a React app with just Context and Hooks
做者:Samuel Omole
譯者:博軒
爲保證文章的可讀性,本文采用意譯
自從 React Hooks 發佈以來,數以千計關於它的文章,庫和視頻課程已經被髮布。若是本身搜索下這些資源,您會發現我前段時間寫的一篇文章,是關於如何使用 Hooks 構建示例應用程序。您能夠在 這裏找到它。

基於該文章,不少人(其實是兩個)提出了有關如何僅使用 Context 和 Hooks 在 React 應用程序中管理 State 的問題,這讓我對這個問題產生了一些研究。javascript

所以,對於本文,咱們將使用一種模式來管理狀態,該模式使用兩個很是重要的 Hooks ( useContextuseReducer ) 來構建簡單的音樂畫廊應用。該應用程序只有兩個視圖:一個用於登陸,另外一個用於列出該畫廊中的歌曲。css

採用登陸頁面做爲示例的主要緣由,是當咱們想在組件之間共享登陸(Auth)狀態的時候,一般會採用 Redux 來實現。html

等到完成的時候,咱們應該會擁有一個以下圖所示的應用程序:java

對於後端服務,我設置了一個簡單的 Express 應用程序,並將其託管於 Heroku 上。它有兩個主要的接口:react

  • /login - 用於認證。成功登陸後,它將返回 JWT 令牌和用戶詳細信息。
  • /songs - 返回歌曲列表。

若是您想添加其餘功能,能夠在此處找到後端應用程序的儲存庫。git

概述

在構建應用以前,讓咱們先來看下接下來要使用的 Hooks:github

  • useState - 該 Hook 容許咱們在函數組件中使用狀態(至關於 this.statethis.setState 在類組件中的做用)
  • useContext - 該 Hook 接受一個上下文( Context )對象,並在 MyContext.Provider 中返回任何傳入 value 屬性的值。若是您還不瞭解上下文,那麼這是一種將狀態從父組件傳遞到組件樹中任何其餘組件的方法(不論組件的深度如何),而沒必要經過不須要該狀態的其餘組件進行傳遞(這個問題也叫作「prop drilling」)。您能夠在此處閱讀有關上下文( Context )的更多信息。
  • useReducer - 這是 useState 的替代方法, 可用於複雜的狀態邏輯。這是我最喜歡的 Hook ,由於它使用起來就像 Redux 。 它能夠接收一個相似下面這樣的 reducer 函數:
(state, action) => newState複製代碼

這個函數在返回新狀態以前會接收一個初始狀態。sql

入門

首先,咱們可使用 create-react-app 腳手架來開始構建這個項目。在此以前,須要準備一些東西:typescript

  • Node (≥ 6)
  • 一個酷炫的文本編輯器

在您的終端,輸入:express

npx create-react-app hooked複製代碼

或者,在全局安裝 create-react-app

npm install -g create-react-app
create-react-app hooked複製代碼

您將在本文結束時,建立5個組件:

  • Header.js —該組件將包含應用程序的頂部導航,並顯示一個包含用戶名的註銷按鈕。僅當用戶經過身份驗證時,該按鈕纔會顯示。
  • App.js —這是頂級組件,咱們將在其中建立身份驗證上下文(我將在後面討論)。若是用戶未登陸,則此組件會展現「登陸」組件,若是已經過身份驗證,則展現「主頁」組件。
  • Home.js —該組件將從服務器獲取歌曲列表並將其呈如今頁面上。
  • Login.js —該組件將包含用戶的登陸表單。它還將負責向登陸端點發出 POST 請求,並根據服務器的響應來更新身份驗證的上下文。
  • Card.js —這是一個呈現組件(UI),用於呈現傳遞到其中的歌曲的詳細信息。

Header.js

import React from "react";
export const Header = () => {
  return (
    <nav id="navigation"> <h1 href="#" className="logo"> HOOKED </h1> </nav>
  );
};
export default Header;複製代碼

Home.js

import React from "react";
export const Home = () => {
return (
    <div className="home"> </div>
  );
};
export default Home;複製代碼

Login.js

import React from "react";
import logo from "../logo.svg";
import { AuthContext } from "../App";
export const Login = () => {
return (
    <div className="login-container"> <div className="card"> <div className="container"> </div> </div> </div>
  );
};
export default Login;複製代碼

App.js

最開始的時候,App.js 文件應該以下所示:

import React from "react";
import "./App.css";
function App() {
return (
      <div className="App"></div>
  );
}
export default App;複製代碼

接下來,咱們將建立 Auth 上下文,該上下文將 auth 狀態從該組件傳遞到須要它的任何其餘組件。代碼以下:

import React from "react";
import "./App.css";
import Login from "./components/Login";
import Home from "./components/Home";
import Header from "./components/Header";
export const AuthContext = React.createContext(); // added this
function App() {
return (
    <AuthContext.Provider> <div className="App"></div> </AuthContext.Provider> ); } export default App;複製代碼

而後,咱們添加 useReducer hook 來處理咱們的身份驗證狀態,並有條件的展現 Login 組件和 Home 組件。

請記住,useReducer 具備兩個參數,一個 reducer 函數 (這是一個將狀態和操做做爲參數並根據操做返回新狀態的函數) 和一個初始狀態,該狀態也會傳遞給 reducer 函數。代碼以下:

import React from "react";
import "./App.css";
import Login from "./components/Login";
import Home from "./components/Home";
import Header from "./components/Header";
export const AuthContext = React.createContext();
const initialState = {
  isAuthenticated: false,
  user: null,
  token: null,
};
const reducer = (state, action) => {
  switch (action.type) {
    case "LOGIN":
      localStorage.setItem("user", JSON.stringify(action.payload.user));
      localStorage.setItem("token", JSON.stringify(action.payload.token));
      return {
        ...state,
        isAuthenticated: true,
        user: action.payload.user,
        token: action.payload.token
      };
    case "LOGOUT":
      localStorage.clear();
      return {
        ...state,
        isAuthenticated: false,
        user: null
      };
    default:
      return state;
  }
};
function App() {
  const [state, dispatch] = React.useReducer(reducer, initialState);
return (
    <AuthContext.Provider value={{ state, dispatch }} > <Header /> <div className="App">{!state.isAuthenticated ? <Login /> : <Home />}</div> </AuthContext.Provider> ); } export default App;複製代碼

上面的代碼片斷中發生了不少事情,讓我來解釋每一部分:

const initialState = {
  isAuthenticated: false,
  user: null,
  token: null,
};複製代碼

上面的片斷是咱們對象的初始狀態,將在 reducer 函數中使用。該對象中的值主要取決於您的使用場景。在咱們的示例中,須要檢查用戶是否登陸,登陸以後,服務器返回的信息是否包含 user 以及 token 數據。

const reducer = (state, action) => {
  switch (action.type) {
    case "LOGIN":
      localStorage.setItem("user", JSON.stringify(action.payload.user));
      localStorage.setItem("token", JSON.stringify(action.payload.token));
      return {
        ...state,
        isAuthenticated: true,
        user: action.payload.user,
        token: action.payload.token
      };
    case "LOGOUT":
      localStorage.clear();
      return {
        ...state,
        isAuthenticated: false,
        user: null,
        token: null,
      };
    default:
      return state;
  }
};複製代碼

reducer 函數包含一個 switch-case 語句,該函數將根據某些設定的動做來返回新的狀態。reducer 中的動做是:

  • LOGIN - 當執行這類動做時,還將傳遞一些數據(包含 user 和 token )。它將 user 和 token 保存到 localStorage ,而後返回新狀態(設置 isAuthenticated 爲 true),併爲 user 和 token 屬性賦值。
  • LOGOUT - 當這個動做被執行,咱們會清空 localStorage 的全部數據,並將 user 和 token 置爲 null。

若是爲執行任何操做,將返回初始狀態。

const [state, dispatch] = React.useReducer(reducer, initialState);複製代碼

useReducer 會返回兩個參數, statedispatchstate 包含組件中使用的狀態,並根據執行的動做進行更新。dispatch 是在應用程序中用於執行動做,修改狀態的函數。

<AuthContext.Provider value={{ state, dispatch }} >
      <Header />
      <div className="App">{!state.isAuthenticated ? <Login /> : <Home />}</div>
 </AuthContext.Provider>複製代碼

Context.Provider 組件中,咱們正在將一個對象傳遞到 value prop 中。該對象包含 state 和 dispatch 函數,所以能夠由須要該上下文的任何其餘組件使用。而後,咱們有條件地渲染組件–若是用戶經過身份驗證,則渲染 Home 組件,不然渲染 Login 組件。

登陸組件

首先,添加一些表單的必要組件:

import React from "react";
export const Login = () => {
return (
    <div className="login-container">
      <div className="card">
        <div className="container">
          <form>
            <h1>Login</h1>
            
            <label htmlFor="email">
              Email Address
              <input type="text" name="email" id="email" />
            </label>
            
            <label htmlFor="password">
              Password
              <input type="password" name="password" id="password" />
            </label>
            
            <button>
                "Login"
            </button>
          
          </form>
        </div>
      </div>
    </div>
  );
};
export default Login;複製代碼

在上面的代碼中,咱們添加了顯示錶單的 JSX,接下來,咱們將添加 useState Hook 來處理表單狀態。添加 Hook 後,咱們的代碼展現以下:

import React from "react";
export const Login = () => {
  const initialState = {
    email: "",
    password: "",
    isSubmitting: false,
    errorMessage: null
  };
const [data, setData] = React.useState(initialState);
const handleInputChange = event => {
    setData({
      ...data,
      [event.target.name]: event.target.value
    });
  };
return (
    <div className="login-container">
      <div className="card">
        <div className="container">
          <form>
            <h1>Login</h1>

            <label htmlFor="email">
              Email Address
              <input type="text" value={data.email} onChange={handleInputChange} name="email" id="email" />
            </label>

            <label htmlFor="password">
              Password
              <input type="password" value={data.password} onChange={handleInputChange} name="password" id="password" />
            </label>

        {data.errorMessage && (
              <span className="form-error">{data.errorMessage}</span>
            )}

            <button disabled={data.isSubmitting}>
              {data.isSubmitting ? (
                "Loading..."
              ) : (
                "Login"
              )}
            </button>
          </form>
        </div>
      </div>
    </div>
  );
};
export default Login;複製代碼

在上面的代碼中,咱們將一個 initialState 對象傳遞給了 useState Hook。在該對象中,咱們處理電子郵件、密碼的狀態,一個用於檢查是否正在向服務器發送數據的狀態,以及服務器返回的錯誤值。

接下來,咱們將添加一個函數,用於處理向後端 API 提交表單。在該函數中,咱們將使用 fetch API 將數據發送到後端。若是請求成功,咱們將執行 LOGIN 操做,並將服務器返回的數據一塊兒傳遞。若是服務器返回錯誤(登陸信息有誤),咱們將調用 setData 並傳遞來自服務器的 errorMessage ,它將顯示在表單上。爲了執行 dispatch 函數,咱們須要將 App 組件中的 AuthContext 導入到 Login 組件中,而後 dispatch 函數就可使用了。代碼以下:

import React from "react";
import { AuthContext } from "../App";
export const Login = () => {
  const { dispatch } = React.useContext(AuthContext);
  const initialState = {
    email: "",
    password: "",
    isSubmitting: false,
    errorMessage: null
  };
const [data, setData] = React.useState(initialState);
const handleInputChange = event => {
    setData({
      ...data,
      [event.target.name]: event.target.value
    });
  };
const handleFormSubmit = event => {
    event.preventDefault();
    setData({
      ...data,
      isSubmitting: true,
      errorMessage: null
    });
    fetch("https://hookedbe.herokuapp.com/api/login", {
      method: "post",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        username: data.email,
        password: data.password
      })
    })
      .then(res => {
        if (res.ok) {
          return res.json();
        }
        throw res;
      })
      .then(resJson => {
        dispatch({
            type: "LOGIN",
            payload: resJson
        })
      })
      .catch(error => {
        setData({
          ...data,
          isSubmitting: false,
          errorMessage: error.message || error.statusText
        });
      });
  };
return (
    <div className="login-container">
      <div className="card">
        <div className="container">
          <form onSubmit={handleFormSubmit}>
            <h1>Login</h1>

            <label htmlFor="email">
              Email Address
              <input
                type="text"
                value={data.email}
                onChange={handleInputChange}
                name="email"
                id="email"
              />
            </label>

            <label htmlFor="password">
              Password
              <input
                type="password"
                value={data.password}
                onChange={handleInputChange}
                name="password"
                id="password"
              />
            </label>

            {data.errorMessage && (
              <span className="form-error">{data.errorMessage}</span>
            )}

           <button disabled={data.isSubmitting}>
              {data.isSubmitting ? (
                "Loading..."
              ) : (
                "Login"
              )}
            </button>
          </form>
        </div>
      </div>
    </div>
  );
};
export default Login;複製代碼

Home 組件

Home 組件將處理從服務器獲取的歌曲並顯示他們。因爲後端須要請求的時候帶上身份信息,所以咱們須要找一種方法,把存貯在 App 組件中的身份信息取出來。

讓咱們開始構建這個組件。咱們須要獲取歌曲數據並映射到列表,而後使用 Card 組件來渲染每一首歌曲。Card 組件是一個簡單的函數組件,它會將 props 傳遞給 render 函數並渲染。代碼以下:

import React from "react";
export const Card = ({ song }) => {
    
  return (
    <div className="card"> <img src={song.albumArt} alt="" /> <div className="content"> <h2>{song.name}</h2> <span>BY: {song.artist}</span> </div> </div> ); }; export default Card;複製代碼

由於它不處理任何自定義邏輯,只是展現 props 中的內容,咱們稱它爲演示組件

回到咱們的 Home 組件中,當大多數應用程序在處理網絡請求時,咱們一般經過三個狀態來實現可視化。首先,在處理請求時(展現加載中),請求成功時(展現頁面,並提示成功),最後請求失敗時(展現錯誤通知)。爲了在加載組件時發出請求並同時處理這三種狀態,咱們將使用 useEffectuseReducer Hook。

首先咱們來建立一個初始狀態:

const initialState = {
  songs: [],
  isFetching: false,
  hasError: false,
};複製代碼

songs 將保留從服務器檢索到的歌曲列表,初始值爲空。isFetching 用於表示加載狀態,初始值爲 false。hasError 用於表示錯誤狀態,初始值爲 false。

如今,咱們能夠爲此組件建立 reducer,並結合 Home 組件,代碼以下:

import React from "react";
import { AuthContext } from "../App";
import Card from "./Card";
const initialState = {
  songs: [],
  isFetching: false,
  hasError: false,
};
const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH_SONGS_REQUEST":
      return {
        ...state,
        isFetching: true,
        hasError: false
      };
    case "FETCH_SONGS_SUCCESS":
      return {
        ...state,
        isFetching: false,
        songs: action.payload
      };
    case "FETCH_SONGS_FAILURE":
      return {
        ...state,
        hasError: true,
        isFetching: false
      };
    default:
      return state;
  }
};
export const Home = () => {
  const [state, dispatch] = React.useReducer(reducer, initialState);
return (
    <div className="home"> {state.isFetching ? ( <span className="loader">LOADING...</span> ) : state.hasError ? ( <span className="error">AN ERROR HAS OCCURED</span> ) : ( <> {state.songs.length > 0 && state.songs.map(song => ( <Card key={song.id.toString()} song={song} /> ))} </> )} </div> ); }; export default Home;複製代碼

reducer 函數中定義了視圖的三種狀態,而視圖中也根據狀態設置了:加載中,請求失敗,請求成功三種狀態。

接下來,咱們須要添加 useEffect 來處理網絡請求,並調用相應的 ACTION。代碼以下:

import React from "react";
import { AuthContext } from "../App";
import Card from "./Card";
const initialState = {
  songs: [],
  isFetching: false,
  hasError: false,
};
const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH_SONGS_REQUEST":
      return {
        ...state,
        isFetching: true,
        hasError: false
      };
    case "FETCH_SONGS_SUCCESS":
      return {
        ...state,
        isFetching: false,
        songs: action.payload
      };
    case "FETCH_SONGS_FAILURE":
      return {
        ...state,
        hasError: true,
        isFetching: false
      };
    default:
      return state;
  }
};
export const Home = () => {
  const { state: authState } = React.useContext(AuthContext);
  const [state, dispatch] = React.useReducer(reducer, initialState);
React.useEffect(() => {
    dispatch({
      type: "FETCH_SONGS_REQUEST"
    });
    fetch("https://hookedbe.herokuapp.com/api/songs", {
      headers: {
        Authorization: `Bearer ${authState.token}`
      }
    })
      .then(res => {
        if (res.ok) {
          return res.json();
        } else {
          throw res;
        }
      })
      .then(resJson => {
        console.log(resJson);
        dispatch({
          type: "FETCH_SONGS_SUCCESS",
          payload: resJson
        });
      })
      .catch(error => {
        console.log(error);
        dispatch({
          type: "FETCH_SONGS_FAILURE"
        });
      });
  }, [authState.token]);

  return (
    <React.Fragment>
    <div className="home">
      {state.isFetching ? (
        <span className="loader">LOADING...</span>
      ) : state.hasError ? (
        <span className="error">AN ERROR HAS OCCURED</span>
      ) : (
        <>
          {state.songs.length > 0 &&
            state.songs.map(song => (
              <Card key={song.id.toString()} song={song} />
            ))}
        </>
      )}
    </div>
    </React.Fragment>
  );
};
export default Home;複製代碼

若是您注意到,在上面的代碼中,咱們使用了另外一個 hook,useContext。緣由是,爲了從服務器獲取歌曲,咱們還必須傳遞在登陸頁面上提供給咱們的 token。可是,咱們將 token 保存於另一個組件,因此須要使用 useContextAuthContext 中取出 token

在 useEffect 函數內部,咱們首先執行 FETCH_SONGS_REQUEST 以便顯示加載中的狀態,而後使用 fetchAPI 發出網絡請求,並將 token 放到 header 中傳遞。若是響應成功,咱們將執行該 FETCH_SONGS_SUCCESS 動做,並將從服務器獲取的歌曲列表做傳遞給該動做。若是服務器出現錯誤,咱們將執行 FETCH_SONGS_FAILURE 動做,以使錯誤範圍顯示在屏幕上。

使用 useEffect hook 要注意的最後一件事,咱們在 hook 的依賴項數組中傳遞 token。這意味着咱們只會在令牌更改時調用該 hook,只有在 token 過時且咱們須要獲取一個新 token 或以新用戶身份登陸時,纔會觸發該 hook。所以,對於此用戶,該 hook 僅被調用一次。

好的,咱們已經完成全部邏輯。

本文有點長,可是它確實涵蓋了使用 hook 來管理應用程序中的狀態的常見用例。

你能夠訪問github 地址來查看代碼,也能夠在此基礎上添加一些功能。

我也寫了栗子: git地址 | 在線預覽 地址

相關文章
相關標籤/搜索