linkedin Skip to Main Content
Categories

An Introduction to Global State Management in React without a Library

Development

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 and useReducer)
  • 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.