React state management with Context and Reducers

Luis Canas
Version 1
Published in
7 min readSep 13, 2022

Today I want to start a series of posts about state management in React using Context.

In this first post, I want to give you an overview of state management, what is React Context and how it compares to external state managers.

Overview

I started building web apps a long time ago. In the beginning, everything was server-side: PHP, ASP.NET, etc. with little or no JavaScript in the front end. Then I had to add more and more JavaScript, but when the app got complicated and I had to handle the DOM across different browsers and browser versions… “Oh, this is a nightmare!”😱

Photo by Yogendra Singh on Unsplash

Then jQuery came along and everything changed… “OMG this is the future!” 😍 Until I had to develop a complex app…😱

Then I started with Angular.js and, again, “YEAH! This is the game-changer! The future is now!!” 😍 Until I had to develop another complex app…😱

Well, I am still in this loop, and I suspect it’s infinite. When I started with React in 2017, I had another 😍 feeling because it was a big improvement in my development experience compared to what I used before. It was designed as a JavaScript library for building UIs, using a declarative style for component rendering based on state. It is great at handling local component state. It allows you to use plain JavaScript (or TypeScript) and import any JavaScript library. It doesn’t mandate a way to handle any of the different aspects of a web app, such as global state. This aspect, state management, is one of the hardest in front-end development.

We have to manage the app state in the browser. Part of this app state is pure UI state, but the rest is usually just a cache of server state (which probably comes from a database). And we are using a stateless protocol such as HTTP to get server state. So, we have to somehow deal with another of the most difficult aspects of software development: cache invalidation.

This might explain why there are so many state management libraries for React: Redux, MobX, Apollo, React Query, SWR, Recoil, Zustand, etc.

I have used Redux in the past and I think the required boilerplate and complexities (what I call the ‘piping system’) to make it work is just too much, especially for newcomers.

Photo by Crystal Kwok on Unsplash

Once the piping is in place and Redux is working, it’s much easier. But as the app grows, you have to create more reducers, actions, dispatch calls, etc. A lot of open files in your editor’s forest of tabs. I think we should simplify this, just like we simplify global configuration and other complicated aspects by using app builders like create-react-app, NextJS, etc.

In this article I want to explain how to handle state management in a simple way without any external library, using the built-in React Context APIs and the useContext and useReducer hooks. In particular, I will show how to implement a very simple Finite State Machine (FSM) to handle a piece of the global state.

Goal

My goal is to answer the following questions:

  1. What is React Context?
  2. When to use external state managers?
  3. How to manage global state with React Context?
  4. How to create a simple Finite State Machine with Context and useReducer?

What is React Context

React Context “provides a way to pass data through the component tree without having to pass props down manually at every level”.

Basically, it is an object you can interact with from any component inside the children tree of the Context Provider. So, it needs a provider with a default context object, and all of the provider’s children can use this object. As this object allows us to handle state at the app level, we can say that React has a built-in mechanism for state management; there is no need to install external libraries.

Good for:

  • Slow-moving global data: theme, authenticated user, preferences, etc.
  • When the data changes, a lot of the UI elements need to update
  • Micro frontends: small footprint of data

When to use external state managers

Sometimes it is better to use one of the many state management libraries. As they are not part of React, we have to do some research. Then we should try and evaluate a few of them and finally choose one, on which we depend from that moment. Therefore, it is important that the chosen library has a wide community supporting and maintaining it.

Good for:

  • Fast-moving data.
  • Bigger app, more complex state
  • Teamwork when standards are important

How to manage global state with React Context

We should divide the global state into smaller parts and use these parts as close as possible to the components using it. Divide the global state logically into sub-states, according to the features that depend on these sub-states. Then, place the context as close to the components using it as possible.

Using TypeScript is also very helpful to define and maintain the state. When you refactor the state, for example, by renaming, changing a function signature, or adding or removing elements, TypeScript will inform you immediately if you make any mistakes.

A not-so-simple example

As I am tired of basic examples, I want to show you a mid-complexity state management one. Let’s assume we want to manage the state of the currently logged user, using React Context and TypeScript. The state will contain:

  • User profile: first/last name, email, settings, etc. Will be null before login and after logout.
  • User functions:
    login
    logout
    acceptTermsAndConditions
    updateUserProfile

These functions will handle the user profile, so we don’t have to worry about it in the components that use this profile. We don’t have to modify the user profile directly. So, let’s define it:

Now let’s define the Context itself, which includes the user profile and functions:

If, for example, we want to change one of the user settings, we call updateUserProfile with the new settings and the userProfile will be updated (and possibly we will call the API to update it server-side). Then all the components that use the profile from inside the Context Provider will be rendered. This is important because if the affected components tree is very big, a lot of component rendering will take place, possibly slowing down the app. Later we will see how to partially alleviate this by memoizing the Context object.

Now let’s create the Context object using the interface above. We need to pass the initial value of this object, so we pass null as userProfile to indicate that the user is not logged in:

We are going to create a custom hook to facilitate the use of this context to the components. It will check that it is called from within the Context provider (it is a common mistake to use a Context from outside the corresponding provider):

Then, let’s say we need a component to display the user email and a button to log out if the user is logged in, or a link to the login page otherwise; we only have to import this useCurrentUser hook and de-structure the user profile and the logOut function:

Of course, this component should be inside the Context provider, or an error will be thrown and the component won’t be rendered.

Let’s see how to create a wrapper for the provider that should be placed as close to where it’s needed as possible:

As you can see, we are memorising the UserContext to avoid unnecessary renders at the provider level. We will add the “TODO” logic later.

Now we are going to locate this provider as close as possible to where it is needed. Imagine we need it only for a user page. We can use this Context provider like this:

Then, inside the components UserContactInfo, UserSettings and UserAccount we can use the context with useCurrentUser as explained below (see ComponentUsingUserContext):

const { /* objects and functions you need */ } = useCurrentUser();

Finally, let’s see how we can implement the remaining logic in the provider (the “TODO” part in CurrentUserProvider):

As you can see, we can use any API call mechanism (fetch, Axios…) by implementing the useAnyUserAPI custom hook. Then we have to process the responses in useEffects:

  • In the first useEffect, we set the user profile when successfully received from the API.
  • In the second useEffect, we set the user profile to null after a successful API call to log out.
  • In the third useEffect, we set only the value of termsAccepted when the API call is OK; the rest of the user profile props are left untouched.

In these cases, as well as when updateUserProfile is called, all the components that depend on userProfile will be re-rendered with the updated profile. These useEffects handle successful calls to API endpoints (logIn, logOut, acceptTermsAndConditions) by updating the profile. In a production app, we should handle the error responses as well. To do this, we might add error props to the context so that, when a component makes an API call, it can check the error and perhaps display a message or toast notification. Also, we might add functions to clear the errors once the user is notified.

As mentioned below, the API calls logic is implemented in the custom hook useAnyUserAPI, where you can use fetch, Axios or any other library, so our context doesn’t depend on this. You can find an implementation using Axios in the repo https://github.com/luisgonzalo/state-management-with-react-context.

Photo by Artem Sapegin on Unsplash

Now we have all the user state and logic in a Context we can use anywhere in our app. In the next post, we will see how we can test it!

About the author:

Luis Canas is a Senior Software Engineer at Version 1.

--

--