Effective State Management

How do we structure the state in our applications more effectively?

It’s a question I’ve asked myself a lot over the years, because state is so central to app development and can be so difficult to wrangle.

My main suggestion is this:

Push state as far down the component tree as you can — but no further.

But before we can get to why that is, we have to cover a couple things first. I want to introduce two ideas to you:

  1. State Distance
  2. State Scope

These ideas underpin a whole lot of the conversation around state, and they’re helpful when figuring out how to better structure our state.

They’ve helped me clarify my own vague intuitions about what’s good vs. bad, beyond just “knowing it when I see it” — and I think they’ll help you, too.

The costs of state

We have three main culprits that make our state hard to work with:

  1. Prop drilling — passing props through layers and layers of components increases coupling and makes our code more complex. The further we have to move state before it’s used, the more difficult our code is to understand.

/articles/effective-state-management/prop-drilling.png

  1. Event frothing — this is like prop drilling but for events. Bubbling events up through many layers requires you to remember too many things and increases complexity.

/articles/effective-state-management/event-frothing.png

  1. Global state — since global state can be used anywhere, changing it becomes more challenging. Debugging becomes infuriating, since the problem code can be anywhere.

/articles/effective-state-management/global-state.png

There are two common threads running through these problems, which are State Distance and State Scope.

State Distance

Let’s try to put a more concrete number on this vague intuition that we have for “bad state”.

We’ll define something called State Distance, which is like algorithmic complexity, but for state. It isn’t a precise number that can be calculated to several decimals, but rather, something to give us a better sense for how complicated and gnarly our state is.

State Distance is how far a piece of state has to travel through your application before it’s finally used.

/articles/effective-state-management/distance.png

Local state that’s defined and used in one component and never leaves it is the simplest kind of state.

So let’s give local state a state distance of 0.

/articles/effective-state-management/local-state.png

If it’s defined and used only within a single component, we don’t incur any of the costs of prop drilling, event frothing, or the challenges of global state.

Slightly more complicated is when we pass a prop to another component. So we’ll add 1 for every time we pass it to another component.

If we pass a value down to a grandchild component, the distance is 2.

/articles/effective-state-management/state-distance.png

(You could make the argument that this number should increase exponentially, but let’s keep things simple for now, okay?)

Now the cost of state, defined as state distance, matches our intuition.

Small demo apps never have issues with state because the state distance never gets that large. But as an app grows, the cost of state naturally grows with it.

Keeping State Distance Small

To keep our cost of state low, we need to minimize the state distance in our application.

This means that, ideally, all of our state is local state. We’ll only pass state around if we absolutely need to, and we’ll try and limit how far we need to pass it when we do.

And if we do that, we’ll naturally avoid prop drilling and its inverse — event frothing.

State Scope

If we were to blindly focus on getting our State Distance as small as possible, we’d just chuck everything into global state. But this seems very wrong, so we’re clearly missing a piece of the puzzle.

This leads us to State Scope — the second piece we need to understand the cost of state.

The scope of a piece of state is defined as all of the components that can potentially use that piece of state.

This addresses the specific issue that we have with global state — that anything can access it. We make it concrete by turning it into a specific number.

Again, the simplest scope is when only a single component can access the state. The most difficult is when all components can access it.

The bigger this number, the harder it is to reason about how this state affects our application. The smaller the number, the more certainty we have when making changes involving the piece of state.

This number also matches up to our intuitions about dealing with complex state.

Illustrating State Scope

State Scope doesn’t really have defined “levels”, as it’s more of a continuum. But it’s helpful to see some more specific examples:

  • Global scope — any component in our component tree can access this state

/articles/effective-state-management/globalstate.png

  • Sub-tree scope — using provide and inject in Vue, we can make state available to only a portion of our state tree

/articles/effective-state-management/subtreescope.png

  • Component scope — we can provide state to component and all of its instances, so it behaves like a [static field or method on a class](..png](./local2.png)
  • Local scope (aka component instance scope)** — this is state that’s available to only a single instance of a component.
  • Slot scope / lexical scope — within templates we get an even smaller scope created by scoped slots. This behaves like a closure. We also have lexical scope within the logic of our components.

Optimizing Distance and Scope

How do we achieve both of these goals — reducing state distance as well as reducing state scope?

We have to remember one of the keys of state management:

State flows down the component hierarchy.

This means that components can only access state from other components higher in the component tree. State only flows down, not up.

We know this intuitively, since we often have to lift state up the component tree in order to use it somewhere else. We have to give that state a broader scope so it can be used more widely.

Making a piece of state global is equivalent to putting it in the root component, since it becomes available to all components in the component tree, since we can pass that state down to anywhere in our application.

/articles/effective-state-management/global-equiv.png

Conversely, state in a component at the very bottom of the component tree can’t be passed anywhere.

/articles/effective-state-management/leafnode.png

This leads us to an important conclusion — we want to push state as far down the tree as possible.

For example, if we have two sibling components that need to access name, the lowest spot on the tree is their common parent component.

/articles/effective-state-management/pushdown.png

This isn’t a new idea, nor is it a controversial one. But now we can see exactly why it’s such a useful and widely used tool.

Event Distance and Scope

I’ve mostly ignored events until now, so here’s how they fit in.

Event Distance works exactly the same as State Distance — the more directly we deal with events, the better, reducing the amount of event frothing.

However, the idea of Event Scope doesn’t really make sense.

Because events only flow up the tree, they only have a single path from their source to their destination. There is no branching of paths like we get when we go down a tree.

/articles/effective-state-management/event-distance.png

This means that event distance is all we need to describe the cost of events, since event distance is equivalent to event scope.

Further, most of the time we can simply ignore event distance, since events typically mirror the state they change. If we optimize our state well, we’ll likely get optimized events for free.

Wrapping Up

I introduced two new concepts here that will help you when you’re thinking about where state should live in your application:

  • State Distance — how far a piece of state travels before being used
  • State Scope — the number of possible components a piece of state could be used in

To improve our state, we need to minimize this equation:

cost = stateDistance + stateScope

And it turns out that there’s a simple way to make this optimization — pushing state as far down the component tree as we can (and no further).