State management in react is a hard problem and the number of libraries and concepts around the topic can feel overwhelming.

Instead of going through all the tools and concepts, we’ll explore a few state management patterns using React’s built-in functionality and without any external libraries.

Server Cache vs Application State

The key to better state management is separating between async data coming from and owned by a server which is usually cached on the client for usage and application/client data or UI state.

Client state is owned by the application, it’s usually gone on browser refresh. Some examples are form field values, modal states, filtered items, current themes, and compound components states.

Client state can be local to specific components like which modal is open, or globally such as the chosen theme.

This article will focus on client/application state management patterns.

Current Solutions

A couple of years ago it was very common to have all states in a Redux store, including local client state, and now similar approaches are being taken with React context and maybe another tool like Zustand.

Another common pattern is prop drilling, where a piece of state is being passed from parent to child through multiple levels, often while not being needed by the parent(s) component at all.

You might not need a library

React is already equipped with tools to define, modify and share application state out of the box. We have useState, useReducer, and Context.

The composition nature of React opens the door for an easily shared state.

For this exact reason, it’s sometimes hard to choose the correct tool for the job.

Lift state up

This concept is as old as React itself. The idea is to move the state from individual components to their closest parent, and then pass it down via props.

In this example, we have a SearchBar component for the search text field and a List component for rendering the items.

They are currently disconnected, typing in the search bar wouldn’t change the displayed items.

import { useState } from "react";
import { foods, filterItems } from "./data.js";

const FilterableList = () => {
  return (
    <>
      <SearchBar />
      <hr />
      <List items={foods} />
    </>
  );
};

const SearchBar = () => {
  const [query, setQuery] = useState("");

  function handleChange(e) {
    setQuery(e.target.value);
  }

  return (
    <label>
      Search: <input value={query} onChange={handleChange} />
    </label>
  );
};

const List = ({ items }) => {
  return (
    <table>
      <tbody>
        {items.map((food) => (
          <tr key={food.id}>
            <td>{food.name}</td>
            <td>{food.description}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
};

To connect them we might introduce a new state in FilterableList for the typed query and then use that to filter the items in List

import { useState } from "react";
import { foods, filterItems } from "./data.js";

const FilterableList = () => {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <>
      <SearchBar onChange={setSearchQuery} />
      <hr />
      <List searchQuery={searchQuery} items={foods} />
    </>
  );
};

const SearchBar = ({ onChange }) => {
  const handleChange = (event) => {
    onChange(event.target.value);
  }

  return (
    <label>
      Search: <input onChange={handleChange} />
    </label>
  );
};

const List = ({ searchQuery, items }) => {
  const filteredItems = filterItems(items, searchQuery);

  return (
    <table>
      <tbody>
        {filteredItems.map((food) => (
          <tr key={food.id}>
            <td>{food.name}</td>
            <td>{food.description}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
};

This approach would work but it introduces a new problem. The state is now owned by multiple components.

  1. searchQuery lives in FilterableList while updated from SearchBar
  2. filteredItems is owned by List but the query used to filter the items is not.

As you can see, this might lead to confusion, especially in more complex use cases. It’s going to be hard following where all the different pieces of state are coming from.

Single source of truth

It doesn’t mean that all state should live in one place like it was common with Redux, but that each piece of state is owned by a specific component.

import { useState } from "react";
import { foods, filterItems } from "./data.js";

const FilterableList = () => {
  // Moved all state to `FilterableList`
  // since it's the closest parent
  const [query, setQuery] = useState("");
  const filteredItems = filterItems(foods, query);

  const handleChange = (event) => {
    setQuery(event.target.value);
  }

  return (
    <>
      <SearchBar value={query} onChange={handleChange} />
      <hr />
      <List items={filteredItems} />
    </>
  );
};

const SearchBar = ({ value, onChange }) => {
  return (
    <label>
      Search: <input value={query} onChange={handleChange} />
    </label>
  );
};

const List = ({ items }) => {
  return (
    <table>
      <tbody>
        {items.map((food) => (
          <tr key={food.id}>
            <td>{food.name}</td>
            <td>{food.description}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
};

All state now only lives in FilterableList it owns the searchQuery as well as the filteredItems. It can then determine what state/callbacks to pass to which children as required. It acts as the single source of truth.

SearchBar and List are now controlled components, getting their state from their parent as props.

Passing props down

When lifting the state up, we need to pass it down again through props to the relevant children. This is fairly straightforward for simple components and use cases like above. However, it can get a little tricky in more complex scenarios.

Prop Drilling and Composition

Props are typically used to pass data from a parent component to a child component. However, passing props can become cumbersome and annoying if you have to pass them through numerous middle components or if many components in your app want the same data.

It’s often that we’ll pass data through many components that don’t even need it just so we can use it in a deeply nested component. This is called “Prop Drilling”.

This problem can be solved by Context but just because the information is needed a few levels deep doesn’t necessarily mean it should live in a Context.

Alternatively, we should look into composing components for a more flat structure.

Imagine we need to render a list of items, each item has some secondary content that may be displayed in a modal, we also have a state keeping track of how many times modals were toggled which we need to show in the modals.

We should also have the possibility to toggle the modals from outside the items.

const Modal = ({ isOpen, children }) => {
  return <dialog open={isOpen}>{children}</dialog>;
};

const Item = ({ itemId, isInModal, isOpen, totalToggleCount, toggleModal }) => {
  return (
    <div className="item">
      <p>Item number {itemId}</p>

      {isInModal ? (
        <>
          <button onClick={() => toggleModal(itemId)}>Toggle Modal</button>
          <Modal isOpen={isOpen}>
            Item #{itemId} Extra Content
            <p>Total Toggle Count: {totalToggleCount}</p>
          </Modal>
        </>
      ) : (
        <p>Item #{itemId} Secondary Content</p>
      )}
    </div>
  );
};

const App = () => {
  const [activeModal, setActiveModal] = useState(null);
  const [totalToggleCount, setTotalToggleCount] = useState(null);

  const toggleModal = (modalId) => {
    setActiveModal((currentActiveModal) => {
      const isOpen = currentActiveModal === modalId;

      if (isOpen) {
        return null;
      }

      setTotalToggleCount(totalToggleCount + 1);
      return modalId;
    });
  };

  return (
    <div className="items">
      <Item
        itemId={1}
        toggleModal={toggleModal}
        isOpen={activeModal === 1}
        totalToggleCount={totalToggleCount}
        isInModal
      />
      <Item
        itemId={2}
        toggleModal={toggleModal}
        isOpen={activeModal === 2}
        totalToggleCount={totalToggleCount}
        isInModal
      />
      <Item itemId={3} />
    </div>
  );
};

Feel free to try out the interactive version on Codesandbox.

This implementation works, but as you can see, we are passing a lot of props to Item while it doesn’t care about most of them. It accepts those props just so that it can render the Modal.

This problem becomes apparent when we need to pass the props a few more levels down.

Using Render Props we can now skip most of the props to Item it just cared about its ID and delegates the extra content rendering through the renderSecondaryContent render prop.

const Item = ({ itemId, renderSecondaryContent }) => {
  return (
    <div className="item">
      <p>Item number {itemId}</p>
      {renderSecondaryContent(`Item #${itemId} Secondary Content`)}
    </div>
  );
};

const App = () => {
  ...

  return (
    <div>
      <button onClick={() => toggleModal(1)}>Toggle Modal (1)</button>
      <button onClick={() => toggleModal(2)}>Toggle Modal (2)</button>

      <div className="items">
        <Item
          itemId={1}
          setActiveModal={toggleModal}
          renderSecondaryContent={(itemContent) => (
            <>
              <button onClick={() => toggleModal(1)}>Toggle Modal</button>
              <Modal isOpen={activeModal === 1}>
                {itemContent}
                <p>Total Toggles Count: {totalToggleCount}</p>
              </Modal>
            </>
          )}
        />
        <Item
          itemId={2}
          renderSecondaryContent={(itemContent) => (
            <>
              <button onClick={() => toggleModal(2)}>Toggle Modal</button>
              <Modal isOpen={activeModal === 2}>
                {itemContent}
                <p>Total Toggles Count: {totalToggleCount}</p>
              </Modal>
            </>
          )}
        />
        <Item
          itemId={3}
          renderSecondaryContent={(itemContent) => <p>{itemContent}</p>}
        />
      </div>
    </div>
  );
};

Since renderSecondaryContent is living in the parent component scope, it has access to all of its state plus the inner state of the individual Item components through its parameters. We could also use the children prop to achieve the same behavior.

Individual Item components don’t need to worry about how extra content will be displayed any more. We can use a Modal, a p tag or any other component as we see fit without touchting the internals of Item. An example of the “O” in “SOLID”, open for extension, closed for modification.

We can further compose the content of the render prop to minimize prop drilling as much as possible.

Micheal Jackson, the co-author of React Router and Remix has a great video on composition and prop drilling.

When to use Context

Context can become handy in different use cases. It works for the local as well as global state such as theming.

The problem with Context is that all of its consumers will rerender even if they aren’t using the piece of state that changed. Making it less than ideal for managing global state that covers a lot of components.

Compound Components work well with Context to manage internal state when we can’t pass props through composition, usually when the user of the components controls its composition.

If you’ve decided to go with Context make sure to use it effectively and optimize its value

It’s ok to have a global application state

In some cases, a global application state is still needed, and unfortunately React doesn’t natively support a performant way of handling it.

In those cases, it’s ok to reach out for an external tool such as Jotai from the same people behind Zustand and react-spring.

Tl;DR

Once the server state is handled and cached the need for a global state is greatly reduced. Application state can mostly be handled through composition and Context. If we still need frequently updated global state we can reach out to external tools like Jotai

Want to know more about how we can work together and launch a successful digital energy service?