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:
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.
We have three main culprits that make our state hard to work with:
There are two common threads running through these problems, which are State Distance and State Scope.
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.
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.
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.
(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.
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.
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.
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:
inject in Vue, we can make state available to only a portion of our state tree
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.
Conversely, state in a component at the very bottom of the component tree can’t be passed anywhere.
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.
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.
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.
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.
I introduced two new concepts here that will help you when you’re thinking about where state should live in your application:
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).