The modern guide to React state patterns

mangelosanto

Matt Angelosanto

Posted on August 9, 2023

The modern guide to React state patterns

Written by Kapeel Kokane✏️

When it comes to React applications, state is an integral part of what makes the application dynamic and interactive. Without state, applications would be static and unresponsive to user input.

State management is the process of handling the state of an application optimally. It is a crucial part of the development process. In this article, we will take a look at the different state management options available for React developers in 2023, and how to choose the right one for your project.

Jump ahead:

State in React

The UI that is generated in a React app is a function of the state. To invoke reactivity in an app, all we need to do is modify the state, and the React library will take care of the rest. The graphic below sums up the relationship between the application UX, state, and props in React:

Graphic To Demonstrate Relationship Between Application UX, State, And Props In React

React's built-in state management

React has a variety of built-in features for state management, including the useState and useReducer Hooks, as well as the Context API. Before exploring third-party libraries for state management, let’s take a look at these built-in features.

The useState and useReducer Hooks

Both the useState and useReducer Hooks have a different approach for how the state is updated. As mentioned above, both of these interfaces help us modify state. Beyond that, React takes care of all the heavy lifting required to faithfully represent that state on the browser DOM. The graphic below sums it up well:

Graphic Demonstrating How UseState And UseReducer Hooks Represent State On The Browser DOM

The useState Hook

The useState Hook is the most basic API provided by React to interact with state. To better understand how this hook works, let’s look at a counter app example. First, let’s create the state:

const [count, setCount] = useState(0);
Enter fullscreen mode Exit fullscreen mode

The above piece of code creates a state variable called count and a function called setCount that can be used to update the state. The initial value of the state is set to 0. Now, we’ll update the state by incrementing the value of count by 1:

setCount(count + 1);
Enter fullscreen mode Exit fullscreen mode

Finally, we’ll use the state:

<p>You clicked {count} times</p>
Enter fullscreen mode Exit fullscreen mode

In this example, we used the state by displaying the value of count in the UI. Learn more about the useState Hook in this guide.

The useReducer Hook

The useReducer Hook allows us to manage state by dispatching actions and then responding to them in the reducer function. Again, we’ll use a counter app example to understand how this hook works:

const [state, dispatch] = useReducer(reducerFunc, 0);
Enter fullscreen mode Exit fullscreen mode

We call the useReducer Hook with the reducer function and the initial state as arguments. It returns the current state and a dispatch function. Whenever we need to update the state, we can call the dispatch function with an action object:

dispatch({ type: 'INCREMENT' })
Enter fullscreen mode Exit fullscreen mode

React then calls the reducer function with the action object. Here's what a common reducer function looks like:

function reducerFunc(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    case 'RESET':
      return 0;
    case 'SET':
      return action.val;
  }
}
Enter fullscreen mode Exit fullscreen mode

At the end of this transaction, whatever the reducer function returns becomes the new state and is accessible through the state variable. Here's a sketch that explains the flow of data in the useReducer Hook:

Graphic Demonstrating The React UseReducer Hook

Read more about the useReducer Hook here.

State management with the useContext Hook

Next, let’s look at how the useContext Hook comes into the picture for state management. The general idea is to store a piece of data that components can use, but the pattern and the use cases are different.

While useState and useReducer are used to manage state that is scoped to a component, useContext is used to manage state that is shared across components. Its main purpose is to avoid prop drilling when a piece of state is supposed to be accessed by a child component several levels down the component tree. One of the primary use cases of context is to manage the theme of the application. So, let’s look at the code for the same.

Inside a component near the top of the component tree, we’ll create the context:

export const themeContext = createContext('light');
Enter fullscreen mode Exit fullscreen mode

Notice how the newly created context object is exported from here. To use that context, we’ll import it in a component down the tree:

import { useContext } from 'react';
import { ThemeContext } from './App.js';

export default function Button({ children }) {
  const theme = useContext(ThemeContext);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

But, for the above code to work, we need to first wrap the component tree above this consumer component with the context Provider:

<ThemeContext.Provider value={theme}>
  {children}
</ThemeContext.Provider>
Enter fullscreen mode Exit fullscreen mode

With that in place, the consumer component will receive the value supplied by the Provider. Whenever the value supplied to the Provider is updated, the child below will be able to access that latest updated value. For more information about how the Context API can be used with the useContext Hook to manage state, check out this article.

Third-party libraries for state management

While the above solution for state management makes sense when the scope of the state is just a single component (in the case of useState and useReducer), or when the state is supposed to be accessed in a downstream component (in the case of useContext), these restrictions make it difficult to use these solutions in larger applications where the order of creation and consumption of state is not defined. This is where third-party libraries come in.

Redux

Redux is one of the oldest and most popular libraries for state management in React. It has a large ecosystem of libraries and tools that make it easy to use in modern applications. The paradigm is similar to what we saw in the useReducer Hook, with the added advantage that the state is now global and can be accessed by any component in the application.

We’ll look into the react-redux library because that is the official binding for React. We’ll also need the redux-toolkit library, which will help us accomplish common Redux patterns while minimizing boilerplate code.

Similar to how the useContext Hook creates a context, which is a central place where the state is saved, the Redux library has a parallel concept called the store, which is the central state that is shared across components and acts as the source of truth. Let’s create the store:

import { configureStore } from '@reduxjs/toolkit'
import todosReducer from './features/todos/todosSlice'
import userReducer from './features/user/userSlice'

export default configureStore({
  reducer: {
    todo: todosReducer,
    user: userReducer
  },
})
Enter fullscreen mode Exit fullscreen mode

The configureStore function from redux-toolkit does something similar to Redux’s store. It creates a single store by combining several reducer-like functions that your entire application can have.

Notice the todosReducer and the userReducer, which are being imported from separate slices. A slice is nothing but an abstraction, provided by redux-toolkit, that helps us specify a name, an initial state, and a reducer function for a particular piece (or slice) of the entire app state.

Now that the central store is created, it needs to be accessible throughout the application. The way to accomplish that is via a Provider:

import { Provider } from 'react-redux'
import store from './store'

root.render(
  <Provider store={store}>
    <App />
  </Provider>
)
Enter fullscreen mode Exit fullscreen mode

We import the previously created state and the Provider component from react-redux and wrap the entire application with it. With that set up, we are now ready to access the state inside our function components using the hooks that react-redux provides:

import { useSelector, useDispatch } from 'react-redux'
import { markDone } from './todosSlice'

export function Counter() {
  const userName = useSelector((state) => state.user.name);
  const dispatch = useDispatch();

  ...
  dispatch(markDone(id));
Enter fullscreen mode Exit fullscreen mode

The useSelector Hook lets us get ahold of a particular slice of state from the store. It also gets the latest value by re-rendering the component when that slice of state is updated. The useDispatch() Hook gives us access to the dispatch function, which is used to dispatch actions to the store exactly the same as we did in the useReducer Hook.With that, we have a React app that uses Redux for state management. It can be scaled up by adding more slices whenever the app adds more features.

MobX

MobX is another alternative for state management in React. It relies on the concept of observables. State is segregated into pure values and computed values. The core belief of MobX is that "Anything that can be derived from the application state, should be. Automatically."

Let’s look at an example to better understand this. The state of the application is defined in the form of a JavaScript class. Here is an example of how a Todo item can be represented in state:

import { makeObservable, observable, action } from "mobx"

class Todo {
    id = Math.random()
    label = ""
    done = false

    constructor(label) {
        makeObservable(this, {
            label: observable,
            done: observable,
            toggle: action,
            summary: computed
        })
        this.label = label
    }

    toggle() {
        this.done = !this.done
    }

    get summary() {
        console.log("summarizing...")
        return `${this.label} ${this.done ? "is" : "is not"} done.`
    }
}
Enter fullscreen mode Exit fullscreen mode

We can see that the class has three properties: id, label, and done. Notice how the constructor calls the makeObservable function imported from Mobx, which defines the properties as observables. The summary property is being defined as computed, which means it will be re-calculated every time an observable dependency changes. The toggle function is defined as action.

With that general understanding of how Mobx works, let’s try to use it in a React application. We will use the mobx-react-lite package, which provides lightweight bindings for React to interact with Mobx.

If we don’t want to make the class defining the state more complex, we can use a utility like makeAutoObservable:

constructor() {
  makeAutoObservable(this)
}
Enter fullscreen mode Exit fullscreen mode

With this utility, we need not individually specify the types for individual properties (e.g. observable, action, computed) like we did in the previous example. When we call makeAutoObservable in the constructor, all own properties inside the class are marked as observable, all getters are marked as computed, and all setters as actions.

Once that is set, we can move on to integrating this with our React component. This is where the observer utility from Mobx comes into play. We just need to wrap our React component with the observer HOC and everything else is taken care of for us:

const Task = observer(({ todo }) => (
    <>
      <h1>Task: {todo.summary}</h1>
      <button onClick={() => todo.toggle()}>Toggle</button>
    </>
))
Enter fullscreen mode Exit fullscreen mode

In the example above, we can see that the Task component is being passed a todo prop, which is an instance of the Mobx store we created above. As we are accessing the summary property of the todo object, Mobx automatically takes care of re-rendering the component whenever the value of summary changes. We don’t need to do anything extra. Isn't that great?

This is the core concept around reactivity in Mobx. Based on the property that we access, Mobx will re-render our component whenever only the accessed property changes. For example, we are currently accessing the summary observable, which means this component will re-render only when that particular observable from the class changes. If there was another observable in the class that never gets accessed in this component, it would not have any impact on the re-rendering of the component. This mimics the behavior of subscribing to changes in the state, similar to Redux, and without the need to explicitly do so.

Recoil

Recoil is a newer library for state management in React that takes the atomic approach to state management. Atomic state management is a paradigm where the state of an application, instead of being stored in a single, large object, is broken down into smaller independent units of state called atoms.

An atom represents a piece of state that can be read to or written from any component. Any component that reads the value of an atom is automatically subscribed to that atom and will be re-rendered whenever the value of the atom changes.

For the application to be able to read and write to Recoil state, we need to wrap our parent component in RecoilRoot:

import { RecoilRoot } from 'recoil';

function App() {
  return (
    <RecoilRoot>
      <SayHi />
    </RecoilRoot>
  );
}
Enter fullscreen mode Exit fullscreen mode

With that in place, we can create and consume state. We can use the atom utility provided by Recoil to create atoms:

const firstNameState = atom({
  key: 'firstNameState',
  default: 'Bob',
});
Enter fullscreen mode Exit fullscreen mode

We can also create a derived state that depends on the two states above and returns a transformed output:

const introductionState = selector({
  key: 'introductionState',
  get: ({get}) => {
    const name = get(firstNameState);

    return `My name is ${name}!`;
  },
});
Enter fullscreen mode Exit fullscreen mode

In the example above, we created a greetingState derived state using the selector utility provided by Recoil. Now, to access these in the component and make modifications, we can use the useRecoilState Hook:

function SayHi() {
  // firstNameState & introductionState is the atom we created above
  const [firstname, setFirstName] = useRecoilState(firstNameState); 
  const introduction = useRecoilValue(introductionState);

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

  return (
    <div>
      <input type="text" value={text} onChange={onChange} />
      <br />
      <h1>Hello from {firstname}!</h1>
      <h2>{introduction}</h2>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice that we also used the useRecoilValue tool to access the value of the derived state. The core philosophy is that when any name is typed in the input box, setFirstName gets called, which updates firstName. This also triggers an update on the derived state introductionState, and we can see the latest results on the page. Jotai does something similar while bringing a few special capabilities to the table.

Jotai

Similar to Recoil, Jotai is a state management library that also takes an atomic approach to state management but in a slightly different way. This library also provides an atom utility to create atoms:

const firstNameAtom = atom('Bob');

  const personAtom = atom({
    firstName: 'Bob',
    lastName: 'Ross',
    age: 35
  });
Enter fullscreen mode Exit fullscreen mode

There is also a provision to create a derived state:

const greetingAtom = atom((get) => {
    const firstName = get(firstNameAtom);

    return `My name is ${firstName}!`;
  })
Enter fullscreen mode Exit fullscreen mode

Once created, this atom can be consumed and updated inside a React component similar to the useState Hook:

const [firstName, setFirstName] = useAtom(firstNameAtom);
Enter fullscreen mode Exit fullscreen mode

There are a few functionalities that make Jotai unique when compared to Recoil. For example, the atomWithStorage utility is exported from jotai/utils and persists the state onto LocalStorage so we don’t lose the value even after a refresh! This is useful when we need to store something like a user’s dark mode preference.

Jotai also provides separate integrations with libraries like Immer, Query, XState, etc. For instance, atomWithImmer exported from jota-immer lets us create an atom with an immer-based write function. These utilities are what make Jotai a unique choice as a state management library.

Signia

Signia is an alternate library for state management in React. Instead of using observables like Mobx, or atoms like Recoil and Jotai, it uses the concept of signals. A signal is a pure, reactive value that can be observed for changes.

Even though the underlying entity is a signal, the Signia library allows us to create atoms that are based on signals:

import { atom } from 'signia'
const fruit = atom('fruit', 'Apple');
Enter fullscreen mode Exit fullscreen mode

Updating the value of a signal is done by calling the set method on the atom:

fruit.set('Banana');
console.log(fruit.value); // Banana
Enter fullscreen mode Exit fullscreen mode

Signia also has the concept of computed state, similar to Recoil and Jotai. It is created by using the atom.value property of any atom inside the compute function:

const fruits = atom('fruits', 'Apples')
const numberOf = atom('numberOf', 10)
const display = computed('display', () => {
    return `${numberOf.value} ${fruits.value}`
})
Enter fullscreen mode Exit fullscreen mode

Read more about state management with Signia in this article.

State machines in React

Although state machines are not explicitly used in React, they can be powerful tools for managing state in React applications. A state machine is an entity that has a current state and can transition to other states based on certain events. This is similar to the concept of Redux, where we have a state and can dispatch actions on that state. But state machines have added advantages, like the ability to have parallel states, hierarchical states, and more.

XState

Working with states and managing transitions between them natively is a complicated affair. XState is a library that provides primitives that help us work with state machines. It also provides React bindings so that the state machine can be used in a React component.

Here's what a general state machine defined with XState looks like:

import { createMachine } from 'xstate';

const todoMachine = createMachine({
  id: 'todo',
  initial: 'pending',
  states: {
    pending: { on: { TOGGLE: 'done' } },
    done: { on: { TOGGLE: 'pending' } }
  }
});
Enter fullscreen mode Exit fullscreen mode

Notice how this is a neat way to define all the states, the initial state, and also the actions that lead to particular states. This state machine can then be used inside a React component using the hooks exposed by @xstate/react:

import { useMachine } from '@xstate/react';
import { todoMachine } from '../todoMachine';

function Toggle() {
  const [current, send] = useMachine(todoMachine);

  return (
    <button onClick={() => send('TOGGLE')}>
      {current.matches('pending') ? 'Mark done' : 'Mark pending'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is an example of a simple state, but we can make the state machine as complex as we want. The machine holds our application state and can act as a capable state management alternative to the libraries that we discussed above.

Conclusion

In this article, we explored the general concept of state in React, as well as tools for managing state. We reviewed the built-in options for local component state management, like useState and useReducer. We also explored useContext as a means to store slow-changing application-level state.

Then, we looked into the different external libraries available, including Redux, Mobx, Recoil, Jotai, and Signia. Finally, we looked at state machines as a means to store state and how they can be used in React applications courtesy of the XState library.

I hope you now have a better idea about the state of state management in React and can make an informed decision about which library to use for your next project.


Get set up with LogRocket's modern React error tracking in minutes:

1.Visit https://logrocket.com/signup/ to get an app ID.
2.Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on August 9, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related