two pieces of puzzle with a grass background

Photo Credit: Vardan Papikyan

Demystifying React State Management: A Comprehensive Guide

October 25, 2023 React

Learn how React manages application state and how to use it in a real application.

React is a prime library for creating performant UI’s. However, it’s nearly impossible to create a modern Frontend application without using some form of state. That state describes the application UI and the data that it needs to handle. It can be a challenge to manage this in a scalable and performant way, so React has some features and APIs that can help developers with it.

Table of contents

  • What is state management?

  • Why is state management so hard?

  • How does React manage state?

  • Local (Component) state

  • Shared state with props

  • Shared state with Context API and useReducer hook

  • Summary

What is state management?

For many years, client applications have become more and more powerful. They need to handle complex, data-heavy UI’s, that are performant and adaptable to different screen sizes and devices. A lot of data-related operations must be synchronized:

  • Between the Frontend and Backend layers — this is done through network requests.

  • Between multiple components of the Frontend application — this is done through state management.

State management can be categorized mainly in two ways:

By scope

  • Local — state that is only relevant for the component or module in which it lives, like a form state.

  • Shared — state that spans across multiple components and can be used in many places, like user details, or items in the cart.

By longevity

  • Ephemeral — state that is only needed for the current user’s session. Usually UI-related, like collapsed/expanded elements, and active menu items.

  • Persistent — state that will be persisted across sessions. Depends mainly on the business logic, like the items added to a cart, the user preferences, etc.

Why is state management so hard?

  • Complex user interfaces — modern applications consist of hundreds of components that handle different operations. These operations can depend on a correct data flow or asynchronous operations.

  • Performance — Re-rendering of the whole application because some state element changes can lead to a slow user interface and the loss of users. Thus, we want to make sure that only the components that are using the updated pieces of state will be re-rendered.

  • Shared state — When multiple components can modify the same state, it becomes hard to manage the consistency of the affected data. We need to ensure proper handling of race conditions, synchronization between components, and ensure a single source of truth for the data.

How does React manage state?

As of now, React is the most popular Frontend library for creating modern web applications. It was created to manage the UI with ease, and it does a great job at that. In terms of state management:

  • It has prime support for local (component-level) state with the useState hook.

  • Basic support for simple shared state with props and the useReducer hook.

The following examples are also available on Codesandbox.

Local (Component) state

In React this is a state that is local to a component, and maybe its immediate subtree. React has a useState hook, that in essence subscribes the component to changes in a small piece of data and exposes a simple API to update its value. Whenever the piece of data is updated, any component that uses it will be re-rendered.

I encourage you to read more about it, as in most of the cases, it is the simplest and best way to go.

import { useState } from "react"

export default function App() {
  // create the state with useState
  const [todos, setTodos] = useState([])

  return (
    <div>
      <button
        onClick={() =>
          // setTodos creates a new state value
          setTodos([
            ...todos,
            "New todo " + new Date().getMilliseconds().toString(),
          ])
        }
      >
        Add todo
      </button>

      <ul>
        {/* the component will re-render whenever the `todos` state change */}
        {todos.map(todo => (
          <li>{todo}</li>
        ))}
      </ul>
    </div>
  )
}

Shared state with props

React allows us to pass the state to some component down the component tree just by using component props. This is great and simple when we just pass the data one level down. However, when the target component is far away from the data, we will need to pass the prop through every component in the middle. This is called props drilling.

It’s a problem because intermediary components are now receiving props only to pass them further down, which poses some risks:

  • The components are cluttered with props that they don’t use.

  • The application becomes very coupled. A change to the shape of the state can require changing every component in the middle.

  • It’s hard to scale and maintain — if the component that uses the props is removed, or moved somewhere else, we need to not only remember to remove the unused prop from every component that was in the middle but also create a new flow of props in the new place where it is needed.

  • It’s hard to debug — finding where the props come from and where they are actually used requires scanning the whole component tree. This takes time and increases the risk of human error.

import { useState } from "react"

function TodoElem({ todo }) {
  // TodoElem is a "sateteless" component that gets props from his parent
  return <li>{todo}</li>
}

export default function AppSharedState() {
  // Top-level state is managed by this component
  const [todos, setTodos] = useState([])

  return (
    <div>
      <button
        onClick={() =>
          // setTodos creates a new state value
          setTodos([
            ...todos,
            "New todo " + new Date().getMilliseconds().toString(),
          ])
        }
      >
        Add todo
      </button>

      <ul>
        {/* the component will re-render whenever the `todos` state change */}
        {todos.map(todo => (
          // Pass the todo as a prop to the <Todo /> component
          <TodoElem todo={todo} />
        ))}
      </ul>
    </div>
  )
}

Shared state with the Context API and the useReducer hook

In order to fix the issues with prop drilling and centralize state management, React introduced respectively the Context API and the useReducer hook.

Together they form a powerful toolbox for managing an internal store with shared state accessible from anywhere in the application.

The reducer pattern

In the React world, it was very common to use some external library that works with the reducer pattern for managing state. The pattern was so popular that starting from React version 16.8, it was encapsulated within the useReducer hook.

The reducer pattern consists of having a function, called a reducer, that takes the current state and an action and returns the new state as a result. The action informs the reducer what part of the state it wants to change, and the reducer is responsible for applying that change.

The data flow is unidirectional, which makes it easy to track changes in the state. It allows multiple “stores” that contain pieces of the data, and their matching reducers, for better scalability and maintenance.

The reducer pattern — image by the author

First, create a todoReducer function. It accepts the current state (todos) and an action that has a type and the payload with new data. In this case, the payload is just our todo text.

function todosReducer(todos, action) {
    if (action.type === "add") {
        return […todos, action.payload];
    } else {
        return […todos];
    }
}

Next, replace the useState in our stateful component with the useReducer hook. Similarly to useState, it returns the latest value of our todos, and a function that can be called to alter the state. By convention, I named it dispatch and you can call it to send an action of type “add”, which will contain the payload in the form of a new todo.

import { useReducer } from "react"

const initialTodos = []

export default function AppWithContextAndReducer() {
  // Top-level state is managed by this component
  // const [todos, setTodos] = useState([]);
  const [todos, dispatch] = useReducer(todosReducer, initialTodos)
  return (
    <div>
      <button
        onClick={() =>
          dispatch({
            type: "add",
            payload: "New todo " + new Date().getMilliseconds().toString(),
          })
        }
      >
        Add todo
      </button>
      <ul>
        {todos.map(todo => (
          <TodoElem todo={todo} />
        ))}
      </ul>
    </div>
  )
}

And that is how we manage the state in one place with the useReducer hook. The state updates are now centralized in one reducer function.

Immutable updates

One thing to note is that the reducer pattern requires us to remember about doing immutable updates. That means, deep cloning the parts of the state that are arrays and objects. The reason is that in JavaScript every object (arrays are also objects) is a reference type, so only one copy of the data is stored in memory. When you assign it to another variable or you pass it to a function, you are actually passing a reference to the object. This can lead to errors when trying to update the state, because React can do more re-renders than you expect, which may lead to calling your dispatchers multiple times for the same action. If the same object is mutated more than one time for the same action, your data will be wrong.

If your state updates are created without deep cloning, this can lead to wrong behavior and hard-to-fix bugs. Imagine adding an element to the cart, but your reducer adds it two times, resulting in a double cost for the user. This is a bug we really don’t want in production.

Fixing props-drilling issues with the Context API

The Context API allows for creating a context — a piece of data that lives alongside the component tree and can be accessed from any component.

First, we need to create a context for both our reducer and dispatch function:

import { createContext } from "react"

export const TodosContext = createContext([])
export const TodosDispatchContext = createContext(null)

The next step is to provide the context into an application subtree with a context provider. It is very common to create a common provider for the whole domain that the state encapsulates, and we’ll do the same, by creating the TodosProvider. It will encapsulate the todos and dispatch actions that are returned by our useReducer. The final version looks like this:

import { createContext, useReducer } from "react"

const initialTodos = []

function todosReducer(todos, action) {
  if (action.type === "add") {
    return [...todos, action.payload]
  } else {
    return [...todos]
  }
}

export const TodosContext = createContext([])
export const TodosDispatchContext = createContext(null)

// TodosProvider encapsulates our state with the context providers.
export const TodosProvider = ({ children }) => {
  const [todos, dispatch] = useReducer(todosReducer, initialTodos)

  return (
    <TodosContext.Provider value={todos}>
      <TodosDispatchContext.Provider value={dispatch}>
        {children}
      </TodosDispatchContext.Provider>
    </TodosContext.Provider>
  )
}

Next, we need to wrap our App with the TodoProvider, to make the context available to any component in the subtree.

export default function App() {
  return (
    <TodosProvider>
      <AppWithContextAndReducer />
    </TodosProvider>
  )
}

And finally the context can be consumed with the useContext hook.

const SomeComponent = () => {
  const todos = useContext(TodosContext)
  const dispatch = useContext(TodosDispatchContext)

  /* other code */
}

We can now access the dispatch function and the current value of todos anywhere in the app.

The final code looks like this:

// App.js

import { useContext } from "react"
import {
  TodosContext,
  TodosDispatchContext,
  TodosProvider,
} from "./TodosContext"

function Todos() {
  const todos = useContext(TodosContext)

  return (
    <ul>
      {todos.map((todo, idx) => (
        <li key={idx}>{todo}</li>
      ))}
    </ul>
  )
}

function AppWithContextAndReducer() {
  const dispatch = useContext(TodosDispatchContext)

  return (
    <div>
      <button
        onClick={() =>
          dispatch({
            type: "add",
            payload: "New todo " + new Date().getMilliseconds().toString(),
          })
        }
      >
        Add todo
      </button>
      <Todos />
    </div>
  )
}

export default function App() {
  return (
    <TodosProvider>
      <AppWithContextAndReducer />
    </TodosProvider>
  )
}

// TodosContext.js

import { createContext, useReducer } from "react"

const initialTodos = []

function todosReducer(todos, action) {
  if (action.type === "add") {
    return [...todos, action.payload]
  } else {
    return [...todos]
  }
}

export const TodosContext = createContext([])
export const TodosDispatchContext = createContext(null)

// TodosProvider encapsulates our state with the context providers.
export const TodosProvider = ({ children }) => {
  const [todos, dispatch] = useReducer(todosReducer, initialTodos)

  return (
    <TodosContext.Provider value={todos}>
      <TodosDispatchContext.Provider value={dispatch}>
        {children}
      </TodosDispatchContext.Provider>
    </TodosContext.Provider>
  )
}

Summary

Remember, handling state in React boils down to one of the three strategies:

  • Local (component) state

  • Shared state passed by props

  • Shared state accessible from anywhere via the Context API

On top of that, we can use the useReducer hook to help us manage the state using the reducer pattern.

I hope this article will serve you well as a quick reference on those topics.