An Introduction to Global State Management in React without a Library
Scalable React projects tend to contain code bases with numerous components making up the primary application. Good data management and communication between these individual components is pivotal for cleaner code, and improved data sharing between components.
Global state management was created as a means to centralize and manage data in an application, making it easily mutable and available to all components in the application. Finding and implementing a suitable means to handle global state management is an integral part of an application.
In this article, we will give you a deep dive on the terms state and state management in React, and discuss the best choices for managing global state in a React application. We will also cover different methods of managing global state in a React application without the use of any 3rd party library or packages.
What is state and global state in React?
In JavaScript applications, state refers to all data the application needs in order to operate. State can be stored in any data type, including arrays, booleans, strings, or numbers.
Within React apps, global state is a JavaScript object that stores data used by React to render components with dynamic content based on user action. Applications can include this global state for both functional and class components.
State can be transferred across components in React apps in different ways depending on the state management technique used.
What is state management?
State management is a method of controlling communication between different parts of state and their usage, including React components.
Except for extremely simple applications, components need to communicate with each other. For example, in an e-commerce application, a cart component needs to communicate to the site header component to show how many items are currently in the cart.
Why state management is crucial
Components are used in the development of React apps, and these components often manage state on their own because a state object can be included directly in React components.
When present, the component’s state is considered “encapsulated data” and contains the properties that are persistent across component renderings. This is effective for applications with few components, but as the application grows, it becomes difficult to control the complexity of shared state between components.
When state management is implemented:
- Data is consolidated, making it easier to reason with your app’s logic.
- The location of your data is always known.
- You can get a point-in-time snapshot of all the data stored globally.
- Your development will proceed more quickly as a result of this better developer experience.
Use cases where global state management plays a pivotal role
Global state management is useful in virtually every type of app, but some examples of where it may be particularly critical include:
- Ecommerce applications: In a standard ecommerce application, there are usually many components that communicate with each other and multiple actions that users can take which causes the data to change. For example, a wish list for users, where items can be added and saved by a user, to be shopped on later in the future.
- Educational applications: Global state management is useful in educational applications that feature a user management platform, where its visitors can log in, sign in, enroll in a course, etc. All these are made possible by state management.
- Applications with authentication: Without state management, it would be very hard to build applications where users need to be authenticated. This is due to the fact that most user information would be required by multiple components in the application for security and in order to present the user with the corresponding data stored in the application database. This is made possible with the use of state management, which tells if the user is authenticated and provides information about the user.
- Personalized applications & websites: In order to provide users with a personalized experience, some user data has to be saved in the application. Without state management, this would be difficult. This is because state management takes in the user’s preferences and actions, and uses this to determine what kind of content the user receives, ex: YouTube and Pinterest.
Methods of managing state in React
There are multiple ways we can manage global state in React applications. Let’s take a look at the basic approaches to managing state:
- React Hooks (e.g.
useState
anduseReducer
) - React’s Context API
- State Management Libraries (e.g. Redux and Recoil)
Each state management technique is used to build React applications of different scales, ranging from small-scale to large-scale applications.
For example, relying on React Hooks like useState
and useReducer
for global state, although encouraged by the React Docs, only really works well for small and simple React applications.
As the number of features and the size of the application increases, we begin to increasingly rely on “prop drilling” to pass state between components and it becomes hard to manage the application state with only hooks.
This is where the Context API or state management libraries can come in.
But we’re getting ahead of ourselves; let’s start by taking a look at React’s useState
and useReducer
hooks.
React Hooks
According to the React Docs, “Hooks are functions that let you ‘hook into’ React state and lifecycle features from function components.”
The useState
hook, for example, is useful for setting and updating a React component’s state and the useReducer
hook lets you manage the local state of complex components with a reducer.
Let’s dive in and see how we can use these hooks to manage the application state in our React application.
useState hook
With useState
you can set a value of state within a component.
const [state, setState] = useState(initialState);
Code language: JavaScript (javascript)
useState
is a function that takes in the initial state (initialState
in the code above) as an argument.
React will preserve this state between re-renders. useState
returns two things that we can obtain by destructuring: the current state value and a function that lets you update it.
You can call this function setState
function in order to update the state
to a new value.
Consider this example:
import { useState } from "react";
import "./styles.css";
export default function App() {
const [count, setCount] = useState(0)
return (
<div className="App">
<h1> State management 101 </h1>
<button onClick={() => setCount(count + 1)}>Clicked me {count} times</button>
</div>
);
}
Code language: JavaScript (javascript)
In the simple code example above you can see how the state is updated when we call the setCount
function by clicking on the button.
Now, let’s use a bit more practical of an example. We’ll take a look at hooks in this Books component – ./src/Books.js
:
// ./src/Books.js
import React, { useState } from "react";
// array of book objects
const books = [
{
title: "Harry Potter and the Deathly Hallows",
price: "5.00",
rating: "5.0"
},
{
title: "Harry Potter and the Goblet of Fire",
price: "5.00",
rating: "4.8"
}
];
export function Books() {
// create state variables with initial values
const [savedBooks, setSavedBooks] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
return (
<div>
<header>
<p> No. of Books: {savedBooks.length} </p>
<p> Total price: ${totalPrice} </p>
</header>
<ul className="Books">
{books.map((book) => {
return (
<li className="book" key={book.name}>
<header>
<h3> {book.title} </h3>
<p>Rating: {book.rating} </p>
<p> ${book.price} </p>
</header>
<button> Add </button>
<button> Remove </button>
</li>
);
})}
</ul>
</div>
);
}
Code language: JavaScript (javascript)
Then in ./src/App.js:
//./src/App.js
import { useState } from "react";
import { Books } from "./Books";
export default function App() {
return (
<div className="App">
<Books />
</div>
);
}
Code language: JavaScript (javascript)
In the Books
component, we imported the useState
Hook from React. Then we initialized two states of data: savedBooks
and totalPrice
.
With hooks, we can have multiple and independent state objects by just calling the useState
function and providing an initial value for the state.
Let’s see how we can update the value of the state:
To do that, we’ll create a function that will update our state with some data. First, we’ll create a function that will add a book from the books
array to our savedBooks
array and set the price.
// ./src/Books.js
import React, { useState } from "react";
// array of book objects
const books = [
{
id: "001",
title: "Harry Potter and the Deathly Hallows",
price: "5.00",
rating: "5.0"
},
{
id: "002",
title: "Harry Potter and the Goblet of Fire",
price: "5.00",
rating: "4.8"
}
];
export function Books() {
// create state variables with initial values
const [savedBooks, setSavedBooks] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
// functions to set the state
const add = (id) => {
// set state for savedBooks
setSavedBooks([books[0]]);
// set state for totalPrice
setTotalPrice(books[0].price);
};
// reset state values
const remove = () => {
// reset values
setSavedBooks([]);
setTotalPrice(0);
};
return (
<div>
<header>
<p> No. of Books: {savedBooks.length} </p>
<p> Total price: ${totalPrice} </p>
</header>
<ul className="Books">
{books.map((book) => {
return (
<li className="book" key={book.name}>
<header>
<h3> {book.title} </h3>
<p>Rating: {book.rating} </p>
<p> ${book.price} </p>
</header>
<button onClick={add}> Add </button>
<button onClick={remove}> Remove </button>
</li>
);
})}
</ul>
</div>
);
}
Code language: JavaScript (javascript)
From the code above, you can see we’ve created two functions, add()
and remove()
.
In the add()
function, we use the setSavedBooks()
function to set the value of savedBooks
to the first item in the books
array.
The add()
function also uses the setTotalPrice()
function to set the value of totalPrice
to the price of the first item in the books
array.
We fire the add()
function using the onClick
event listener on the Add button.
Once the button is clicked, savedBooks
is assigned the value of the first book in the books
list ( "Harry Potter and the Deathly Hallows"
) and totalPrice
now contains the price of that same first book ( $5.00
).
The remove()
function, when clicked, sets savedBooks
to an empty array and totalPrice
to 0
. You can try out this example of useState
here:
Now that we’ve seen how we can set up and update state data with useState
, let’s explore how the useReducer
hook may be used to update the state while using the current state since we’ve been using static, hard-coded values.
Real-world applications necessitate setting the new state based off of the old state rather than overwriting with an original issue. Let’s dive in.
useReducer hook
Unlike useState
which simply replaces the set value with a new one, e.g when we fired the add()
function, it replaced the content of savedBooks
state with the new data, we will be able to update the state based on the previous state using useReducer
. Similar to the Array Reduce method, this hook is intended to update the state based on the current state.
Let’s start by creating a savedBooksReducer
function that takes in two arguments: state
and action
.
state
is the current state,
action
is an object which contains the following two properties:
- the
book
object and - the
type
of action to be carried out on the value of the state –"add"
or"remove"
.
// ./src/Books.js
...// saved books reducer function
const savedBooksReducer = (state, action) => {
// get the book object and the type of action by destructuring
const { book, type } = action;
// if "add"
// return an array of the previous state and the book object
if (type === "add") return [...state, book];
// if "remove"
// remove the book object in the previous state
// that matches the title of the current book object
if (type === "remove") {
const bookIndex = state.findIndex((x) => x.title === book.title);
// if no match, return the previous state
if (bookIndex < 0) return state;
// avoid mutating the original state, create a copy
const stateUpdate = [...state];
// then splice it out from the array
stateUpdate.splice(bookIndex, 1);
return stateUpdate;
}
return state;
};
...
export default function Books() {
...
}
Code language: JavaScript (javascript)
The next step involves creating a function called totalPriceReducer
, which accepts the same arguments as savedBooksReducer
but only adds and subtracts the value of the previous state to and from the new state, respectively.
// ./src/Books.js
// ...
function totalPriceReducer(state, action) {
let { price, type } = action;
// price = parseFloat(price);
if (type === "add") return state + price;
// return the value when the type of action was not "add"
// subtract the new book price from the previous state
if (state - price < 0) return 0;
return state - price;
}
// ...
export default function Books() {
// ...
}
Code language: JavaScript (javascript)
Note that here, we have a condition that if (state – price) is ever less than 0 (negative), the calculation is reset to 0.
Let’s use our refactored functions in useReducer
now that we have them.
// ./src/Books.js
// ...
export function Books() {
// replace useState with useReducer and pass two arguments
// the first argument is the reducer function
// the second function is the initialState
const [savedBooks, setSavedBooks] = useReducer(savedBooksReducer, []);
const [totalPrice, setTotalPrice] = useReducer(totalPriceReducer, 0);
// functions to set the state
// to add items and price
const add = (book) => {
// set state for savedBooks
// pass an object containing the book and type of action, "add"
setSavedBooks({ book, type: "add" });
// set state for totalPrice
setTotalPrice({ price: book.price, type: "add" });
};
// reset state values
// to remove items and price
const remove = (book) => {
setSavedBooks({ book, type: "remove" });
setTotalPrice({ price: book.price, type: "remove" });
};
return (
// ...
);
}
Code language: JavaScript (javascript)
Each of the functions are returned from their respective useReducer
functions takes an object as an argument.
const [savedBooks, setSavedBooks] = useReducer(savedBooksReducer, []);
const [totalPrice, setTotalPrice] = useReducer(totalPriceReducer, 0);
Code language: JavaScript (javascript)
For setSavedBooks
, it can take, for example, the object – {book, type: "add"}
as an argument, while, setTotalPrice
can also take this object – {price: book.price, type: "add"}
as an argument.
These objects are then passed into their respective reducer functions to update the state. setSavedBooks
for example passes its object into savedBooksReducer
.
Also, having changed our add()
and remove()
functions to accept a book
argument, we can now give the book
as input to our template’s buttons onClick
listener.
export function Books() {
// ...
return (
<div>
{ // ... }
<ul className="Books">
{books.map((book) => {
return (
<li className="book" key={book.name}>
<header>
<h3> {book.title} </h3>
<p>Rating: {book.rating} </p>
<p> ${book.price} </p>
</header>
<button onClick={() => add(book)}> Add </button>
<button onClick={() => remove(book)}> Remove </button>
</li>
);
})}
</ul>
</div>
);
}
Code language: JavaScript (javascript)
Now, when we click on Add or Remove, the reducer function adds the new data to the previous state without overwriting it. Here’s an example of what that implementation of useReducer
would look like:
So far, we’ve been dealing with state contained in one component. In a real-world app, we’ll need to pass data/state between multiple components, from parent to child, child to parent, and between sibling components (components with the same parent).
In the next section, we’ll take a look at how we can manage state between components using props.
Props
“Props” is short for “properties” and they are arguments passed to React components. We use props to pass data from the parent to child component.
To illustrate this, we’ll move our books
array, savedBooksReducer
and totalPriceReducer
functions to our ./src/App.js
file.
// ./src/App.js
import { Books } from "./Books";
// array of book objects
const books = [
// ...
];
// saved books reducer function
const savedBooksReducer = (state, action) => {
// ...
};
// total price reducer function
function totalPriceReducer(state, action) {
// ...
}
export default function App() {
return (
<div className="App">
<Books
books={books}
savedBooksReducer={savedBooksReducer}
totalPriceReducer={totalPriceReducer}
/>
</div>
);
}
Code language: JavaScript (javascript)
As you can see from the code above, in order to pass the data into the <Books>
component, we use JSX component bindings and pass the data.
To access this data from within the component we destructure the props
parameter in our Books
function definition.
// ./src/Books.js
import React, { useReducer } from "react";
export function Books({ books, savedBooksReducer, totalPriceReducer }) {
// ...
}
Code language: JavaScript (javascript)
✅ You can see these props in action in the Context API sandbox example below.
While this is great for simple applications, when the application begins to scale and data needs to be passed from children back up to parents, between sibling components, or even globally throughout the app, relying only on props might not be such a great idea.
Next, we’re going to look at the Context API which helps us share state a bit better.
Context API
Context API provides a way to move data up and down the component tree of your app without having to manually pass props through numerous component levels. You can configure a “global” state for a tree of React components using Context. Once this is done, the state is accessible from any component in the tree without passing it through intermediary components.
Let’s further restructure our application by creating a new component, SavedBooks
, which will contain the total number of saved books and the total price. The Books
component will still contain the list of books.
Before we do that, let’s set up a global state in our application with the Context API.
Initialize context
Create a new file ./src/modules/BooksContext.js
specifically for our context, and then import createContext
and use it to create our BooksContext
.
// ./src/modules/BooksContext.js
import React, {createContext} from "react"
export const BooksContext = createContext()
Code language: JavaScript (javascript)
Next, we create a context provider.
Create a context provider
A context object comes with a Provider
component that allows components wrapped within it to subscribe to context changes.
We’re going to once again move our books
array and savedBooksReducer
functions to our ./src/modules/BooksContext.js
file and initialize the state within a new BooksProvider
function using the useState
and useReducer
hook.
// ./src/modules/BooksContext.js
import React, { useContext, useReducer, useState } from "react";
// array of book objects
const bookList = [
// ...
];
// saved books reducer function
const savedBooksReducer = (state, action) => {
// ...
};
export const BooksContext = createContext();
export const BooksProvider = (props) => {
const [books, setBooks] = useState(bookList)
const [savedBooks, setSavedBooks] = useReducer(savedBooksReducer, [])
return <BooksContext.Provider> {props.children} </BooksContext.Provider>
}
Code language: JavaScript (javascript)
In the code above, you can see that we’re returning the Provider component <BooksContext.Provider
> from BooksProvider
.
props.children
will allow us to render the components to be nested in the provider.
Accessing the state
Components that are descendants of the Provider can access a value
prop that the <BooksContext.Provider>
component accepts.
All of our state must be entered into this value
prop in our context file like so:
// ./src/modules/BooksContext.js
...
return (
<BooksContext.Provider
value={{
bookList,
setBookList,
savedBooks,
setSavedBooks,
}}
>
{props.children}
</BooksContext.Provider>
);
...
Code language: JavaScript (javascript)
In order for this component to be accessible to the rest of our application, we’ll have refactor our ./src/App.js
file to use and wrap the application with the BooksProvider
component:
// ./src/App.js
import { Books } from "./Books";
import { BooksProvider } from "./modules/BooksContext";
export default function App() {
return (
<BooksProvider>
<div className="App">
<Books />
</div>
</BooksProvider>
);
}
Code language: JavaScript (javascript)
To subscribe to the state passed to the provider component in our components, we have to import BooksContext
and consume it with the useContext
hook.
Here’s how we can do that in our Books
component:
// ./src/Books.js
// import the useContext Hook
import React, { useContext } from "react";
// import the Context
import { BooksContext } from "./modules/BooksContext";
export function Books() {
const { books, setSavedBooks } = useContext(BooksContext);
// functions to set the state
const add = (book) => {
// set state for savedBooks
// pass an object containing the book and type of action, "add"
setSavedBooks({ book, type: "add" });
};
// reset state values
const remove = (book) => {
setSavedBooks({ book, type: "remove" });
};
return (
<div>
<ul className="Books">
{books.map((book) => {
return (
<li className="book" key={book.name}>
<header>
<h3> {book.title} </h3>
<p>Rating: {book.rating} </p>
<p> ${book.price} </p>
</header>
<button onClick={() => add(book)}> Add </button>
<button onClick={() => remove(book)}> Remove </button>
</li>
);
})}
</ul>
</div>
);
}
Code language: JavaScript (javascript)
Based on the code above, we’re able to access books
and setSavedBooks
state from the BooksContext
using useContext
.
Now that we have our global state set up with context, let’s create our SavedBooks
component:
// ./src/SavedBooks.js
import React, { useContext } from "react";
import { BooksContext } from "./modules/BooksContext";
export function SavedBooks() {
const { savedBooks } = useContext(BooksContext);
// function to get total price from savedbooks array
const getTotalPrice = (savedBooks) => {
const totalPrice = savedBooks.reduce(
(totalCost, item) => totalCost + item.price,
0
);
return totalPrice;
};
return (
<header>
<p> No. of Books: {savedBooks.length} </p>
<p> Total price: ${getTotalPrice(savedBooks)} </p>
</header>
);
}
Code language: JavaScript (javascript)
Finally, now that we’ve separated the Books
component and created the SavedBooks
component, let’s add it in ./src/App.js:
// ./src/App.js
import { Books } from "./Books";
import { BooksProvider } from "./modules/BooksContext";
import { SavedBooks } from "./SavedBooks";
export default function App() {
return (
<BooksProvider>
<div className="App">
<SavedBooks />
<Books />
</div>
</BooksProvider>
);
}
Code language: JavaScript (javascript)
Now, you can see that we’re able to pass the data between sibling components using the Context API:
Awesome!
Up next: Comparing two state management libraries
So far, we’ve covered the basics of state management in a React application using React Hooks like useState
and useReducer
.
We’ve also seen how we can take local component state management a step further by setting up a global application state using the Context API which ships with React.
With this, we can build small to medium scale applications without having to install and depend on third party state management libraries.
Do you feel ready to dive into state management with libraries? Stay tuned for my article next week comparing Redux and Recoil.
My name is Victory Tuduo and I am a software developer who loves building web applications, creating flexible UI designs, mentoring, and writing technical articles. You can find out more about me and my work here and here.