原文:How to manage state in a React app with just Context and Hooks
作者:Samuel Omole
译者:博轩
为保证文章的可读性,本文采用意译
自从 React Hooks 发布以来,数以千计关于它的文章,库和视频课程已经被发布。如果自己搜索下这些资源,您会发现我前段时间写的一篇文章,是关于如何使用 Hooks 构建示例应用程序。您可以在这里找到它。
基于该文章,很多人(实际上是两个)提出了有关如何仅使用 Context 和 Hooks 在 React 应用程序中管理 State 的问题,这让我对这个问题产生了一些研究。
因此,对于本文,我们将使用一种模式来管理状态,该模式使用两个非常重要的 Hooks(useContext
和 useReducer
)来构建简单的音乐画廊应用。该应用程序只有两个视图:一个用于登录,另一个用于列出该画廊中的歌曲。
采用登录页面作为示例的主要原因,是当我们想在组件之间共享登录(Auth)状态的时候,通常会采用 Redux 来实现。
等到完成的时候,我们应该会拥有一个如下图所示的应用程序:
对于后端服务,我设置了一个简单的 Express 应用程序,并将其托管于 Heroku 上。它有两个主要的接口:
-
/login
– 用于认证。成功登录后,它将返回 JWT 令牌和用户详细信息。 -
/songs
– 返回歌曲列表。
如果您想添加其他功能,可以在此处找到后端应用程序的储存库。
概述
在构建应用之前,让我们先来看下接下来要使用的 Hooks:
-
useState
– 该 Hook 允许我们在函数组件中使用状态(相当于this.state
与this.setState
在类组件中的作用) -
useContext
– 该 Hook 接受一个上下文(Context)对象,并在MyContext.Provider
中返回任何传入value
属性的值。如果您还不了解上下文,那么这是一种将状态从父组件传递到组件树中任何其他组件的方法(不论组件的深度如何),而不必通过不需要该状态的其他组件进行传递(这个问题也叫做「prop drilling」)。您可以在此处阅读有关上下文 (Context) 的更多信息。 -
useReducer
– 这是useState
的替代方法,可用于复杂的状态逻辑。这是我最喜欢的 Hook,因为它使用起来就像 Redux。它可以接收一个类似下面这样的reducer
函数:
(state, action) => newState
这个函数在返回新状态之前会接收一个初始状态。
入门
首先,我们可以使用 create-react-app 脚手架来开始构建这个项目。在此之前,需要准备一些东西:
- Node (≥ 6)
- 一个酷炫的文本编辑器
在您的终端,输入:
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
会返回两个参数,state
和 dispatch
。state
包含组件中使用的状态,并根据执行的动作进行更新。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
组件中,当大多数应用程序在处理网络请求时,我们通常通过三个状态来实现可视化。首先,在处理请求时(展示加载中),请求成功时(展示页面,并提示成功),最后请求失败时(展示错误通知)。为了在加载组件时发出请求并同时处理这三种状态,我们将使用 useEffect
和 useReducer
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
保存于另外一个组件,所以需要使用 useContext
从 AuthContext
中取出 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 地址来查看代码,也可以在此基础上添加一些功能。