原文: How to manage state in a React app with just Context and Hooks
做者:Samuel Omole
译者:博轩
为保证文章的可读性,本文采用意译
自从 React Hooks 发布以来,数以千计关于它的文章,库和视频课程已经被发布。若是本身搜索下这些资源,您会发现我前段时间写的一篇文章,是关于如何使用 Hooks 构建示例应用程序。您能够在 这里找到它。
基于该文章,不少人(其实是两个)提出了有关如何仅使用 Context 和 Hooks 在 React 应用程序中管理 State 的问题,这让我对这个问题产生了一些研究。javascript
所以,对于本文,咱们将使用一种模式来管理状态,该模式使用两个很是重要的 Hooks ( useContext
和 useReducer
) 来构建简单的音乐画廊应用。该应用程序只有两个视图:一个用于登陆,另外一个用于列出该画廊中的歌曲。css
采用登陆页面做为示例的主要缘由,是当咱们想在组件之间共享登陆(Auth)状态的时候,一般会采用 Redux 来实现。html
等到完成的时候,咱们应该会拥有一个以下图所示的应用程序:java
对于后端服务,我设置了一个简单的 Express 应用程序,并将其托管于 Heroku 上。它有两个主要的接口:react
/login
- 用于认证。成功登陆后,它将返回 JWT 令牌和用户详细信息。/songs
- 返回歌曲列表。若是您想添加其余功能,能够在此处找到后端应用程序的储存库。git
在构建应用以前,让咱们先来看下接下来要使用的 Hooks:github
useState
- 该 Hook 容许咱们在函数组件中使用状态(至关于 this.state
与 this.setState
在类组件中的做用)useContext
- 该 Hook 接受一个上下文( Context )对象,并在 MyContext.Provider
中返回任何传入 value
属性的值。若是您还不了解上下文,那么这是一种将状态从父组件传递到组件树中任何其余组件的方法(不论组件的深度如何),而没必要经过不须要该状态的其余组件进行传递(这个问题也叫作「prop drilling」)。您能够在此处阅读有关上下文( Context )的更多信息。useReducer
- 这是 useState
的替代方法, 可用于复杂的状态逻辑。这是我最喜欢的 Hook ,由于它使用起来就像 Redux 。 它能够接收一个相似下面这样的 reducer
函数:(state, action) => newState复制代码
这个函数在返回新状态以前会接收一个初始状态。sql
首先,咱们可使用 create-react-app 脚手架来开始构建这个项目。在此以前,须要准备一些东西:typescript
在您的终端,输入:express
npx create-react-app hooked复制代码
或者,在全局安装 create-react-app
npm install -g create-react-app
create-react-app hooked复制代码
您将在本文结束时,建立5个组件:
import React from "react";
export const Header = () => {
return (
<nav id="navigation"> <h1 href="#" className="logo"> HOOKED </h1> </nav>
);
};
export default Header;复制代码
import React from "react";
export const Home = () => {
return (
<div className="home"> </div>
);
};
export default Home;复制代码
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
文件应该以下所示:
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 中的动做是:
若是为执行任何操做,将返回初始状态。
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
组件将处理从服务器获取的歌曲并显示他们。因为后端须要请求的时候带上身份信息,所以咱们须要找一种方法,把存贮在 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 地址来查看代码,也能够在此基础上添加一些功能。
我也写了栗子: git地址 | 在线预览 地址