Towards the end of Human Redux, Jereteg takes all of the implementation details surrounding state management and essentially hides them behind an augmented Store object that exposes methods for reading from (selecting) and writing to (actions) the encapsulated state. What he ends up with is something that looks very much like the Facade pattern discussed by Thomas Burleson and the Sandbox pattern discussed by Brecht Billiet.
When looking at these three implementations - Jereteg's augmented Store, Burleson's Facade object, and Billiet's Sandbox - the common thread is the encapsulation of the very "notion" of state. Meaning, once these hard lines have been drawn between the public contract and the private implementation of these objects, subsequent choices like Redux and NgRx become nothing but a matter of preference. In fact, they become completely optional.
| || || |
| || |
| || || |
In this mental model, I'm using the term "Runtime" to refer to what the others have called Facades and Sandboxes. I don't love the term; but, I do like the fact that it entails a set of functionality that persists beyond the life-cycle of any given Component. In fact, in this mental model, Components come into existence, tap into a particular set of Runtimes, do some things, and then get destroyed, at which point they detach from the persistent Runtimes. This creates a hard separation between the View of the application and the Behavior of the application.
ASIDE: About a year ago, when I was also struggling with state management, fellow InVision engineer Adam DiCarlo described Redux middleware as the "Runtime" of the application. Since then, the term has stuck with me.
Notice that this mental model makes no mention of Redux or of NgRx. That's because, at this level of encapsulation, those details can't be known. Using Redux or NgRx is a decision to be made by an individual Runtime. In fact, part of the application could be using Redux behind the scenes; and, part of it could be using NgRx; and, still, another part of it could be using nothing but vanilla instance variables.
In this approach, each "Runtime" is somewhat akin to a "Microservice". It exposes end-points (via methods and properties); it emits events (via the message bus); and, it holey owns its own data persistence (the store). This absolute encapsulation of state prevents the underlying "store" (if there even is one) from becoming an "integration database". The creates strong public contracts and decouples one Runtime from another Runtime's implementation details.
As I've been noodling on all of this, I've been trying to come up with guiding principals to make things easier to reason about:
- There's no "single store" in the application. Each runtime has its own store - if it even has a store.
- All state access and mutation goes through a Runtime API since there is no other access point. This prevents the store from becoming an "integration database" and prevents coupling to internal implementation details.
- Since there is no single store, we never have to worry about giving a unique name to a particular "slice" of state.
- Long-term persistence of state is a Runtime-specific concern. Some Runtimes may want to persist to LocalStorage or IndexedDB. Other Runtimes, with sensitive or voluminous data, may not want to persist their data at all.
- Runtimes do not know about the Router. The Router is used to control which Components are being rendered. These components will then, in turn, interact with the appropriate Runtime.
- Runtimes should emit events on the message bus as state is mutated so that other Runtimes can synchronize their own internal state, if necessary.
- A Runtime's events become part of its public contact. As such, it should only emit events that it is willing to keep consistent. In other words, a Runtime shouldn't emit events that are too tightly-coupled to internal implementation details (which may change over time).
- Runtimes can be lazy-loaded into an application and shouldn't have to register with any centralized repository. This is because the life-cycle of a Runtime is not relevant to any other Runtime. With the exception of "core runtimes" that are, by definition, a point of high coupling in the application.
Well, that's what I have so far. Noodling on state management is a fun and often frustrating adventure. What I am going for with my mental model is not necessarily something that is fancy or elegant - it's just something that I hope is easy to reason about. By creating very strong boundaries of encapsulation around runtime state, it keeps each runtime easier to understand because it can only be affected by its own public API.
The reality is, none of this is all that different from Selectors and Action Creators and Middleware and State Trees. The differences really lie in how these set of constructs is organized and how the separation of concerns is defined. All I'm really doing is taking the prior art and drawing lines through it that my brain can comprehend.
All theory and no practice makes Jack a dull boy :) As such, I wanted to sit down and start to flesh out some actual code that adheres to the things I want to see in the Runtime Abstraction. I created a "Santa's list" demo:
... this allows people to be added to a Nice List and a Naughty List. There's a lot I like about this experiment. But, there's stuff I don't like. For example, I really don't like how everything is a Stream. This feels like it really pigeon-holes you into a particular approach, which is not something I want. I'll have to address that in my subsequent experiments.