The original:
How to manage state in a React app with just Context and Hooks
By Samuel Omole
Translator: Bo Xuan
In order to ensure the readability of the article, free translation is adopted
Thousands of articles, libraries, and video lessons have been published about React Hooks since its release. If you search these resources yourself, you’ll find an article I wrote some time ago on how to build a sample application using Hooks. You can be in
hereFind it.
Based on that article, many people (actually two) raised questions about how to manage State in React applications using only Context and Hooks, which led me to do some research on this issue.
So, for this article, we will use a pattern to manage state that uses two very important Hooks (useContext and useReducer) to build a simple music gallery application. The application has only two views: one for logging in and one for listing the songs in the gallery.
The main reason for using the login page as an example is that Redux is often used when we want to share login (Auth) state between components.
By the time we’re done, we should have an application that looks like this:
For the back-end service, I set up a simple Express application and hosted it on Heroku. It has two main interfaces:
/login
– Used for authentication. After a successful login, it returns the JWT token and user details./songs
– Returns to the list of songs.
If you want to add additional functionality, you can find the back-end application repository here.
An overview of the
Before building the application, let’s look at the Hooks to use next:
useState
– This Hook allows us to use state in function components (equivalent tothis.state
与this.setState
Role in class components)useContext
– This Hook takes a Context object and runs theMyContext.Provider
Returns any incoming invalue
Property value. If you don’t already know the context, this is a way to pass state from a parent component to any other component in the component tree, regardless of the depth of the component, without passing it through other components that don’t need the state (a problem also known as “prop drilling”). You can be inhereRead more about Context.useReducer
– this isuseState
Can be used for complex state logic. This is my favorite Hook because it works just like Redux. It can receive one like the followingreducer
Function:
(state, action) => newStateCopy the code
This function receives an initial state before returning a new state.
An introduction to
First, we can use the create-React-app scaffolding to start building the project. To do this, you need to prepare a few things:
- The Node (6 or higher)
- A cool text editor
At your terminal, type:
npx create-react-app hookedCopy the code
Alternatively, install create-react-app globally
npm install -g create-react-app
create-react-app hookedCopy the code
You will have created five components by the end of this article:
- Header.js – This component will contain the top navigation of the application and display a logout button containing the user name. This button is displayed only if the user is authenticated.
- App.js – This is the top-level component in which we will create the authentication context (which I’ll discuss later). This component displays the Login component if the user is not logged in, and the Home component if authenticated.
- Home.js – This component gets the list of songs from the server and renders them on the page.
- Login.js – This component will contain the user’s Login form. It will also be responsible for making POST requests to the login endpoint and updating the authentication context based on the server’s response.
- Card.js – This is a rendering component (UI) that renders the details of the song passed to it.
Header.js
import React from "react";
export const Header = (a)= > {
return (
<nav id="navigation">
<h1 href="#" className="logo">
HOOKED
</h1>
</nav>
);
};
export default Header;Copy the code
Home.js
import React from "react";
export const Home = (a)= > {
return (
<div className="home">
</div>
);
};
export default Home;Copy the code
Login.js
import React from "react";
import logo from ".. /logo.svg";
import { AuthContext } from ".. /App";
export const Login = (a)= > {
return (
<div className="login-container">
<div className="card">
<div className="container">
</div>
</div>
</div>
);
};
export default Login;Copy the code
App.js
To start, the app.js file should look like this:
import React from "react";
import "./App.css";
function App() {
return (
<div className="App"></div>
);
}
export default App;Copy the code
Next, we will create an Auth context that passes the Auth state from this component to any other component that needs it. The code is as follows:
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;Copy the code
We then add a useReducer hook to handle our authentication status and conditionally display the Login component and Home component.
Remember that the useReducer has two parameters, a reducer function (which is a function that takes states and actions as parameters and returns new states based on the actions) and an initial state, which is also passed to the Reducer function. The code is as follows:
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:
returnstate; }};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;Copy the code
There’s a lot going on in the code snippet above, so let me explain each part:
const initialState = {
isAuthenticated: false,
user: null,
token: null};Copy the code
The above fragment is the initial state of our object and will be used in the Reducer function. The values in this object depend largely on your usage scenario. In our example, we need to check whether the user is logged in and whether the information returned by the server contains user and token data.
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:
returnstate; }};Copy the code
The Reducer function contains a switch-case statement that returns a new state based on some specified action. The actions in reducer are:
- LOGIN – When this action is performed, some data (including user and token) is also passed. It saves the user and token to localStorage, then returns the new state (setting isAuthenticated to true) and assigns values to the User and token properties.
- LOGOUT – When this action is executed, we will clear all data of localStorage and set user and token to NULL.
If any operation is performed, the initial state is returned.
const [state, dispatch] = React.useReducer(reducer, initialState);Copy the code
UseReducer returns two arguments, state and Dispatch. State contains the state used in the component and is updated based on the action performed. Dispatch is a function used in an application to perform an action to modify the state.
<AuthContext.Provider
value={{
state.dispatch
}}
>
<Header />
<div className="App">{! state.isAuthenticated ?<Login /> : <Home />}</div>
</AuthContext.Provider>Copy the code
In the context. Provider component, we are passing an object into a Value Prop. This object contains the state and Dispatch functions, so it can be used by any other component that needs this context. We then conditionally render the component-the Home component if the user is authenticated, and the Login component otherwise.
Log on to the component
First, add some essential components of the form:
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;Copy the code
In the code above, we added JSX to display the form, and next, we’ll add useState Hook to handle the form state. After adding hooks, our code looks like this:
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;Copy the code
In the above code, we pass an initialState object to the useState Hook. In this object, we deal with E-mail, password state, a state to check if data is being sent to the server, and error values returned by the server.
Next, we’ll add a function that handles the back-end API submission form. In this function, we will use the FETCH API to send data to the back end. If the request is successful, we execute LOGIN and pass along the data returned by the server. If the server returns an error (incorrect login information), we will call setData and pass the errorMessage from the server, which will appear on the form. To execute the Dispatch function, we need to import the AuthContext from the App component into the Login component, and then the Dispatch function can be used. The code is as follows:
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;Copy the code
Home components
The Home component will process the songs fetched from the server and display them. Since the backend needs to carry identity information with it, we need to find a way to pull out identity information stored in the App component.
Let’s start building this component. We need to get the song data and map it to the list, then use the Card component to render each song. The Card component is a simple function component that passes props to the Render function and renders. The code is as follows:
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;Copy the code
Because it doesn’t handle any custom logic and just shows what’s in props, we call it a demo component.
Back in our Home component, we typically visualize three states when most applications are processing network requests. First, when the request is processed (show loading), when the request is successful (show the page with a success message), and finally when the request fails (show error notification). To make the request when the component is loaded and handle all three states simultaneously, we will use useEffect and useReducer hooks.
First let’s create an initial state:
const initialState = {
songs: [],
isFetching: false,
hasError: false,
};Copy the code
Songs will retain the list of songs retrieved from the server, starting with a null value. IsFetching is used to indicate loading status. The initial value is false. HasError is used to indicate error status. The initial value is false.
Now we can create a Reducer for this component, combined with the Home component, as follows:
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:
returnstate; }};export const Home = (a)= > {
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;Copy the code
The Reducer function defines three states of the view, which are set according to the states: Loading, request failure, and request success.
Next, we need to add useEffect to handle the network request and invoke the appropriate ACTION. The code is as follows:
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:
returnstate; }};export const Home = (a)= > {
const { state: authState } = React.useContext(AuthContext);
const [state, dispatch] = React.useReducer(reducer, initialState);
React.useEffect((a)= > {
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;Copy the code
If you noticed that in the code above, we used another hook, useContext. The reason is that in order to get the song from the server, we also have to pass the token provided to us on the login page. However, we store the token in another component, so we need to use useContext to retrieve the token from the AuthContext.
Inside the useEffect function, we first execute FETCH_SONGS_REQUEST to show the state in load, then use fetchAPI to make a network request and pass the token in the header. If the response is successful, we execute the FETCH_SONGS_SUCCESS action and pass the list of songs retrieved from the server to the action. If there is an error on the server, we execute the FETCH_SONGS_FAILURE action to make the error range appear on the screen.
The last thing to notice with useEffect Hook is that we pass tokens in the dependency array of the hooks. This means that we only call the hook when the token changes, and only trigger it when the token expires and we need to acquire a new token or log in as a new user. Therefore, the hook is called only once for this user.
Okay, so we’ve done all the logic.
This article is a bit long, but it does cover common use cases for using hooks to manage state in an application.
You can visit the Github address to see the code, or you can add functionality from there.
I also wrote chestnut:
Git address |
The online preview
address