Some Real-World Experimenting With The Runtime Abstraction For State Management In Angular 7.2.7
Ever since Facebook reintroduced the programming world to the concept of unidirectional data-flow (with their Flux pattern), I've been struggling to wrap my head around more effective state management in a single-page application (SPA) using Angular. I know that I dislike all of the ceremony involved in any pattern that requires me to create an "Actions" abstraction. But, I do like the idea of moving business logic concerns out of my Controllers / View-component. A few months ago, I started to narrow in on what I call the "Runtime Abstraction"; and, shortly thereafter, I created a tiny app that implemented a Runtime Abstraction. But, with my recent Proof-of-Concept for InVision Screen Flow, I was finally able to exercise the Runtime Abstraction in a non-trivial Angular 7.2.7 context. As such, I wanted to share my updated thoughts and practices.
Run this demo in my Screen Flow POC project on GitHub.
View this code in my Screen Flow POC project on GitHub.
To quickly recap what the Runtime Abstraction is, it's a Service that encapsulates the business logic and state-management for a given feature-set. And, as I've stated previously, it looks very much like the Facade pattern discussed by Thomas Burleson and the Sandbox pattern discussed by Brecht Billiet.
Essentially, the Runtime Abstraction makes the choice of state-management libraries a mere "implementation detail" that the "web application" doesn't need to know about. Using Redux? Who knows. Using NgRx? Can't say. Using Akita? No idea. All the web application knows about is that it tells the Runtime Abstraction to perform actions and then "listens" to the exposed state-streams in order to reactively update the web application's view-state.
To borrow an illustration from my previous post, the runtime abstraction looks like this:
ASIDE: Notice that the URL / Router is not part of "Runtime Abstraction". That's because the router is a concern of the "web application", not of the Runtime. It's up to the web application to parle changes in the Router into commands against the runtime.
With that said, let's look at how I implemented a Runtime Abstraction in the Screen Flow proof-of-concept for InVision. I don't want to go into all of the code, so I will limit it to the parts that are relevant to state management. First, let's look at the underlying "Store". My SimpleStore class is nothing more than an encapsulated RxJS BehaviorSubject with a method that can "patch" the BehaviorSubject's current value:
// Import the core angular services.
import { BehaviorSubject } from "rxjs";
import { distinctUntilChanged } from "rxjs/operators";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export class SimpleStore<StateType = any> {
private stateSubject: BehaviorSubject<StateType>;
// I initialize the simple store with the given initial state value.
constructor( initialState: StateType ) {
this.stateSubject = new BehaviorSubject( initialState );
}
// ---
// PUBLIC METHODS.
// ---
// I get the current state snapshot.
public getSnapshot() : StateType {
return( this.stateSubject.getValue() );
}
// I get the current state as a stream (will always emit the current state value as
// the first item in the stream).
public getState(): Observable<StateType> {
return( this.stateSubject.asObservable() );
}
// I return the given top-level state key as a stream (will always emit the current
// key value as the first item in the stream).
public select<K extends keyof StateType>( key: K ) : Observable<StateType[K]> {
var selectStream = this.stateSubject.pipe(
map(
( state: StateType ) => {
return( state[ key ] );
}
),
distinctUntilChanged()
);
return( selectStream );
}
// I move the store to a new state by merging the given partial state into the
// existing state (creating a new state object).
// --
// CAUTION: Partial<T> does not currently project against "undefined" values. This is
// a known type safety issue in TypeScript.
public setState( partialState: Partial<StateType> ) : void {
var currentState = this.getSnapshot();
var nextState = Object.assign( {}, currentState, partialState );
this.stateSubject.next( nextState );
}
}
As you can see, this does little more than provide a way to set state and then to listen for changes on any particular state property as an Observable Stream.
This SimpleStore class is then instantiated and used inside my ScreenFlowRuntime - the runtime abstraction for the "Screen Flow" feature of the application (which happens to be the only feature). The ScreenFlowRuntime completely hides the fact that we are using SimpleStore. And, in we wanted to, we could use any kind of state management internally to the runtime - it wouldn't matter.
The API of the ScreenFlowRuntime is a unified abstraction that coalesces the framework-specific concepts of "actions", "selectors", "side effects", and "mutations". In order to successfully hide the state-management approach (in order to create a clean separation of concerns), we can't let the specific implementation details leak outside of the Runtime. As such, we are using generic RxJS observables to expose state-changes over time.
To make the ScreenFlowRuntime class easier to read (and maintain), I've divided the public methods into two sections: Commands and Queries. The command methods are the methods that potentially mutate state and trigger asynchronous workflows. The query methods, on the other hand, are the methods that return RxJS observables that indicate state-changes over time:
// Import the core angular services.
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { map } from "rxjs/operators";
import { Observable } from "rxjs";
// Import the application components and services.
import { SimpleStore } from "./simple-store";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export interface FlowTree {
root: FlowTreeNode;
unreachable: FlowTreeNode[];
}
export interface FlowTreeHotspot {
height: number;
screenID: number;
targetScreenID: number;
width: number;
x: number;
y: number;
}
export interface FlowTreeIndex {
[ id: string ]: FlowTreeNode;
}
export interface FlowTreeNode {
hardLinkIDs: number[];
hotspots?: FlowTreeHotspot[];
id: number;
links: FlowTreeNode[];
softLinkIDs: number[];
screen: FlowTreeScreen;
}
export interface FlowTreeScreen {
clientFilename: string;
height: number;
id: number;
imageUrl: string;
name: string;
thumbnailUrl: string;
width: number;
}
export interface Project {
id: number;
name: string;
}
export type ProjectOrientation = "portrait" | "landscape";
// NOTE: Internal state interface is never needed outside of runtime.
interface ScreenFlowState {
project: Project | null;
projectOrientation: ProjectOrientation | null;
reachableScreenCount: number;
relatedScreenImages: string[];
screenSize: number;
selectedTreeNode: FlowTreeNode | null;
tree: FlowTree | null;
treeIndex: FlowTreeIndex | null;
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Injectable({
providedIn: "root"
})
export class ScreenFlowRuntime {
private httpClient: HttpClient;
private store: SimpleStore<ScreenFlowState>;
// I initialize the ScreenFlow runtime.
constructor( httpClient: HttpClient ) {
this.httpClient = httpClient;
// NOTE: For the store instance we are NOT USING DEPENDENCY-INJECTION. That's
// because the store isn't really a "behavior" that we would ever want to swap -
// it's just a slightly more complex data structure. In reality, it's just a
// fancy hash/object that can also emit values.
this.store = new SimpleStore({
project: null,
projectOrientation: null,
reachableScreenCount: this.deriveReachableScreenCount( null ),
relatedScreenImages: this.deriveRelatedScreenImages( null, null ),
screenSize: 1,
selectedTreeNode: null,
tree: null,
treeIndex: this.deriveTreeIndex( null )
});
}
// ---
// COMMAND METHODS.
// ---
// I load the given demo-data into the runtime. Returns a promise of success.
public async load( version: number ) : Promise<void> {
this.store.setState({
project: null,
projectOrientation: null,
reachableScreenCount: this.deriveReachableScreenCount( null ),
relatedScreenImages: this.deriveRelatedScreenImages( null, null ),
selectedTreeNode: null,
tree: null,
treeIndex: this.deriveTreeIndex( null )
});
// NOTE: Since this is just a proof-of-concept, having the HTTP call right here
// in the runtime is OK. However, in production, this logic should be moved into
// a more formal API Client (that would be called from this point).
var response = await this.httpClient
.get<any>( `./static/${ version }/data.json` )
.toPromise()
;
// If the store has already been populated while we were executing the async
// fetch, let's just return-out - another request has already loaded data into
// the runtime and taken over.
if ( this.store.getSnapshot().project ) {
return;
}
var treeIndex = this.deriveTreeIndex( response.tree );
var reachableScreenCount = this.deriveReachableScreenCount( response.tree );
var relatedScreenImages = this.deriveRelatedScreenImages( null, treeIndex );
this.store.setState({
project: response.project,
projectOrientation: response.projectOrientation,
reachableScreenCount: reachableScreenCount,
relatedScreenImages: relatedScreenImages,
selectedTreeNode: null,
tree: response.tree,
treeIndex: treeIndex
});
}
// I select the tree node that is targeted by the given hotspot. Returns a boolean
// indicating whether or not the selection was successful.
public selectHotspot( hotspot: FlowTreeHotspot ) : boolean {
return( this.selectScreenID( hotspot.targetScreenID ) );
}
// I select the tree node with the given ID. Returns a boolean indicating whether or
// not the selection was successful.
public selectScreenID( screenID: number ) : boolean {
var treeIndex = this.store.getSnapshot().treeIndex;
if ( ! treeIndex ) {
return( false );
}
var treeNode = treeIndex[ screenID ];
if ( ! treeNode ) {
return( false );
}
this.store.setState({
relatedScreenImages: this.deriveRelatedScreenImages( treeNode, treeIndex ),
selectedTreeNode: treeNode
});
return( true );
}
// I select the given tree node. Returns a boolean indicating whether or not the
// selection was successful.
public selectTreeNode( treeNode: FlowTreeNode ) : boolean {
return( this.selectScreenID( treeNode.id ) );
}
// I unselect the currently-selected tree node.
public unselectTreeNode() : void {
var selectedTreeNode = this.store.getSnapshot().selectedTreeNode;
if ( ! selectedTreeNode ) {
return;
}
var treeIndex = this.store.getSnapshot().treeIndex;
this.store.setState({
relatedScreenImages: this.deriveRelatedScreenImages( null, treeIndex ),
selectedTreeNode: null
});
}
// I increase the zoom of the flow-tree.
public zoomIn() : void {
var screenSize = this.store.getSnapshot().screenSize;
if ( screenSize < 5 ) {
this.store.setState({
screenSize: ( screenSize + 1 )
});
}
}
// I decrease the zoom of the flow-tree.
public zoomOut() : void {
var screenSize = this.store.getSnapshot().screenSize;
if ( screenSize > 1 ) {
this.store.setState({
screenSize: ( screenSize - 1 )
});
}
}
// ---
// QUERY METHODS.
// ---
// I return a stream for the project. May emit NULL.
public getProject() : Observable<Project | null> {
return( this.store.select( "project" ) );
}
// I return a stream for the project orientation. May emit null.
public getProjectOrientation() : Observable<ProjectOrientation | null> {
return( this.store.select( "projectOrientation" ) );
}
// I return a stream for the number of reachable screens in the flow.
public getReachableScreenCount() : Observable<number> {
return( this.store.select( "reachableScreenCount" ) );
}
// I return a stream for the array of image URLs that can be linked-to from the
// currently-selected screen in the flow.
public getRelatedScreenImages() : Observable<string[]> {
return( this.store.select( "relatedScreenImages" ) );
}
// I return a stream for the screen size.
public getScreenSize() : Observable<number> {
return( this.store.select( "screenSize" ) );
}
// I return a stream for the selected tree node. May emit null.
public getSelectedTreeNode(): Observable<FlowTreeNode | null> {
return( this.store.select( "selectedTreeNode" ) );
}
// I return a stream for the tree. May emit null.
public getTree() : Observable<FlowTree | null> {
return( this.store.select( "tree" ) );
}
// ---
// PRIVATE METHODS.
// ---
// I return the number of screens that are reachable in the given tree.
private deriveReachableScreenCount( tree: FlowTree | null ) : number {
// If we have no tree yet, no screens can be reached (obviously).
if ( ! tree ) {
return( 0 );
}
return( walkTreeNodes( tree.root ) );
// -- Hoisted functions.
function walkTreeNodes( node: FlowTreeNode ) : number {
var count = 1;
for ( var linkedNode of node.links ) {
count += walkTreeNodes( linkedNode );
}
return( count );
}
}
// I return an array of image URLs that can be linked-to from the currently-selected
// screen in the flow.
private deriveRelatedScreenImages(
selectedTreeNode: FlowTreeNode | null,
treeIndex: FlowTreeIndex | null
) : string[] {
var imageUrls: string[] = [];
var targetNode;
if ( selectedTreeNode && selectedTreeNode.hotspots && treeIndex ) {
for ( var hotspot of selectedTreeNode.hotspots ) {
if ( targetNode = treeIndex[ hotspot.targetScreenID ] ) {
imageUrls.push( targetNode.screen.imageUrl );
}
}
}
return( imageUrls );
}
// I return an index of the tree that maps IDs to tree nodes.
private deriveTreeIndex( tree: FlowTree | null ) : FlowTreeIndex | null {
// If we have no tree yet, we can't build an index.
if ( ! tree ) {
return( null );
}
var index: FlowTreeIndex = Object.create( null );
var nodesToVisit = [ tree.root ];
while ( nodesToVisit.length ) {
var node = nodesToVisit.shift() !; // Asserting non-null.
index[ node.id ] = node;
nodesToVisit.push( ...node.links );
}
return( index );
}
}
What you can see is that each "command" method boils down, essentially, to one-or-more calls to "this.store.setState()". The "store" is our runtime's instance of SimpleStore, and is little more than a BehaviorSubject whose value we can patch. The "query" methods then return RxJS Observable streams that pluck properties out of this BehaviorSubject.
A command method may perform a synchronous action; or, it may perform an asynchronous action. If it performs an asynchronous action (such a the load() method), it returns a Promise to help the calling context understand the timing and deal with potential workflow errors.
Overall, I find this runtime abstraction fairly easy to reason about. It's all "right there". No going off into other files to see what something is doing. No need to create payload interfaces that define actions - I'm just using methods and method arguments.
And, now that we see the Runtime Abstraction, let's look at one of the View-Components that is consuming it. In the following view, assume that the "tree" data has already been loaded by the application. As such, we won't deal with any asynchronous commands.
The bulk of the logic for this View-Component is in the ngOnInit() life-cycle method, where we subscribe to the various streams in the application - in this case, the ScreenFlowRuntime and Router streams:
// Import the core angular services.
import { ActivatedRoute } from "@angular/router";
import { Component } from "@angular/core";
import { Observable } from "rxjs";
import { Router } from "@angular/router";
import { Subscription } from "rxjs";
// Import the application components and services.
import { FlowTree } from "~/app/shared/services/screen-flow.runtime";
import { FlowTreeHotspot } from "~/app/shared/services/screen-flow.runtime";
import { FlowTreeNode } from "~/app/shared/services/screen-flow.runtime";
import { Project } from "~/app/shared/services/screen-flow.runtime";
import { ProjectOrientation } from "~/app/shared/services/screen-flow.runtime";
import { ScreenFlowRuntime } from "~/app/shared/services/screen-flow.runtime";
import { ScreenPreloaderService } from "~/app/shared/services/screen-preloader.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "flow-view",
styleUrls: [ "./flow-view.component.less" ],
templateUrl: "./flow-view.component.htm"
})
export class FlowViewComponent {
public isProtectingDrag: boolean;
public project: Project | null;
public projectOrientation: ProjectOrientation | null;
public reachableScreenCount: number;
public screenSize: number;
public selectedTreeNode: FlowTreeNode | null;
public tree: FlowTree | null;
private activatedRoute: ActivatedRoute;
private router: Router;
private screenFlowRuntime: ScreenFlowRuntime;
private screenPreloaderService: ScreenPreloaderService;
private subscriptions: Subscription[];
// I initialize the flow-view component.
constructor(
activeatedRoute: ActivatedRoute,
router: Router,
screenFlowRuntime: ScreenFlowRuntime,
screenPreloaderService: ScreenPreloaderService
) {
this.activatedRoute = activeatedRoute;
this.router = router;
this.screenFlowRuntime = screenFlowRuntime;
this.screenPreloaderService = screenPreloaderService;
this.isProtectingDrag = false;
this.project = null;
this.projectOrientation = null;
this.reachableScreenCount = 0;
this.screenSize = 1;
this.selectedTreeNode = null;
this.subscriptions = [];
this.tree = null;
}
// ---
// PUBLIC METHODS.
// ---
// I handle the selection of the given tree node.
public handleSelect( treeNode: FlowTreeNode ) : void {
// The selection process is really more of a "toggle" process that drives URL
// changes. If the given tree node is the CURRENTLY SELECTED one, then we want to
// navigate away from any selection (implicitly turning off selection).
if ( treeNode === this.selectedTreeNode ) {
this.navigateAwayFromScreen();
// If the given tree node is NOT the currently selected one, then we want to
// navigate to the given node (implicitly selecting it).
} else {
this.navigateToScreen( treeNode.id );
}
}
// I handle the selection of the given hotspot.
public handleSelectHotspot( hotspot: FlowTreeHotspot ) : void {
this.navigateToScreen( hotspot.targetScreenID );
}
// I get called once when the component is being destroyed.
public ngOnDestroy() : void {
for ( var subscription of this.subscriptions ) {
subscription.unsubscribe();
}
}
// I get called once when the component is being created.
public ngOnInit() : void {
this.subscriptions.push(
this.activatedRoute.params.subscribe(
( params ) => {
// If there is no ID in the route, then unselect any currently-
// selected screen.
if ( ! params.screenID ) {
this.screenFlowRuntime.unselectTreeNode();
return;
}
var success = this.screenFlowRuntime.selectScreenID( +params.screenID );
// At this point, it's possible that the route param didn't actually
// lead to a tree node selection (if the ID is invalid). If so, let's
// navigate away from any selection.
if ( ! success ) {
this.navigateAwayFromScreen();
}
}
),
this.screenFlowRuntime.getProject().subscribe(
( project ) => {
this.project = project;
}
),
this.screenFlowRuntime.getProjectOrientation().subscribe(
( projectOrientation ) => {
this.projectOrientation = projectOrientation;
}
),
this.screenFlowRuntime.getReachableScreenCount().subscribe(
( reachableScreenCount ) => {
this.reachableScreenCount = reachableScreenCount;
}
),
this.screenFlowRuntime.getRelatedScreenImages().subscribe(
( relatedScreenImages ) => {
if ( relatedScreenImages.length ) {
// WHY NOT IN THE RUNTIME? At first, it might be tempting to put
// the pre-loading of image binaries into the Runtime service.
// However, I don't really see this as a "runtime" concern - I
// see this a "User Interface" (UI) concern. If we have the
// Runtime getting involved with user experience issues, then the
// lines of responsibility become too blurred. I don't want the
// Runtime to think about the browser (as little as possible).
this.screenPreloaderService.preloadImages( relatedScreenImages );
}
}
),
this.screenFlowRuntime.getScreenSize().subscribe(
( screenSize ) => {
this.screenSize = screenSize;
}
),
this.screenFlowRuntime.getSelectedTreeNode().subscribe(
( selectedTreeNode ) => {
this.selectedTreeNode = selectedTreeNode;
}
),
this.screenFlowRuntime.getTree().subscribe(
( tree ) => {
this.tree = tree;
}
)
);
}
// I toggle the drag protection layer.
public setDragProtection( isProtectingDrag: boolean ) : void {
this.isProtectingDrag = isProtectingDrag;
}
// I handle requests to start the screen-flow from the given node.
public startFlowFromScreen( treeNode: FlowTreeNode ) : void {
alert( "Re-rendering is not supported in Proof-of-Concept." );
}
// I handle requests to preview the given node in the live-site.
public viewScreenInPreview( treeNode: FlowTreeNode ) : void {
alert( "Preview is not supported in Proof-of-Concept." );
}
// ---
// PRIVATE METHODS.
// ---
// I navigate the router away from the currently-routed screen.
private navigateAwayFromScreen() : void {
this.router.navigate([ "/app/flow" ]);
}
// I navigate the router to the given screen ID.
private navigateToScreen( screenID: number ) : void {
this.router.navigate([
"/app/flow",
{
screenID: screenID
}
]);
}
}
When I first approached the View-Components in this application, I tried to use the AsyncPipe in order to simplify some of the subscription-logic. And, indeed it did reduce the subscription logic inside of the View Class. But, I found that the AsyncPipe made the actual view-templates much harder to reason about. As such, I decided to just bite-the-bullet and collect subscriptions in the ngOnInit() method.
While I didn't love this at first, once I started to use it, I found it very simple and easy to understand. It also let me respond to changes in individual state properties, which made things like like pre-loading screen-binaries (a browser concern, not a Runtime concern) dead-simple.
Notice also that not all "view state" is in the Runtime Abstraction. In this particular view, I have the notion of a "drag protection layer" - a Div that I show or hide based on the user's mouse interactions. This does not relate to the business logic of the application and is therefore not inside the Runtime Abstraction. Not all state has to be maintained in the same place.
This small but non-trivial Angular application doesn't necessarily test all the use-cases of a Runtime Abstraction. But, it's helping me believe that the approach has merit. It cleanly hides the implementation details of the state-management choices; it doesn't really have any superfluous code; and, it makes state mutations really easy to reason about by keeping "all the parts" in one place.
Want to use code from this post? Check out the license.
Reader Comments
Ben. It's great that you've actually tested your runtime abstraction, using a real world example. As, I said before, if I ever need a state management framework, I will definitely use your runtime abstraction, because, it seems relatively easy to understand, and doesn't involve a lot of boiler plate code!
Recently, I have been using Angular Material's MatTree, which is essentially a way of transforming data objects with nested objects into either a nested MatTree or a flattened nested MatTree. This could be the thing, you are looking for. It has a really powerful API, so it might save you some time with the implementation details of your custom tree structure:
https://material.angular.io/components/tree/overview
https://material.angular.io/cdk/tree/overview