DEV Community

Cover image for The modern guide to React state patterns
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

The modern guide to React state patterns

Written by Fredrik Strand Oseberg ✏️

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>
}
Enter fullscreen mode Exit fullscreen mode

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.

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"}]
}
Enter fullscreen mode Exit fullscreen mode

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" } }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 
    }
}
Enter fullscreen mode Exit fullscreen mode

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

const apiContext = createContext({})

export default apiContext;
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
)
Enter fullscreen mode Exit fullscreen mode

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/>      
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 difficult 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.

The LogRocket Dashboard

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 — start monitoring for free.

Top comments (0)