Fredrik Strand Oseberg I'm a self-taught entrepreneur-turned-web developer consultant. Knowledgeable in JavaScript and Go.

The modern guide to React state patterns

8 min read 2465

The Modern Guide to React State Patterns

Introduction

Since its inception in 2013, React has rolled out a robust set of tools to help relieve developers of some of the minutiae of creating web applications and allow them to focus on what matters.

Despite React’s many features and consistent popularity among developers, however, I have found time and again that many of us ask the same question: How do we handle complex state using React?

In this article we’ll investigate what state is, how we can organize it, and different patterns to employ as the complexity of our applications grow.

Understanding state in React

In its purest form, React can be considered a blueprint. Given some state, your application will look a certain way. React favors the declarative over the imperative, which is a fancy way of saying that you write what you want to happen instead of writing the steps to make it happen. Because of this, managing state correctly becomes supremely important because state controls how your application will behave.

Illustration of the Concept of State in a React App

State in action

Before we get started, it will be useful to briefly discuss what state is. Personally, I think of state as a collection of mutable values that change over time and directly influence component behavior.

State is very similar to props, but the difference is that state can be changed within the context of where it is defined, whereas props received cannot be changed without passing a callback function.

Let’s have a look:

const UserList = () => {
    const [users, setUsers] = useState([])

     useEffect(() => {
       const getUsers = async () => {
           const response = await fetch("https://myuserapi.com/users")
           const users = await response.json()
           setUsers(users)
       }
       getUsers()
     }, [])

    if (users.length < 1) return null;

    return <ul>
      {users.map(user => <li>{user.name}</li>)}
    </ul>
}

In this example, we are fetching users from an API when the component mounts and updating the users array once we have received a response. We naively assume that the call will always be successful to reduce the complexity of the example.

We can see that the state is being used to render list items with the user’s name, and it will return null if there are no users in the array. The state changes over time and is used to directly influence component behavior.

We made a custom demo for .
No really. Click here to check it out.

Another thing worth noting here is that we are using React’s built-in state management method using the useState Hook. Depending on the complexity of your application and state management, you may only need to use React’s built-in Hook to manage your state.

However, as is clear by the abundance of state management solutions for React, the built-in state management method sometimes is not enough. Let’s take a look at some of the reasons why.

Understanding prop drilling

Let’s consider a slightly more complex app. As your application grows, you’re forced to create multiple layers of components in order to separate concerns and/or increase readability. The problem occurs when you have state that is needed in multiple components that have different places in the tree.

Diagram of a Basic React Component Tree

If we wanted to supply both the UserMenu and the Profile components with user data, we must place the state in App because that is the only place that can propagate the data down to each component that requires it. That means we’ll pass it through components that may not require the data — Dashboard and Settings, for example — polluting them with unnecessary data.

Now, what if you need to manipulate the data in another component? Well, you’ll need to supply the updater function (the setUsers function from the last example) to the component that needs to do the updating, adding yet another property to propagate down — all this for one piece of state. Now imagine compounding it by adding five more properties. It can quickly get out of hand.

Have you ever heard someone say, “You’ll know when you need a state library”?

For me, that means how comfortable I feel with drilling the properties and updater functions through multiple layers of components. Personally, I have a hard limit on three layers; after that, I reach for another solution. But until that point, I’m adamant about using the built-in functionality in React.

State libraries come with a cost as well, and there is no reason to add unnecessary complexity until you are sure that it’s absolutely needed.

The re-rendering issue

Since React automatically triggers a re-render once state is updated, the internal state handling can become problematic once the application grows. Different branches of the component tree might need the same data, and the only way to provide these components with the same data is to lift the state up to the closest common ancestor.

As the application grows, a lot of state will need to be lifted upwards in the component tree, which will increase the level of prop drilling and cause unnecessary re-renders as the state is updated.

The testing issue

Another problem with keeping all of your state in the components is that your state handling becomes cumbersome to test. Stateful components require you to set up complex test scenarios where you invoke actions that trigger state and match on the result. Testing the state in this way can quickly become complex, and changing how state works in your application will often require a full rewrite of your component tests.

Managing state with Redux

As far as state libraries go, one of the most prominent and widely used libraries for managing state is Redux. Launched in 2015, Redux is a state container that helps you write maintainable, testable state. It’s based upon principles from Flux, which is an open source architecture pattern from Facebook.

Illustrating How Redux Handles State

In essence, Redux provides a global state object that supplies each component with the state it needs, re-rendering only the components that receive the state (and their children). Redux manages stated based on actions and reducers. Let’s quickly examine the components:

Illustrating How Redux Manages State with Actions and Reducers

In this example, the component dispatches an action that goes to the reducer. The reducer updates the state, which in turn triggers a re-render.

State

State is the single source of truth; it represents your state at all times. Its job is to supply the components with state. Example:

{
  users: [{ id: "1231", username: "Dale" }, { id: "1235", username: "Sarah"}]
}

Actions

Actions are predefined objects that represent a change in state. They are plain text objects that follow a certain contract:

{
  type: "ADD_USER",
  payload: { user: { id: "5123", username: "Kyle" } }
}

Reducers

A reducer is a function that receives an action and is responsible for updating the state object:

const userReducer = (state, action) => {
    switch (action.type) {
       case "ADD_USER":
          return { ...state, users: [...state.users, action.payload.user ]}
       default:
          return state;
    }
}

Contemporary React state patterns

While Redux still is a great tool, over time, React has evolved and given us access to new technology. In addition, new thoughts and ideas have been introduced into state management, which have resulted in many different ways of handling state. Let’s investigate some more contemporary patterns in this section.

useReducer and the Context API

React 16.8 introduced Hooks and gave us new ways to share functionality through our application. As a result, we now have access to a Hook that comes built into React called useReducer, which allows us to create reducers out of the box. If we then pair this functionality with React’s Context API, we now have a lightweight Redux-like solution that we can use through our application.

Let’s take a look at an example with a reducer handling API calls:

const apiReducer = (state = {}, action) => {
  switch (action.type) {
      case "START_FETCH_USERS":
        return { 
               ...state, 
               users: { success: false, loading: true, error: false, data: [] } 
         }
      case "FETCH_USERS_SUCCESS": 
        return {
              ...state,
              users: { success: true, loading: true, error: false, data: action.payload.data}
        }
      case "FETCH_USERS_ERROR":
        return {
           ...state,
           users: { success: false, loading: false, error: true, data: [] }
        }
      case default:
         return state 
    }
}

Now that we have our reducer, let’s create our context:

const apiContext = createContext({})

export default apiContext;

With these two pieces, we can now create a highly flexible state management system by combining them:

import apiReducer from './apiReducer'
import ApiContext from './ApiContext

const initialState = { users: { success: false, loading: false, error: false, data: []}}

const ApiProvider = ({ children }) => {
    const [state, dispatch] = useReducer(apiReducer, initialState)

    return <ApiContext.Provider value={{ ...state, apiDispatcher: dispatch }}>
      {children}
    </ApiContext.Provider>
}

With that done, we now need to wrap this provider around the components in our application that need access to this state. For example, at the root of our application:

ReactDOM.render(document.getElementById("root"), 
   <ApiProvider>
     <App />
   </ApiProvider>
)

Now, any component that is a child of App will be able to access our ApiProviders state and dispatcher in order to trigger actions and access the state in the following way:

import React, { useEffect } from 'react'
import ApiContext from '../ApiProvider/ApiContext

const UserList = () => {
     const { users, apiDispatcher } = useContext(ApiContext)

     useEffect(() => {
        const fetchUsers = () => {
           apiDispatcher({ type: "START_FETCH_USERS" })
           fetch("https://myapi.com/users")
              .then(res => res.json())
              .then(data =>  apiDispatcher({ type: "FETCH_USERS_SUCCCESS", users: data.users }))
              .catch((err) => apiDispatcher({ type: "START_FETCH_ERROR" }))
        }
        fetchUsers()
     }, [])

     const renderUserList = () => {
         // ...render the list 
     }

     const { loading, error, data } = users; 
     return <div>
        <ConditionallyRender condition={loading} show={loader} />
        <ConditionallyRender condition={error} show={loader} />
        <ConditonallyRender condition={users.length > 0} show={renderUserList} />
     <div/>      
}

Managing state with state machines and XState

Another popular way of managing state is using state machines. Briefly explained, state machines are dedicated state containers that can hold a finite number of states any any time. This makes state machines extremely predictable. Since each state machine follows the same pattern, you can insert a state machine into a generator and receive a state chart with an overview of your data flow.

A State Chart Produced by XState

State machines generally follow stricter rules than Redux does with respect to their format to maintain predictability. In the world of React state management, XState is the most popular library for creating, interpreting, and working with state machines.

Let’s take a look at the example from the XState docs:

import { createMachine, interpret, assign } from 'xstate';

const fetchMachine = createMachine({
  id: 'Dog API',
  initial: 'idle',
  context: {
    dog: null
  },
  states: {
    idle: {
      on: {
        FETCH: 'loading'
      }
    },
    loading: {
      invoke: {
        id: 'fetchDog',
        src: (context, event) =>
          fetch('https://dog.ceo/api/breeds/image/random').then((data) =>
            data.json()
          ),
        onDone: {
          target: 'resolved',
          actions: assign({
            dog: (_, event) => event.data
          })
        },
        onError: 'rejected'
      },
      on: {
        CANCEL: 'idle'
      }
    },
    resolved: {
      type: 'final'
    },
    rejected: {
      on: {
        FETCH: 'loading'
      }
    }
  }
});

const dogService = interpret(fetchMachine)
  .onTransition((state) => console.log(state.value))
  .start();

dogService.send('FETCH');

useSWR

Over the years, state management has grown increasingly complex. While proper state management coupled with view libraries like React allows us to do amazing things, there is no doubt that we are moving a lot of complexity to the frontend. And with increased complexity, we are also inviting more cognitive load, more indirection, more potential for bugs, and more code that needs to be thoroughly tested.

useSWR has been a breath of fresh air in this regard. Pairing this library with the native capabilities of React Hooks produces a level of simplicity that is hard not to love. This library uses the HTTP cache technique stale-while-revalidate, which means it keeps a local cache of the previous dataset and syncs with the API in the background to get fresh data.

This keeps the app highly performant and user-friendly because the UI can respond with stale date while waiting for updates to be fetched. Let’s take a look at how we can utilize this library and do away with some of the complexities of state management.

// Data fetching hook
import useSWR from 'swr'

const useUser(userId) {
    const fetcher = (...args) => fetch(...args).then(res => res.json())
    const { data, error } = useSWR(`/api/user/${userId}`, fetcher)

    return { 
      user: data,
      error,
      loading: !data && !error
    }
}

export default useUser

Now we have a reusable Hook that we can utilize in order to get data into our component views. No need to create reducers, actions, or connecting components to state in order to get your data — just import and use the Hook in the components that need the data:

import Loader from '../components/Loader'
import UserError from '../components/UserError'
import useUser from '../hooks/useUser';

const UserProfile = ({ id }) => {
    const { user, error, loading } = useUser(id);

     if (loading) return <Loader />
     if (error) return <UserError />

      return <div>
          <h1>{user.name}</h1>
          ...
      </div>
}

And in another component:

import Loader from '../components/Loader'
import UserError from '../components/UserError'
import useUser from '../hooks/useUser';

const Header = ({ id }) => {
    const { user, error, loading } = useUser(id);

     if (loading) return <Loader />
     if (error) return <UserError />

      return <div>
           <Avatar img={user.imageUrl} />         
           ...
      </div>
}

This method allows you to easily pass around Hooks that can access a shared data object because the first argument to useSWR is a key:

const { data, error } = useSWR(`/api/user/${userId}`, fetcher)

Based on this key, our requests are deduped, cached, and shared across all our components that use the useUser Hook. This also means that only one request is sent to the API as long as the key matches. Even if we have 10 components using the useUser Hook, only one request will be sent as long as the useSWR key matches.

Conclusion

If React is a canvas that at any time represents your application state, then state is really important to get right. In this article, we’ve looked at various ways to handle state in React applications, and in truth, we could have included more.

Recoil and Jotai, not to mention React Query and MobX, are certainly relevant in a discussion like this, and the fact that we have a lot of different state libraries is a great thing. It pushes us to try out different things, and pushes library authors to constantly do better. And such is the way forward.

Now, which solution should you choose for your project? This is a question I cannot answer, but I will give my own opinion.

Personally, I tend to side with the library that introduces the least amount of complexity. It’s fantastic to have tools such as Redux at our disposal, and there are times when they are needed, but until you feel the pain, I would go for the simplest possible solution.

For me, using useSWR has been a revelation and has significantly reduced the indirection and level of complexity in the apps I’ve recently authored.

If you liked this article, please give me a shout on Twitter. And if you want to follow more of my content, follow my YouTube channel.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Fredrik Strand Oseberg I'm a self-taught entrepreneur-turned-web developer consultant. Knowledgeable in JavaScript and Go.

6 Replies to “The modern guide to React state patterns”

  1. Very interesting. UseSWR looks really cool but how well does it work across all CRUD actions? How do you invalidate the cache? And what about saving the cache locally instead of in memory as an iPhone has very small memory that gets wiped often.

  2. Thanks!

    useSWR will only handle the read operations from the API. Create, Update and Delete you need to handle yourself, but you could easily extract this into a hook of your own or use the native fetch API.

    By default useSWR will set up a refreshInterval to refresh the local cache. This property is also configurable, so you can choose your own refreshInterval when you use the useSWR hook. In addition it will revalidate the cache on page focus and network connection by default, which means that you get a lot for free in terms of cache revalidation. In the case that you need to force a cache invalidation you’re provided with a mutate method that you can extract from the useSWR hook that will let useSWR know that something changed and you need to refetch immediately. As for the memory, I believe useSWR utilizes the browser cache instead of actually keeping it in memory.

  3. I’m sorry for being that guy, but claiming that state machines are inspired by Redux is such a backward thing to say. State machines have been around for decades, long before Dan Abramov and Andrew Clark were even born…

    If anything Redux is probably inspired from state machines, like the concept of Actions to move from one state to the other.

  4. Why on earth would you be sorry for pointing that out? Thanks for clarifying that obvious error, it was clumsily formulated. In fact, what I meant was that the library XState is reminiscent to redux to the point where you can actually plug in a reducer in xstate and have a state machine with createMachine. I did not mean to imply redux was the source of inspiration for state machines.

    Great catch!

  5. The way I see it, React has too many inconsistent ways to deal with state to begin with – hooks added even more new ways to deal with state, and still, new libraries and patterns keep cropping up. There are well over 50 state management libraries, any of which build around or on top of state management in React itself.

    In my view, this is all symptomatic of React having incomplete/inconsistent state management to begin with – something that does not seem to get fixed by inventing another pattern or yet another state management library; why else would there still be another idea cropping up every two weeks? It’s because it doesn’t work well, or doesn’t do what developers need or want.

    So why don’t they fix it? Well, they can’t, because ultimately that would be a breaking change – and probably a big one, since the problem is not just implementation details, but rather the fundamental concepts; if it were just implementation details, a library would have solved it by now.

    A fundamental design change would mean existing component libraries no longer work, at which point React is no longer really React, and the community would likely split into two. This is where programming languages and frameworks get stuck, time and again. They can adjust and adapt – but they can’t truly change.

    For an example of state management that actually works, see for example Sinuous – it has a single, consistent, very simple state mechanism that works equally well for local component state and global application state. Refactoring from one to the other literally is a matter of moving lines of code from one place to another. I’ve used it quite a bit, and never felt the need for any state management library or clever patterns – it just works.

    Just an example, but I don’t believe the problem is which library or pattern you choose. I believe the problem is inherent to React itself and the state concepts it embodies.

Leave a Reply