Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Ryan Jeffords and Doug Boude
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Ryan Jeffords@ryanjeffords ) and Doug Boude@dougboude )

Using Presentation Components In Order To Hide Async Pipe Complexity In Angular 7.0.3

By Ben Nadel on

CAUTION: This post is really just me thinking out loud on state management concepts in Angular 7. Consider this nothing but a work in progress from the mind of someone who barely understands what they are doing.

Last week, I shared some of my frustration over the elusive nature of State Management in a JavaScript Single-Page Application (SPA); and, my not-so-novel idea of a "Runtime" abstraction that would completely hide the very notion of state management from the application View. I liked that the Runtime abstraction was very reactive, exposing its state through RxJS Observable streams. But, I found the Async Pipe to have poor "developer ergonomics". As such, I wanted to try and add non-Stream access to the Runtime state. Ultimately, however, I ended up coming back to the Streams and the Async Pipe; but, at the suggestion of Jason Awbrey, using a "presentation component" to quarantine the ugliness of the Async Pipe consumption.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

To quickly recap from last week, I wanted to encapsulate decisions about state management in such a way that the View layer of the JavaScript application had no idea if I was using Redux or NgRx or Akita or any of the new state management hawtness. What I ended up with was a "Runtime" abstraction that marshaled state mutations and state access. This abstraction allows the View to couple itself to the Runtime contract without having to couple itself to a particular state management technology.

I experimented with this in the context of "Santa's Christmas List" that aggregated a list of "Nice" and "Naughty" people. In that context, the Runtime abstraction looked something like this:

  • // Import the core angular services.
  • import { combineLatest } from "rxjs";
  • import { Injectable } from "@angular/core";
  • import { map } from "rxjs/operators";
  • import { Observable } from "rxjs";
  •  
  • // Import the application components and services.
  • import { AppStorageService } from "./app-storage.service";
  • import { SimpleStore } from "./simple.store";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • interface StoreState {
  • v: number;
  • selectedListType: ListType;
  • nicePeople: Person[];
  • naughtyPeople: Person[];
  • }
  •  
  • export interface Person {
  • id: number;
  • name: string;
  • }
  •  
  • export type ListType = "nice" | "naughty";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Injectable({
  • providedIn: "root"
  • })
  • export class SantaRuntime {
  •  
  • private appStorage: AppStorageService;
  • private appStorageKey: string;
  • private store: SimpleStore<StoreState>;
  •  
  • // I initialize the Santa runtime.
  • constructor( appStorage: AppStorageService ) {
  •  
  • this.appStorage = appStorage;
  •  
  • // Setup internal store.
  •  
  • // NOTE: Because we are using a string-literal as a "type", we have to help
  • // TypeScript by using a type annotation on our initial state. Otherwise, it
  • // won't be able to infer that our string is compatible with the type.
  • var initialStoreState: StoreState = {
  • v: 3,
  • selectedListType: "nice",
  • nicePeople: [],
  • naughtyPeople: []
  • };
  •  
  • // 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( initialStoreState );
  •  
  • // Setup app-storage interactions.
  •  
  • this.appStorageKey = "santa_runtime_storage";
  • this.storageLoadData();
  • // Because this runtime wants to persist data across page reloads, let's register
  • // an unload callback so that we have a chance to save the data as the app is
  • // being unloaded.
  • this.appStorage.registerUnloadCallback( this.storageSaveData );
  •  
  • }
  •  
  • // ---
  • // COMMAND METHODS.
  • // ---
  •  
  • // I add the given person to the currently-selected list.
  • public async addPerson( name: string ) : Promise<number> {
  •  
  • var person = {
  • id: Date.now(),
  • name: name
  • };
  •  
  • var state = this.store.getSnapshot();
  •  
  • if ( state.selectedListType === "nice" ) {
  •  
  • this.store.setState({
  • nicePeople: state.nicePeople.concat( person )
  • });
  •  
  • } else {
  •  
  • this.store.setState({
  • naughtyPeople: state.naughtyPeople.concat( person )
  • });
  •  
  • }
  •  
  • return( person.id );
  •  
  • }
  •  
  •  
  • // I remove the person with given ID from the naughty and nice lists.
  • public async removePerson( id: number ) : Promise<void> {
  •  
  • var state = this.store.getSnapshot();
  • var nicePeople = state.nicePeople;
  • var naughtyPeople = state.naughtyPeople;
  •  
  • // Keep the people that don't have a matching ID.
  • var filterInWithoutId = ( person: Person ) : boolean => {
  •  
  • return( person.id !== id );
  •  
  • };
  •  
  • this.store.setState({
  • nicePeople: nicePeople.filter( filterInWithoutId ),
  • naughtyPeople: naughtyPeople.filter( filterInWithoutId )
  • });
  •  
  • }
  •  
  •  
  • // I select the given list.
  • public async selectList( listType: ListType ) : Promise<void> {
  •  
  • this.store.setState({
  • selectedListType: listType
  • });
  •  
  • }
  •  
  • // ---
  • // QUERY METHODS.
  • // ---
  •  
  • // I return a stream that contains the number of people on the naughty list.
  • public getNaughtyCount() : Observable<number> {
  •  
  • var stream = this.store.select( "naughtyPeople" );
  •  
  • var reducedStream = stream.pipe(
  • map(
  • ( naughtyPeople ) => {
  •  
  • return( naughtyPeople.length );
  •  
  • }
  • )
  • );
  •  
  • return( reducedStream );
  •  
  • }
  •  
  •  
  • // I return a stream that contains the number of people on the nice list.
  • public getNiceCount() : Observable<number> {
  •  
  • var stream = this.store.select( "nicePeople" );
  •  
  • var reducedStream = stream.pipe(
  • map(
  • ( nicePeople ) => {
  •  
  • return( nicePeople.length );
  •  
  • }
  • )
  • );
  •  
  • return( reducedStream );
  •  
  • }
  •  
  •  
  • // I return a stream that contains the people in the currently-selected list.
  • public getPeople() : Observable<Person[]> {
  •  
  • var stream = combineLatest(
  • this.store.select( "selectedListType" ),
  • this.store.select( "nicePeople" ),
  • this.store.select( "naughtyPeople" )
  • );
  •  
  • var reducedStream = stream.pipe(
  • map(
  • ([ selectedListType, nicePeople, naughtyPeople ]) => {
  •  
  • if ( selectedListType === "nice" ) {
  •  
  • return( nicePeople );
  •  
  • } else {
  •  
  • return( naughtyPeople );
  •  
  • }
  •  
  • }
  • )
  • );
  •  
  • return( reducedStream );
  •  
  • }
  •  
  •  
  • // I return a stream that contains the currently selected list type.
  • public getSelectedListType() : Observable<ListType> {
  •  
  • return( this.store.select( "selectedListType" ) );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I attempt to load the persisted data into the runtime store.
  • private storageLoadData() : void {
  •  
  • var state = this.store.getSnapshot();
  •  
  • // See if we have any persisted store (returns NULL if not available).
  • var savedState = this.appStorage.loadData<StoreState>( this.appStorageKey );
  •  
  • // If we have saved data AND the data structure is the same VERSION as the one
  • // we expect, then return it as the initial state.
  • if ( savedState && savedState.v && ( savedState.v === state.v ) ) {
  •  
  • this.store.setState( savedState );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I save the current state to given store object.
  • // --
  • // CAUTION: Using a fat-arrow function to bind callback to instance.
  • private storageSaveData = () : void => {
  •  
  • this.appStorage.saveData( this.appStorageKey, this.store.getSnapshot() );
  •  
  • }
  •  
  • }

As you can see, the SantaRuntime class exposes methods for mutating the underlying state and, methods for accessing the underlying state. The accessor methods all return RxJS observable streams. In this case, I'm using my SimpleStore class. But, I could just as easily have chosen to use something NgRx or Redux. The point of the abstraction is that these choices have been completely encapsulated and decoupled from the View layer.

That said, I really didn't like the fact that the RxJS Observable streams moved me in the direction of the Async Pipe. As such, I wanted to try and refactor my SantaRuntime to allow static access as well as reactive access to the underlying state.

My first thought was to turn all of the accessor methods into synchronous methods; then, expose an additional way to access the state as a stream. In the following code, you can see that the accessor methods directly reference the underlying state store; but, there's an .asStream() method which allows a "slice" of the state to be accessed as a stream:

  • // Import the core angular services.
  • import { combineLatest } from "rxjs";
  • import { distinctUntilChanged } from "rxjs/operators";
  • import { Injectable } from "@angular/core";
  • import { map } from "rxjs/operators";
  • import { Observable } from "rxjs";
  •  
  • // Import the application components and services.
  • import { AppStorageService } from "./app-storage.service";
  • import { SimpleStore } from "./simple.store";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • interface StoreState {
  • v: number;
  • selectedListType: ListType;
  • nicePeople: Person[];
  • naughtyPeople: Person[];
  • }
  •  
  • export interface Person {
  • id: number;
  • name: string;
  • }
  •  
  • export type ListType = "nice" | "naughty";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Injectable({
  • providedIn: "root"
  • })
  • export class SantaRuntime2 {
  •  
  • private appStorage: AppStorageService;
  • private appStorageKey: string;
  • private store: SimpleStore<StoreState>;
  •  
  • // I initialize the Santa runtime.
  • constructor( appStorage: AppStorageService ) {
  •  
  • this.appStorage = appStorage;
  •  
  • // Setup internal store.
  •  
  • // NOTE: Because we are using a string-literal as a "type", we have to help
  • // TypeScript by using a type annotation on our initial state. Otherwise, it
  • // won't be able to infer that our string is compatible with the type.
  • var initialStoreState: StoreState = {
  • v: 3,
  • selectedListType: "nice",
  • nicePeople: [],
  • naughtyPeople: []
  • };
  •  
  • // 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( initialStoreState );
  •  
  • // Setup app-storage interactions.
  •  
  • this.appStorageKey = "santa_runtime_storage";
  • this.storageLoadData();
  • // Because this runtime wants to persist data across page reloads, let's register
  • // an unload callback so that we have a chance to save the data as the app is
  • // being unloaded.
  • this.appStorage.registerUnloadCallback( this.storageSaveData );
  •  
  • }
  •  
  • // ---
  • // COMMAND METHODS.
  • // ---
  •  
  • // I add the given person to the currently-selected list.
  • public async addPerson( name: string ) : Promise<number> {
  •  
  • var person = {
  • id: Date.now(),
  • name: name
  • };
  •  
  • var state = this.store.getSnapshot();
  •  
  • if ( state.selectedListType === "nice" ) {
  •  
  • this.store.setState({
  • nicePeople: state.nicePeople.concat( person )
  • });
  •  
  • } else {
  •  
  • this.store.setState({
  • naughtyPeople: state.naughtyPeople.concat( person )
  • });
  •  
  • }
  •  
  • return( person.id );
  •  
  • }
  •  
  •  
  • // I remove the person with given ID from the naughty and nice lists.
  • public async removePerson( id: number ) : Promise<void> {
  •  
  • var state = this.store.getSnapshot();
  • var nicePeople = state.nicePeople;
  • var naughtyPeople = state.naughtyPeople;
  •  
  • // Keep the people that don't have a matching ID.
  • var filterInWithoutId = ( person: Person ) : boolean => {
  •  
  • return( person.id !== id );
  •  
  • };
  •  
  • this.store.setState({
  • nicePeople: nicePeople.filter( filterInWithoutId ),
  • naughtyPeople: naughtyPeople.filter( filterInWithoutId )
  • });
  •  
  • }
  •  
  •  
  • // I select the given list.
  • public async selectList( listType: ListType ) : Promise<void> {
  •  
  • this.store.setState({
  • selectedListType: listType
  • });
  •  
  • }
  •  
  • // ---
  • // QUERY METHODS.
  • // ---
  •  
  • // I return the given runtime value as an Observable stream.
  • public asStream( _value: "selectedListType" ) : Observable<ListType>;
  • public asStream( _value: "naughtyCount" ) : Observable<number>;
  • public asStream( _value: "niceCount" ) : Observable<number>;
  • public asStream( _value: "people" ) : Observable<Person[]>;
  • public asStream( value: string ) : any {
  •  
  • var stream = this.store.getState().pipe(
  • map(
  • () => {
  •  
  • switch ( value ) {
  • case "selectedListType":
  • return( this.getSelectedListType() );
  • break;
  • case "naughtyCount":
  • return( this.getNaughtyCount() );
  • break;
  • case "niceCount":
  • return( this.getNiceCount() );
  • break;
  • case "people":
  • return( this.getPeople() );
  • break;
  • }
  •  
  • }
  • ),
  • distinctUntilChanged()
  • );
  •  
  • return( stream );
  •  
  • }
  •  
  •  
  • // I return the number of people on the naughty list.
  • public getNaughtyCount() : number {
  •  
  • var state = this.store.getSnapshot();
  •  
  • return( state.naughtyPeople.length );
  •  
  • }
  •  
  •  
  • // I return the number of people on the nice list.
  • public getNiceCount() : number {
  •  
  • var state = this.store.getSnapshot();
  •  
  • return( state.nicePeople.length );
  •  
  • }
  •  
  •  
  • // I return the people in the currently-selected list.
  • public getPeople() : Person[] {
  •  
  • var state = this.store.getSnapshot();
  •  
  • if ( state.selectedListType === "nice" ) {
  •  
  • return( state.nicePeople );
  •  
  • } else {
  •  
  • return( state.naughtyPeople );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I return the currently-selected list type.
  • public getSelectedListType() : ListType {
  •  
  • var state = this.store.getSnapshot();
  •  
  • return( state.selectedListType );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I attempt to load the persisted data into the runtime store.
  • private storageLoadData() : void {
  •  
  • var state = this.store.getSnapshot();
  •  
  • // See if we have any persisted store (returns NULL if not available).
  • var savedState = this.appStorage.loadData<StoreState>( this.appStorageKey );
  •  
  • // If we have saved data AND the data structure is the same VERSION as the one
  • // we expect, then return it as the initial state.
  • if ( savedState && savedState.v && ( savedState.v === state.v ) ) {
  •  
  • this.store.setState( savedState );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I save the current state to given store object.
  • // --
  • // CAUTION: Using a fat-arrow function to bind callback to instance.
  • private storageSaveData = () : void => {
  •  
  • this.appStorage.saveData( this.appStorageKey, this.store.getSnapshot() );
  •  
  • }
  •  
  • }

With this approach, I like that the accessors are easy to reason about since they tie directly into the underlying state and don't require a reactive mental model. But, this .asStream() method is totally janky. And, every time I add a new synchronous accessor, I have to make sure to come back and update this .asStream() method. Also, there's no inherent memoization of the state access like there is with the original RxJS streams approach. We can always add explicit memoization; but, that's more developer overhead.

Despite its jankiness, the .asStream() method did make me reconsider the concept of the Runtime state. What if the Runtime state was just a "materialized view" of the underlying state. In other words, what if the Runtime could subscribe to changes in the underlying store and then change its own "view state" to reflect the data transformation.

In this approach, I ended up using my SimpleStore class for both the underlying store as well as a "view store". In the following code, you will see in the method, .materializeView(), that I subscribe to changes in the underlying store and update the "view store" in response. The accessor methods then just become thin proxies to the "view store":

  • // Import the core angular services.
  • import { BehaviorSubject } from "rxjs";
  • import { combineLatest } from "rxjs";
  • import { distinctUntilChanged } from "rxjs/operators";
  • import { Injectable } from "@angular/core";
  • import { map } from "rxjs/operators";
  • import { Observable } from "rxjs";
  •  
  • // Import the application components and services.
  • import { AppStorageService } from "./app-storage.service";
  • import { SimpleStore } from "./simple.store";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • export interface RuntimeState {
  • selectedListType: ListType;
  • niceCount: number;
  • naughtyCount: number;
  • people: Person[];
  • }
  •  
  • interface StoreState {
  • v: number;
  • selectedListType: ListType;
  • nicePeople: Person[];
  • naughtyPeople: Person[];
  • }
  •  
  • export interface Person {
  • id: number;
  • name: string;
  • }
  •  
  • export type ListType = "nice" | "naughty";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Injectable({
  • providedIn: "root"
  • })
  • export class SantaRuntime3 {
  •  
  • private appStorage: AppStorageService;
  • private appStorageKey: string;
  • private store: SimpleStore<StoreState>;
  • private view: SimpleStore<RuntimeState>;
  •  
  • // I initialize the Santa runtime.
  • constructor( appStorage: AppStorageService ) {
  •  
  • this.appStorage = appStorage;
  •  
  • // Setup internal store.
  •  
  • // NOTE: Because we are using a string-literal as a "type", we have to help
  • // TypeScript by using a type annotation on our initial state. Otherwise, it
  • // won't be able to infer that our string is compatible with the type.
  • var initialStoreState: StoreState = {
  • v: 3,
  • selectedListType: "nice",
  • nicePeople: [],
  • naughtyPeople: []
  • };
  •  
  • // 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( initialStoreState );
  •  
  • // Setup runtime state.
  •  
  • // NOTE: Because we are using a string-literal as a "type", we have to help
  • // TypeScript by using a type annotation on our initial state. Otherwise, it
  • // won't be able to infer that our string is compatible with the type.
  • var initialRuntimeState: RuntimeState = {
  • selectedListType: "nice",
  • niceCount: 0,
  • naughtyCount: 0,
  • people: []
  • };
  •  
  • this.view = new SimpleStore( initialRuntimeState );
  •  
  • // When the underlying store changes, we have to update the view to reflect the
  • // materialized values.
  • this.materializeView();
  •  
  • // Setup app-storage interactions.
  •  
  • this.appStorageKey = "santa_runtime_storage";
  • this.storageLoadData();
  • // Because this runtime wants to persist data across page reloads, let's register
  • // an unload callback so that we have a chance to save the data as the app is
  • // being unloaded.
  • this.appStorage.registerUnloadCallback( this.storageSaveData );
  •  
  • }
  •  
  • // ---
  • // COMMAND METHODS.
  • // ---
  •  
  • // I add the given person to the currently-selected list.
  • public async addPerson( name: string ) : Promise<number> {
  •  
  • var person = {
  • id: Date.now(),
  • name: name
  • };
  •  
  • var state = this.store.getSnapshot();
  •  
  • if ( state.selectedListType === "nice" ) {
  •  
  • this.store.setState({
  • nicePeople: state.nicePeople.concat( person )
  • });
  •  
  • } else {
  •  
  • this.store.setState({
  • naughtyPeople: state.naughtyPeople.concat( person )
  • });
  •  
  • }
  •  
  • return( person.id );
  •  
  • }
  •  
  •  
  • // I remove the person with given ID from the naughty and nice lists.
  • public async removePerson( id: number ) : Promise<void> {
  •  
  • var state = this.store.getSnapshot();
  • var nicePeople = state.nicePeople;
  • var naughtyPeople = state.naughtyPeople;
  •  
  • // Keep the people that don't have a matching ID.
  • var filterInWithoutId = ( person: Person ) : boolean => {
  •  
  • return( person.id !== id );
  •  
  • };
  •  
  • this.store.setState({
  • nicePeople: nicePeople.filter( filterInWithoutId ),
  • naughtyPeople: naughtyPeople.filter( filterInWithoutId )
  • });
  •  
  • }
  •  
  •  
  • // I select the given list.
  • public async selectList( listType: ListType ) : Promise<void> {
  •  
  • var state = this.store.getSnapshot();
  •  
  • // Ignore request to select already-selected lsit.
  • if ( state.selectedListType === listType ) {
  •  
  • return;
  •  
  • }
  •  
  • this.store.setState({
  • selectedListType: listType
  • });
  •  
  • }
  •  
  • // ---
  • // QUERY METHODS.
  • // ---
  •  
  • // I get the current runtime state snapshot.
  • public getSnapshot() : RuntimeState {
  •  
  • return( this.view.getSnapshot() );
  •  
  • }
  •  
  •  
  • // I get the current runtime state as a stream (will always emit the current runtime
  • // state value as the first item in the stream).
  • public getState() : Observable<RuntimeState> {
  •  
  • return( this.view.getState() );
  •  
  • }
  •  
  •  
  • // I return the given top-level runtime state key as a stream (will always emit the
  • // current key value as the first item in the stream).
  • public select<K extends keyof RuntimeState>( key: K ) : Observable<RuntimeState[K]> {
  •  
  • return( this.view.select( key ) );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I listen for changes in the underlying store and then update the materialized
  • // view of the runtime state.
  • private materializeView() : void {
  •  
  • this.store.getState().subscribe(
  • ( state ) => {
  •  
  • // Every time the underlying store changes, we need to recalculate the
  • // materialized view state of the Runtime. In the following approach, we
  • // will be re-calculating these values more often than we need to; but,
  • // this is the most straightforward approach. We could always add some
  • // memoization functionality later if we need to.
  •  
  • var selectedListType = state.selectedListType;
  • var niceCount = state.nicePeople.length;
  • var naughtyCount = state.naughtyPeople.length;
  •  
  • var people = ( selectedListType === "nice" )
  • ? state.nicePeople
  • : state.naughtyPeople
  • ;
  •  
  • this.view.setState({
  • selectedListType,
  • niceCount,
  • naughtyCount,
  • people
  • });
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I attempt to load the persisted data into the runtime store.
  • private storageLoadData() : void {
  •  
  • var state = this.store.getSnapshot();
  •  
  • // See if we have any persisted store (returns NULL if not available).
  • var savedState = this.appStorage.loadData<StoreState>( this.appStorageKey );
  •  
  • // If we have saved data AND the data structure is the same VERSION as the one
  • // we expect, then return it as the initial state.
  • if ( savedState && savedState.v && ( savedState.v === state.v ) ) {
  •  
  • this.store.setState( savedState );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I save the current state to given store object.
  • // --
  • // CAUTION: Using a fat-arrow function to bind callback to instance.
  • private storageSaveData = () : void => {
  •  
  • this.appStorage.saveData( this.appStorageKey, this.store.getSnapshot() );
  •  
  • }
  •  
  • }

I do like the fact that this version of the Runtime now exposes a .getSnapshot() method for the synchronous state access and a .getState() method and .select() method for the reactive state access. But, the overall feel of the class is extremely janky! And, the API surface area is very lopsided. For data mutation, we have very specific methods that act on very specific pieces of state. And, for the data access, we have very abstract methods that return abstract slices of state. This asymmetry is a huge red-flag in my mind.

And, of course, we still have no inherent memoization of the derived data. Half the beauty of the RxJS streams approach is that we get the memoization out of the box in a lot of ways. And, by trying to fight the RxJS streams approach, we incur the overhead of having to add memoization back in ourselves.

At this point, I stopped to reconsidered something that Jason Awbrey said on Twitter. He agreed that the Async Pipe ergonomics were janky. So, he quarantines the jank within so-called "Smart Components" which provide the state to subsequent "Presentation Components" (aka, Dumb Components). This way, the presentation components receive "normal" data structures which, if you use immutable-data techniques, also provide for OnPush change detection:


 
 
 

 
Smart components can hide Async Pipe complexity tweet. 
 
 
 

To experiment with this approach, I created a new "SantaComponent" that would represent our "presentation" or "dumb" component which would do nothing but accept inputs and emit output events:

  • // Import the core angular services.
  • import { ChangeDetectionStrategy } from "@angular/core";
  • import { Component } from "@angular/core";
  • import { EventEmitter } from "@angular/core";
  • import { OnChanges } from "@angular/core";
  • import { SimpleChanges } from "@angular/core";
  •  
  • // Import the application components and services.
  • import { ListType } from "./santa.runtime";
  • import { Person } from "./santa.runtime";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-santa",
  • inputs: [
  • "selectedListType",
  • "niceCount",
  • "naughtyCount",
  • "people"
  • ],
  • outputs: [
  • "listTypeSelectEvents: listTypeSelect",
  • "peopleAddEvents: peopleAdd",
  • "peopleRemoveEvents: peopleRemove"
  • ],
  • changeDetection: ChangeDetectionStrategy.OnPush,
  • styleUrls: [ "./santa.component.less" ],
  • templateUrl: "./santa.component.htm"
  • })
  • export class SantaComponent implements OnChanges {
  •  
  • // Inputs.
  • public naughtyCount: number;
  • public niceCount: number;
  • public people: Person[];
  • public selectedListType: ListType;
  •  
  • // Outputs.
  • public listTypeSelectEvents: EventEmitter<ListType>;
  • public peopleAddEvents: EventEmitter<string>;
  • public peopleRemoveEvents: EventEmitter<Person>;
  •  
  • public intake: {
  • name: string;
  • };
  •  
  • // I initialize the santa component.
  • constructor() {
  •  
  • this.naughtyCount = 0;
  • this.niceCount = 0;
  • this.people = [];
  • this.selectedListType = "nice";
  •  
  • this.listTypeSelectEvents = new EventEmitter();
  • this.peopleAddEvents = new EventEmitter();
  • this.peopleRemoveEvents = new EventEmitter();
  •  
  • this.intake = {
  • name: ""
  • };
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called every time the input bindings change.
  • public ngOnChanges( changes: SimpleChanges ) : void {
  •  
  • console.group( "SANTA COMPONENT INPUT CHANGES" );
  • console.log( changes );
  • console.groupEnd();
  •  
  • }
  •  
  •  
  • // I process the new person intake form for Santa's list.
  • public processIntake() : void {
  •  
  • if ( ! this.intake.name ) {
  •  
  • return;
  •  
  • }
  •  
  • this.peopleAddEvents.emit( this.intake.name );
  • this.intake.name = "";
  •  
  • }
  •  
  •  
  • // I remove the given person from Santa's lists.
  • public removePerson( person: any ) : void {
  •  
  • this.peopleRemoveEvents.emit( person );
  •  
  • }
  •  
  •  
  • // I show the given list of people.
  • public showList( list: ListType ) : void {
  •  
  • this.listTypeSelectEvents.emit( list );
  •  
  • }
  •  
  • }

As you can see, there is no mention of any RxJS stream here. This component accepts basic data structures and emits simple events. And, because of this, I was able to set the "changeDetection" component meta-data to use OnPush. This means that Angular won't try to re-render this component unless any of the input references change or if events are emitted from within the view.

With these basic data structures, the "presentation" component template then becomes more straightforward:

  • <h2>
  • Santa's Christmas List
  • </h2>
  •  
  • <nav class="nav">
  • <a
  • (click)="showList( 'nice' )"
  • class="nav__item"
  • [class.nav__item--selected]="( selectedListType === 'nice' )">
  • Nice ({{ niceCount }})
  • </a>
  • <a
  • (click)="showList( 'naughty' )"
  • class="nav__item"
  • [class.nav__item--selected]="( selectedListType === 'naughty' )">
  • Naughty ({{ naughtyCount }})
  • </a>
  • </nav>
  •  
  • <div class="list-view">
  •  
  • <form (submit)="processIntake()" class="list-view__form intake">
  • <input
  • type="text"
  • name="name"
  • [(ngModel)]="intake.name"
  • placeholder="Name..."
  • class="intake__name"
  • />
  • <button type="submit" class="intake__submit">
  • Add Person
  • </button>
  • </form>
  •  
  • <ul *ngIf="people.length" class="list-view__list list">
  • <li *ngFor="let person of people" class="list__item person">
  •  
  • <span class="person__name">
  • {{ person.name }}
  • </span>
  •  
  • <a (click)="removePerson( person )" class="person__delete">
  • Delete
  • </a>
  •  
  • </li>
  • </ul>
  •  
  • </div>

Now, without having to deal with RxJS streams, there's no need for the Async Pipe. The data is all "just there". Vanilla ngIf and ngFor directives are all we need to render this component.

Of course, the RxJS streams still need to be consumed somewhere. And, in the case of this demo, that's the App Component - our so-called "smart" component:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  • import { Observable } from "rxjs";
  • import { OnInit } from "@angular/core";
  •  
  • // Import the application components and services.
  • import { ListType } from "./santa.runtime";
  • import { Person } from "./santa.runtime";
  • import { SantaRuntime } from "./santa.runtime";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.less" ],
  • template: `
  • <my-santa
  • [selectedListType]="( selectedListType | async )"
  • [niceCount]="( niceCount | async )"
  • [naughtyCount]="( naughtyCount | async )"
  • [people]="( people | async )"
  • (listTypeSelect)="showList( $event )"
  • (peopleAdd)="addPerson( $event )"
  • (peopleRemove)="removePerson( $event )">
  • </my-santa>
  • `
  • })
  • export class AppComponent implements OnInit {
  •  
  • public naughtyCount: Observable<number>;
  • public niceCount: Observable<number>;
  • public people: Observable<Person[]>;
  • public selectedListType: Observable<ListType>;
  •  
  • private santaRuntime: SantaRuntime;
  •  
  • // I initialize the app component.
  • constructor( santaRuntime: SantaRuntime ) {
  •  
  • this.santaRuntime = santaRuntime;
  •  
  • // Hook up the various runtime streams - these will act as the input bindings
  • // for our santa "presentation" component.
  • this.selectedListType = this.santaRuntime.getSelectedListType();
  • this.people = this.santaRuntime.getPeople();
  • this.niceCount = this.santaRuntime.getNiceCount();
  • this.naughtyCount = this.santaRuntime.getNaughtyCount();
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I create a new person with the given name and add them to the selected list.
  • public addPerson( name: string ) : void {
  •  
  • this.santaRuntime.addPerson( name );
  •  
  • }
  •  
  •  
  • // I get called once after the inputs have been bound for the first time.
  • public ngOnInit() : void {
  •  
  • var hash = window.location.hash.slice( 1 ).toLowerCase();
  •  
  • // If the window location (a VIEW CONCERN) is indicating a list selection, then
  • // let's update the runtime to match the list selection.
  • if ( ( hash === "nice" ) || ( hash === "naughty" ) ) {
  •  
  • this.santaRuntime.selectList( hash );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I remove the given person from Santa's lists.
  • public removePerson( person: any ) : void {
  •  
  • this.santaRuntime.removePerson( person.id );
  •  
  • }
  •  
  •  
  • // I show the given list of people.
  • public showList( list: ListType ) : void {
  •  
  • // Update the location hash (a VIEW CONCERN) so that we start on the selected
  • // list if the browser is refreshed.
  • window.location.hash = list;
  •  
  • this.santaRuntime.selectList( list );
  •  
  • }
  •  
  • }

The AppComponent still deals with the Runtime interactions. And, it still deals with the Async Pipe template syntax. But, you can see that the template for the AppComponent is terribly simple. All it does is act as a glue-layer between the streaming nature of the Runtime and the pure rendering of the SantaComponent.

Now, if we run this code in the browser and interact with the SantaComponent - aka, the "presentation" component - we get the following output:


 
 
 

 
 Using presentations components to hide the complexity of Async Pipe syntax in Angular 7.0.3. 
 
 
 

As you can see, our AppComponent takes emitted events and turns them into mutation requests against the Runtime abstraction. The Runtime abstraction then emits new values on the exposed RxJS streams. These stream values are then piped into the presentation component using the Async Pipe. But, from within the context of the presentation component, these emitted stream values are just "normal" data structures.

Clearly, the amount of code in this Santa's List demo is increasing as we try to find the right separation of concerns and the right developer ergonomics. But, this level of complexity isn't always necessary. That said, having the split between the "Smart component" and the "Presentation component" does make the Async Pipe seems a lot more palatable. Which, in turn, makes the stream-based Runtime abstraction a lot more palatable as well.



Looking For A New Job?

Ooops, there are no jobs. Post one now for only $29 and own this real estate!

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

Ben. This is a really interesting development. I have never really used the 'async' keyword in a method creation:

public async selectList( listType: ListType ) : Promise<void>...

Can you explain how this works? So, the method is returning 'void' [nothing] as a promise. Does this mean that whatever is calling the method, has to wait until the promise is fulfilled, before moving to the next execution line?

In this case, waiting for the store to have its state set?

Reply to this Comment

@Charles,

So, the async keywords is pretty cool. It magically transforms the function into one that returns a Promise that implicitly resolves to the value returned by the function. You can think of this:

async function foo() {
	return( "bar" );
}

... as being roughly equivalent to:

function foo() {

	return new Promise(
		function( resolve, reject ) {
		
			resolve( "bar" );

		}
	);

}

Now, in my blog example, the return-type, Promise<void> just means that the return value of the async method is a Promise that will resolve to "nothing". Meaning, the function that resolves the promise doesn't return a value. If the resolving function returned a "string", my return-type annotation would be Promise<string>.

As far as the calling context, it doesn't have to wait; but, if this demo were more robust, I would want to do a .then() or .catch() on the return value in order to handle errors better:

async function foo() {
	return( "bar" );
}

foo().then(
	function( value ) {
	
		console.log( value ); // ....logs "bar"

	}
);

So, I think the most confusing thing about what I have is the fact that I'm not actually "consuming" the returned Promise in any way :( Sorry about that.

Reply to this Comment

Ben. Thanks so much. This is a fantastic explanation. I completely understand the 'async' keyword. I am really excited about using it now. I guess it allows more precise control over asynchronous activity, whereas the RxJs Observable 'subscribe', always pushes data, once subscribed, unless one unsubscribes from it!

I have just downloaded Coda for iOS, which is a great code editor for the iPhone. It has built in TypeScript syntax highlighting and I found a Git repo which contains a transpiler in it. So, I can now play around with TS, whilst I am on the train etc...

Again, thanks so much for the explanation!

Reply to this Comment

@Charles,

Ha ha ha :D No worries. I think my underlying database doesn't use utf8mb4 encoding, so some emoji don't get encoded properly.

I'm currently working on re-working Incident Commander tool to use this Runtime / Smart container separation. But, it's a bit slowing going. Right now trying to refactor to using an embedded Presentation component. Then, will work on factoring-out the logic into a Runtime abstraction.

Will keep everyone posted.

Reply to this Comment

Really looking forward to seeing how it develops! I certainly think the community could do with a simpler, more logical implementation. Although, I like Akita, it is a little complicated for my liking...

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.