Skip to content

State management in a component-based world

Managing state in frontend application is hard. From local component state with performance issues to global state management with its own trade offs, there are many different ways for handling state. But is global state management really the right way to share state between components?

Published
Dec 10, 2022
Updated
Dec 10, 2022
Reading time
11 min read

Kinds of states

There are different kinds of states that we need to handle in frontend apps:

Component State (local): State that lives in a component and is bound to its lifecycle - it only exists as long as the component exists. It may be passed to child components via props. React provides the useState() and useReducer() hooks for managing component state.

Shared State (global): State that is global and shared between all components. React itself doesn't provide a solution for global state, but there are many libraries like React Redux, Zustand and Recoil.

Remote State (global): State that is fetched from a backend. We often want to cache this state and reuse it across all components, for better performance and to avoid duplicate requests. A popular library for managing remote state is TanStack Query (aka React Query).

Router State: Router state lives in the URL (e.g. as query params) or in the browser history. This state is managed by a routing library, like React Router or TanStack Router.

In this article we'll focus on component state and shared state.

Handling state

Let's take the journey of how we manage state in React.

Local component state

We need some state in our component. We start simple and use the useState() hook of React, a hook we all know and that works pretty well.

As we add new features, our component grows and looks overloaded. We start refactoring the component by moving elements into child components. But they still need access to our local component state, so we pass the state and some callback functions to update the state as props. Everything is still simple and works.

After adding more and more features, we now have a very large component tree and some components, deeply nested in the tree, need access to our state. We still pass it as props, but it doesn't feel good. We pass the props over so many levels and some components don't even need the state, they just pass it through. Adding a new property or updating an existing one is a nightmare as we need to change too many components. We need to refactor our approach.

Context

React provides the context API that looks like what we need:

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

Instead of passing the props down over multiple levels, we just add a context provider in our main component and let the child components use the context. The props-drilling problem is gone and extending our state is now much easier.

It worked well for a while, but after adding an input somewhere deeply down in the component tree, that updates the state on every onChange event, we experience a problem. Whenever we enter something into the input, the UI stops responding to interactions. Probably we should just update the state less often. Let's add a debounce hook that debounces the state updates of our input - problem solved.

Our component tree keeps growing and another performance problem occurs. After checking the profiler tab of React DevTools, you realize that the problem is related to some very slow components in the tree, that are re-rendered on every state update - and they don't even use the state context. That's because we call setState() on our main component, which re-renders the whole tree. Fortunately, React offers the React.memo() function to memoize a component. We wrap the problematic components into the that function and our performance issues are gone. Great!

Just a few days later, another problem pops up. Some components that access the state via context API, but only need one piece of the state, are re-rendered whenever the state changes. Even if the piece that they use hasn't changed. We need a way to avoid unnecessary re-renders. React.memo() doesn't help - every component that uses a context will be re-rendered when the context value changes. What if we access the context in a parent component, pass the piece of state via props and wrap the component in React.memo()? That would work, but we just start reintroducing the props-drilling problem that we solved by using the context API. Or probably we should split the state into different slices, to re-render fewer components when only some of them change and also use multiple contexts (and context providers). Sounds like we are on the way to the React Context hell. Maybe we can just wait a few more years until React supports context selectors.

We finally need a solution that works. It's time so switch to global state.

Global state

With a global state library like Redux we can solve all of our previous problems: the state lives outside the component. Updating the state doesn't re-render the main component. Child components can use hooks and selectors to only access a piece of the state, eliminating unnecessary re-renders. Our application has now a much better performance. And thanks to Redux Toolkit we don't even need all that boilerplate code that was necessary years ago.

Our UI is now much faster and we are more motivated than ever before to implement new features, so we add a lot of code. We don't experience any performance problems when using the app, but the first page load is very slow. It takes very long until our app renders the first elements on the screen. We check the network panel in the DevTools and see that our JS bundle is huge. Most of that code isn't really needed on the first page, so we should just lazy load code that we don't need. Thanks to dynamic imports and React.lazy that isn't that hard. But wait, what about our global store? We don't need most of the reducers, actions and selectors initially and should lazy load them too. I guess there is an easy way to do this in a type-safe way (of course we use Typescript, its 2022). I don't know how this works but we just let GitHub Copilot do this for us... or maybe there is a ready to use example in the Redux Toolkit docs... but I'm sure that Google can help us... (if you want to know how the story ends: try to find an easy solution and let me know if you've found one)

After the app startup performance problem is fixed by using lazy loading, we now focus on delivering valuable features. Customers often ask for a customizable dashboard that let them add many customizable widgets on the page. And also a tabbed-based interface is a highly requested feature, that let the user open multiple forms at the same time. Or think of any other feature with the same core requirement: we need to render our main component twice on the same page at the same time. Shouldn't be a problem, right? I mean, that's what we build all day long: reusable pieces of UI elements, encapsulated into components. Let's just render them side-by-side and everything works f... oh shit - I totally forgot about the global store. As all of our state is global, every instance of our main component uses the same data, but that's not what we want, they should be independently. Our state shouldn't actually be global. The state should be coupled to the instance of the component... something we had initially, when we started with local component state.

Using global state for state management in a component-based world is often not what we need. What we need in many situations is state the lives in the component, but can be shared with child components in a performant and easy way.

And to bring the story to an end: You refactor your global store and wrap the existing state in a key-value object Record<string, State> to create a separate state for each component instance. The next day you quit your job because you don't want to maintain that code base anymore.

Shared component state

Let us go two steps backs to what we had when we started. We used local component state with useState(). For sharing state between multiple components we basically have two requirements that we need to solve.

First, we need a way to share the state with (deeply nested) child components. The context API is great for this job. We just need to make sure that the context value doesn't change - because that would re-render every component that uses some piece of the context - and child components need some kind of selectors, to only subscribe to some piece of the state.

Second, we need a way to store and update state in a way that doesn't re-render our main component.

Approach for shared component state

How could such a solution look like?

Let's not just talk about state but about stores. A store is simply state + actions + events. State is the data, actions change the state and events are a way to get notified whenever the state has changed.

Given that the store itself doesn't change, only the state inside the store changes, we can use this approach together with the context API. Updating the state inside the store doesn't re-render the component, resulting in a much better performance. Providing the store via context gives us a stable context value that doesn't change. Child components can use the provided store and events (and selectors) to get notified whenever the state changes.

Subscribing to a store could be done with simple callback functions or something more advanced like RxJS. Using RxJS would give as powerful operators for more advanced features.


How could an API for shared component state look like?

We first need to create a store, containing our initial state and actions. This could look like the example below. The state could be a primitive value or any complex object.

const counterStore = createStore(() => ({
  initial: 0,
  actions: {
    increment: (setState) => {
      setState((state) => state + 1);
    },
    decrement: (setState) => {
      setState((state) => state - 1);
    },
  },
}));

The main component creates a store instance and provides it to child components via context API. This could be wrapped in a ProvideStore component (or some hooks) that accepts the store.

function MainComponent() {
  return (
    <ProvideStore store={counterStore}>
      <ChildComponent />
    </ProvideStore>
  );
}

Child components can use a hook to access the current store instance, its state and actions.

function ChildComponent() {
  const [state, actions] = useStore(counterStore);

  return (
    <div>
      <p>Counter: {state}</p>
      <button onClick={() => actions.increment()}>+1</button>
      <button onClick={() => actions.decrement()}>-1</button>
    </div>
  );
}

Components that don't need access to the state, but just want to trigger an action, can use a separate hook. This will not re-render the component when the state changes.

function ChildComponent() {
  const { increment } = useStoreActions(counterStore);

  return <button onClick={increment}>+1</button>;
}

This is just an idea and not an actual implementation. But as part of this article I've implemented a simple prototype. It is far away from a production-ready library, but it shows that it's possible to implement it.

Trade-offs

What are the real benefits of working with global state? And what trade-offs do we have to make when we work without global state?

Accessing state everywhere: Using something like Redux allows us to move a component around in the component tree, while it still can access the same state. Without global state we loose that ability, unless we move our component state higher up in the tree (which is not always a good idea). I think it depends on what state we are talking about. There is definitely complex state that is used across the whole application (which may be a good candidate for global state) but often you may not need state everywhere.

Accessing state outside of React: This is not possible with Redux but with other libraries like Zustand, which keep their state independently of React. But do we really need or want this? Maybe.

Separating state & components: I don't think that we need to separate storing state from the components, but we should definitely keep logic (like reducers in Redux) outside of components to keep components short and easy to understand. That's definitely possible with shared component state too.

Persisting state: In some cases we may want to keep state in memory even if a component gets destroyed, which automatically works when using global state. Using component state we consciously decide against this behavior, but we could store the state in sessionStorage / localStorage if we want to restore it after a page refresh.

DevTools: Redux provides Redux DevTools for inspecting state and state changes. Other state libraries often support these DevTools too. For component state this is a bit more complex to do, as the state may exist multiple times, but it would still be possible.

Summary

Global state management definitely has its use cases and there are some great libraries for working with global state. But I think that people reach out for global state too often, for uses cases that don't actually require global state.