How to choose the best state management solution in React

How to choose the best state management solution in React

Iva Kop

· 14 min read

State management is a fundamental challenge every developer needs to face when building a React app — and it is not a trivial one. There are many valid ways to manage state in React, and each one solves a salient set of problems.

As developers, it is important not only to be aware of the different approaches, tools, and patterns, but to also understand their use cases and trade-offs.

A helpful way to think about state management is in terms of the problems we solve in our projects. So, I will use a simple counter app to illustrate some relevant ways to think about state in React.

Local component state in React

The simplest way to implement the counter is to use local component state with the useState hook.

import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  const increaseCount = () => {
    setCount(count + 1);
  };

  const decreaseCount = () => {
    if (count > 0) {
      setCount(count - 1);
    }
  };
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={decreaseCount}>-</button>
      <button onClick={increaseCount}>+</button>
    </div>
  );
};

export default Counter;

So we are done, right? Article over? Not quite.

If this was a real project, it is likely that in the future, we would need more buttons and headers elsewhere in our app. And it is a good idea to make sure they all look and behave consistently, which is why we should probably turn them into reusable React components.

Component props in React

Turning our Button and Header into separate components reveals a new challenge. We need some way to communicate between them and the main Counter component.

This is where component props come into play. For our Header component, we add a text prop. For our Button, we need both a label prop and an onClick callback. Our code now looks like this:

import { useState } from 'react';

const Header = ({ text }) => <h1>{text}</h1>;

const Button = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

const Counter = () => {
  const [count, setCount] = useState(0);

  const increaseCount = () => {
    setCount(count + 1);
  };

  const decreaseCount = () => {
    if (count > 0) {
      setCount(count - 1);
    }
  };
  return (
    <div>
      <Header text={count} />
      <Button onClick={decreaseCount} label="-" />
      <Button onClick={increaseCount} label="+" />
    </div>
  );
};

export default Counter;

This looks great! But imagine the following scenario: what if we need to only display the count on our home route and have a separate route /controls where we display both the count and the control buttons? How should we go about this?

Routing in React

Given that we are building a single page application, there is now a second piece of state we need to handle — the route we are on. Let’s see how this can be done with React Router, for example.

import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom';
import { useState } from 'react';

const Header = ({ text }) => <h1>{text}</h1>;

const Button = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

const Home = ({ count }) => {
  return <Header text={count} />;
};

const Controls = ({ count, decreaseCount, increaseCount }) => {
  return (
    <>
      <Header text={count} />
      <Button onClick={decreaseCount} label="-" />
      <Button onClick={increaseCount} label="+" />
    </>
  );
};

const App = () => {
  const [count, setCount] = useState(0);
  const increaseCount = () => {
    setCount(count + 1);
  };
  const decreaseCount = () => {
    if (count > 0) {
      setCount(count - 1);
    }
  };

  return (
    <Router>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/controls">Controls</Link>
      </nav>
      <Switch>
        <Route path="/controls">
          <Controls
            increaseCount={increaseCount}
            decreaseCount={decreaseCount}
            count={count}
          />
        </Route>
        <Route path="/">
          <Home count={count} />
        </Route>
      </Switch>
    </Router>
  );
};

export default App;

Nice! We now have our separate routes and everything works as expected. However, you may notice a problem. We are keeping our count state in App and using props to pass it down the component tree. But it appears that we pass down the same prop over and over again until we reach the component that needs to use it. Of course, as our app grows, it will only get worse. This is known as prop drilling.

Let’s fix it!

Using Context + useReducer

Wouldn’t it be great if there is a way for our components to access the count state without having to receive it via a props? A combination of React Context and the useReducer hook does just that:

import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom';
import { createContext, useContext, useReducer } from 'react';

const initialState = 0;

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1 >= 0 ? state - 1 : 0;
    default:
      return state;
  }
};

const CountContext = createContext(null);

const useCount = () => {
  const value = useContext(CountContext);
  if (value === null) throw new Error('CountProvider missing');
  return value;
};

const CountProvider = ({ children }) => (
  <CountContext.Provider value={useReducer(reducer, initialState)}>
    {children}
  </CountContext.Provider>
);

const Header = ({ text }) => <h1>{text}</h1>;

const Button = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

const Home = () => {
  const [state] = useCount();
  return <Header text={state} />;
};

const Controls = () => {
  const [state, dispatch] = useCount();
  return (
    <>
      <Header text={state} />
      <Button onClick={() => dispatch({ type: 'DECREMENT' })} label="-" />
      <Button onClick={() => dispatch({ type: 'INCREMENT' })} label="+" />
    </>
  );
};

const App = () => {
  return (
    <CountProvider>
      <Router>
        <nav>
          <Link to="/">Home</Link>
          <Link to="/controls">Controls</Link>
        </nav>
        <Switch>
          <Route path="/controls">
            <Controls />
          </Route>
          <Route path="/">
            <Home />
          </Route>
        </Switch>
      </Router>
    </CountProvider>
  );
};

export default App;

Awesome! We have solved the problem of prop drilling. We get additional points for having made our code more declarative by creating a descriptive reducer.

We are happy with our implementation, and, for many use cases, it is really all we need. But wouldn’t it be great to have a log of the application state? And what if we could also persist this state? What about crash reports?

Let's explore Redux!

Using Redux for state management

We can do all of the above and much more by using Redux to manage the state of our app. The tool has a strong community behind it and a rich ecosystem that can be leveraged with ease.

Let’s set up our counter with Redux Toolkit.

import { configureStore, createSlice } from '@reduxjs/toolkit';
import { useSelector, useDispatch, Provider } from 'react-redux';

const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      if (state.value > 0) {
        state.value -= 1;
      }
    },
  },
});

const store = configureStore({
  reducer: { counter: counterSlice.reducer },
});

const { increment, decrement } = counterSlice.actions;

const Header = ({ text }) => <h1>{text}</h1>;

const Button = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

const Home = () => {
  const count = useSelector((state) => state.counter.value);
  return <Header text={count} />;
};

const Controls = () => {
  const count = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();
  return (
    <>
      <Header text={count} />
      <Button onClick={() => dispatch(decrement())} label="-" />
      <Button onClick={() => dispatch(increment())} label="+" />
    </>
  );
};

const App = () => {
  return (
    <Provider store={store}>
      <Router>
        <nav>
          <Link to="/">Home</Link>
          <Link to="/controls">Controls</Link>
        </nav>
        <Switch>
          <Route path="/controls">
            <Controls />
          </Route>
          <Route path="/">
            <Home />
          </Route>
        </Switch>
      </Router>
    </Provider>
  );
};
export default App;

This looks really neat! Our state is now stored in the global Redux store and managed with pure functions (Redux Toolkit uses Immer under the hood to guarantee immutability). We can already take advantage of the awesome Redux dev tools.

But what about things like handling side-effects, or making the state persistent, or implementing logging and/or crash reporting? This is where the Redux ecosystem we mentioned earlier comes into play.

There are multiple options to handle side-effects, including redux-thunk and redux-saga. Libraries like redux-persist are great for saving the data from the redux store in local or session storage to make it persistent.

In short, Redux is great! It’s used widely in the React world and for a good reason.

But what if we prefer a more decentralised approach to state management? Maybe we are worried about performance or have frequent data updates in different branches of the React tree, so we want to avoid unnecessary re-renders while keeping everything in sync.

Or, maybe we need a good way to derive data from our state and compute if efficiently and robustly on the client. And what if we want to achieve all of this without sacrificing the ability to have app-wide state observation? Enter Recoil.

Atomic state with Recoil

It’s a bit of a stretch to suggest that we are able to hit the limits of React Context or Redux with a simple Counter app. For a better atomic state management use case, check out Dave McCabe’s awesome video on Recoil.

Nevertheless, thinking of state in terms of atoms does help expand our vocabulary of what state management could look like. Also, the Recoil API is fun to play with, so let’s reimplement our counter with it.

import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom';
import { atom, useRecoilState, RecoilRoot } from 'recoil';

const countState = atom({
  key: 'count',
  default: 0,
});

const Header = ({ text }) => <h1>{text}</h1>;

const Button = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

const Home = () => {
  const [count] = useRecoilState(countState);
  return <Header text={count} />;
};

const Controls = () => {
  const [count, setCount] = useRecoilState(countState);
  const increaseCount = () => {
    setCount(count + 1);
  };
  const decreaseCount = () => {
    if (count > 0) {
      setCount(count - 1);
    }
  };
  return (
    <>
      <Header text={count} />
      <Button onClick={decreaseCount} label="-" />
      <Button onClick={increaseCount} label="+" />
    </>
  );
};

const App = () => {
  return (
    <RecoilRoot>
      <Router>
        <div className="App">
          <nav>
            <Link to="/">Home</Link>
            <Link to="/controls">Controls</Link>
          </nav>
          <Switch>
            <Route path="/controls">
              <Controls />
            </Route>
            <Route path="/">
              <Home />
            </Route>
          </Switch>
        </div>
      </Router>
    </RecoilRoot>
  );
};

export default App;

Using Recoil feels very much like using React itself. A peek back at our initial examples reveals how similar the two are. Recoil also has its very own set of dev tools. An important consideration to keep in mind is that this library is still experimental and subject to change. Use it with caution.

Okay, we can have a Recoil counter. But state management preferences depend on our priorities. What if the app is built by a team and it is really important that the developer, the designer, the project manager, and everyone else speak the same language when it comes to user interfaces?

What if, in addition, this language could be directly expressed with highly declarative code in our app? And what if we could guarantee that we never reach impossible states, thereby eliminating a whole class of bugs? Guess what? We can.

State machines with xState

All of the above can be achieved with the help of state charts and state machines. State charts help visualise all the possible states of our app and define what is possible. They are easy to understand, share, and discuss within the entire team.

Here is our counter as a state chart:

Although this is a trivial implementation, we can already see one cool advantage of using state machines. Initially, it is not possible to decrement the counter, as its initial value is 0. This logic is declared right in our state machine and visible on the chart, where with other approaches we explored, it was harder, generally speaking, to find the right place for it.

Here is our state machine in practice:

import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom';
import { useMachine } from '@xstate/react';
import { createMachine, assign } from 'xstate';

export const counterMachine = createMachine({
  initial: 'active',
  context: { count: 0 },
  states: {
    active: {
      on: {
        INCREMENT: {
          actions: assign({ count: (ctx) => ctx.count + 1 }),
        },
        DECREMENT: {
          cond: (ctx) => ctx.count > 0,
          actions: assign({
            count: (ctx) => ctx.count - 1,
          }),
        },
      },
    },
  },
});

const Header = ({ text }) => <h1>{text}</h1>;

const Button = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

const Home = () => {
  const [state] = useMachine(counterMachine);
  return <Header text={state.context.count} />;
};

const Controls = () => {
  const [state, send] = useMachine(counterMachine);
  return (
    <>
      <Header text={state.context.count} />
      <Button onClick={() => send('DECREMENT')} label="-" />
      <Button onClick={() => send('INCREMENT')} label="+" />
    </>
  );
};

const App = () => {
  return (
    <Router>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/controls">Controls</Link>
      </nav>
      <Switch>
        <Route path="/controls">
          <Controls />
        </Route>
        <Route path="/">
          <Home />
        </Route>
      </Switch>
    </Router>
  );
};

export default App;

Wow, this is really great! However, we are only barely scratching the surface of state machines here. To find out more about them, check out the docs for xState.

Alright, last scenario! What happens if our simple frontend counter app has a backend? What is we need to communicate with a server in order to get or modify the count? What if, in addition, we want to handle data-fetching related challenges like asynchronicity, loading states, caching, and refetching?

Data-fetching with React Query

The final React state management tool I want to highlight is React Query. It is specifically designed to make data-fetching easy and to solve the problems outlined above (and more). Let’s see it in action.

import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom';
import { ReactQueryDevtools } from 'react-query/devtools';
import axios from 'axios';
import {
  useQuery,
  useMutation,
  QueryClient,
  QueryClientProvider,
} from 'react-query';

const useCount = () => {
  return useQuery('count', async () => {
    const { data } = await axios.get('https://our-counter-api.com/count');
    return data;
  });
};

const useIncreaseCount = () => {
  return useMutation(() =>
    axios.post('https://our-counter-api.com/increase', {
      onSuccess: () => {
        queryClient.invalidateQueries('count');
      },
    })
  );
};

const useDecreaseCount = () => {
  return useMutation(
    () => axios.post('https://our-counter-api.com/descrease'),
    {
      onSuccess: () => {
        queryClient.invalidateQueries('count');
      },
    }
  );
};
const Header = ({ text }) => <h1>{text}</h1>;

const Button = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

const Home = () => {
  const { status, data, error } = useCount();
  return status === 'loading' ? (
    'Loading...'
  ) : status === 'error' ? (
    <span>Error: {error.message}</span>
  ) : (
    <Header text={data} />
  );
};

const Controls = () => {
  const { status, data, error } = useCount();
  const increaseCount = useIncreaseCount();
  const decreaseCount = useDecreaseCount();

  return status === 'loading' ? (
    'Loading...'
  ) : status === 'error' ? (
    <span>Error: {error.message}</span>
  ) : (
    <>
      <Header text={data} />
      <Button onClick={() => decreaseCount.mutate()} label="-" />
      <Button onClick={() => increaseCount.mutate()} label="+" />
    </>
  );
};
const queryClient = new QueryClient();

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <Router>
        <ReactQueryDevtools />
        <nav>
          <Link to="/">Home</Link>
          <Link to="/controls">Controls</Link>
        </nav>
        <Switch>
          <Route path="/controls">
            <Controls />
          </Route>
          <Route path="/">
            <Home />
          </Route>
        </Switch>
      </Router>
    </QueryClientProvider>
  );
};

export default App;

The above is a fairly naive implementation with plenty of room for improvement. What is important to note is the ease with which we can make server calls, cache them, and invalidate the cache when needed. In addition, with React Query the task of managing loading and error states in the component becomes much simpler.

It is a great tool that can be used with any backend. If you want to know how to set it up with GraphQL, check out my article about it.

Conclusion

State management in React is an extensive topic. The list of approaches, patterns, and libraries, discussed in this article is neither comprehensive nor definitive. The goal is rather to illustrate the thought process behind solving a specific problem in a particular way.

In the end, what state management comes down to us being aware of the different options, understanding their benefits and trade-offs, and ultimately, going with the solution that fits our use case the best.

Happy coding! ✨

Join my newsletter

Subscribe to get my latest content by email.

I will never send you spam. You can unsubscribe at any time.

MORE ARTICLES

© 2022 Iva Kop. All rights reserved

Privacy policyRSS