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