WebCake

In pushing out a new release of one of the frameworks we use in my office, I had an idea that I started exploring at home. Its previous release was built on AngularJS 1.5 using the UI-Router, and we had put in a lot of work to abstract-out data calls in the UI-Router resolve properties in our state definitions. This allowed us to build up view models very simply with a mapping of data dependencies that are automatically wired-in when a new state loads.

I wanted to see if I could do the same thing with the Angular Router, using @ngrx/store.

TL;DR: If you’re just interested in looking through the code, you can find it published on my GitHub.

Core Concept

The idea here was to set up page dependency data that every page in the app could declare, and the application would ultimately be responsible for pulling down from a backend and putting in a Redux-powered store. Every time a new view is loaded, new data would be stashed in the store. Every time an old view is removed form the DOM, it would be responsible for cleaning up its data.

Approach

To keep things simple, I have a basic @angular/cli app running with a single store and a single reducer with actions for adding data to the store, and removing data from the store. Since the goal here was to dynamically set a view-model based on the components loaded into the various router-outlets, I didn’t get into modifying any data in the view. To me that’s basic Redux, and there are plenty of other tutorials that do that.

I also mocked out the fetching of data by adding some mock JSON files to the assets/ folder in order to simulate a backend data call.

It’s worth noting that for simplicity sake I added this data fetch to a resolve property on each route. Since all of the views extend a BaseView class, which you’ll see, I could have added a lifecycle hook there instead. resolve was just the easier way (in my opinion) to go to get something up and running.

Data Action Mappings

One of the things that’s helped our teams stay platform-agnostic has been an action-mapping setup that maps commands to data calls. On the web, this looks like overkill, but once you’re working in multiple platforms with a hybrid mobile or desktop application, where you can leverage calls to an internal database, or use device-specific threading to fetch data for your webapp, it makes a lot more sense. It also helps with cleaning up the store if you know which views are dependent on which data, as you’ll see later.

I started with modifying the Route interface by extending it to include additional properties for data mapping:

// app/models/resolved-route.model.ts
import { Route } from '@angular/router';

export interface ResolvedRoute extends Route {
    dependencies?: {[key: string]: string}
    children?: ResolvedRoute[]
}

export declare type ResolvedRouteConfig = ResolvedRoute[];

A route with a dependencies property ends up looking like this:

// in app/routes/root.route.ts
// notice that each route now has a `dependencies` object
export const FIRST_PAGE_ROUTES: ResolvedRoute[] = [
    {
        path: '',
        component: BandListingView,
        resolve: {
            model: ModelResolve
        },
        dependencies: {
            // fetching list data
            bands: 'get-bands'
        },
        children: [
            {
                path: 'band/:id',
                component: BandDetailsView,
                resolve: {
                    model: ModelResolve
                },
                dependencies: {
                    // fetching detail data
                    band: 'get-band'
                }
            }
        ],
    },
    // ...more routes...
]

In building a mock data service, I simply hard-coded it to map the three actions I knew I would use to fetch data:

// in app/services/data.service.ts
// these are just mock data calls, you really don't need to worry
// about them as impacting how you'd build a dynamic view model.
// the goal here was to show how route actions map to data calls.
@Injectable()
export class DataService {
    private routeMap: {[key: string]: string};

    constructor(private http: Http) {}

    resolve(action, params): Observable<any> {
        switch (action) {
            case 'get-bands':
                return this.http.get('assets/data/band-list.json').map(res => res.json());
            case 'get-band':
                return this.http.get('assets/data/band-details.json')
                    .map(res => res.json().find((x) => x.id + '' === params['id']));
            case 'get-song':
                return this.http.get('assets/data/song-details.json')
                    .map(res => res.json().find((x) => x.id + '' === params['id']).songs.find((x) => x.id + '' === params['songId']));
        }
    }
}

Adding Resolved Data to the Store

I set these three actions up in a Resolve-based class so that they could be triggered by route resolves when a view loads into the DOM. However instead of storing that data back in the resolved route, I put it in the store. It looks like this:

// in app/resolves/model.resolve.ts

import { Injectable } from '@angular/core';
import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import * as Rx from 'rxjs';
import { DataService } from '../services/data.service';
import 'rxjs/add/operator/combineLatest';

@Injectable()
export class ModelResolve implements Resolve<any> {
    private obs: Observable<any>;

    constructor(private data: DataService, private store: Store<any>) {
        this.obs = new Observable();
    }

    resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> {

        let dataTypes = Object.keys(route.routeConfig['dependencies']);
        let dataObservables = [];

        dataTypes.forEach(type => {
            dataObservables.push(this.data.resolve(route.routeConfig['dependencies'][type], route.params))
        });
        /*
        TODO: Smooth this out with a simpler observable handler
        I'm sure there's something in RxJS that would make this
        next line a lot cleaner
         */
        return Rx.Observable.combineLatest(...dataObservables).combineLatest((latestValues: any[]) => {
            let payload = {};
            for(let i = 0; i < dataTypes.length; i++) {
                payload[dataTypes[i]] = latestValues[i];
            }
            this.store.dispatch({type: 'SET_MODEL', payload});
            return;
        });
    }
}

Two things are really worth noting here. The first, I’m really unhappy with how I was able to get this working. I had to run a combineLatest on a combineLatest, and that just looks terrible. I’m hoping there’s a method for flattening those two that I missed that someone can point me to. If you want to know more about how combineLatest latest works, check out the documentation.

But more importantly, as each data type declared in the dependencies of each route is resolved, it gets mapped through those combineLatest methods into the store through the SET_MODEL action. The store continues to grow as data is resolved. On the plus side, that means that our application data is constantly accessible, particularly as more routes in the same view are accessed. On the down side, it means that as a user navigates our app the store can get bloated with data they no longer need. We’ll deal with that in a second. First, let’s take a look at what SET_MODEL does:

// in app/reducers/model.reducer.ts

export const SET_MODEL = 'SET_MODEL';
export const REMOVE_DEPENDENCIES = 'REMOVE_DEPENDENCIES';

export function model(state: any = {}, action: {type: string, payload?: any} = {type: null}) {
    switch (action.type) {

        case SET_MODEL:
            const val = Object.assign({}, state, action.payload);
            return val;

        default:
            return state;
    }
}

Pretty straight-forward, right? I assign whatever variables are in the payload to a new object, combined with the already-existing state. So as, for example, a band object is resolved in ModelResolve, it’s added to the existing state, which might already have a bands array as well.

Now let’s take a look at how we can access that data, as well as clean it up automatically.

Auto Subscribe and Clean Up in View Components

Since I’m resolving data automatically for all of my routes, I can probably go ahead and assume that there will be some boilerplate code that I can abstract out into a parent class, one that each of my view components – “smart” components in the Redux world – can extend.

Ideally, a parent class in this case would set up a subscription to the store, accessing the view-model exposed therein, as well as clean up after itself automatically when it gets removed from the DOM. That way the store doesn’t get too bloated with unnecessary data.

Subscribing in the OnInit hook

In my parent class, which we’ll call BaseView, I start with a call to ngOnInit that will set up an instance variable that subscribes to the store by selecting the model and assigning its values:

// in app/views/base.view.ts
import { Store } from '@ngrx/store';
import { ActivatedRoute } from '@angular/router';

export class BaseView {
    model: any;

    constructor(public store: Store<any>, public route: ActivatedRoute) {}

    ngOnInit() {
        this.modelSubscription = this.store.select('model')
            .subscribe(val => {
                this.model = val;
            });
}

Ok, so now anything that extends this BaseView class will get a model instance variable for free, set up to consume whatever I have in my store. Nice. Using the routes defined above, I would have my parent list view get a model.bands property, and my child detail view get a model.band property.

From where I’m sitting, this works with the Redux concept of creating “smart” components (I’m calling them views) that are aware of their relationship to a data store, and then passing that data down through a tree of “dumb” components. By extending a BaseView, I can get that data store intelligence boilerplate down to one file, and then use those properties in my child class:

// in app/views/band-details.view.ts
import { Component } from '@angular/core';
import { ActivatedRoute, Router, RouterState } from '@angular/router';
import { Store } from '@ngrx/store';

import { BaseView } from './base.view';

@Component({
    selector: 'second-page',
    template: `
    <h1>Second Page</h1>
    <h2>{{model.band.name}}</h2>
    <h3>Songs:</h3>
    <ul>
        <li *ngFor="let song of model.band.songs" (click)="routeToSong(song.id)">{{song.name}}</li>
    </ul>
    <h3>Band members:</h3>
    <ul>
        <li *ngFor="let musician of model.band.members">{{musician}}</li>
    </ul>
    `,
    styles: [`.left-panel { width: 50%; }`]
})
export class BandDetailsView extends BaseView {

    // notice that I'm not doing anything with the store other than passing it into
    // the parent class's constructor
    constructor(public route:ActivatedRoute, private router: Router, public store: Store<any>) {
        super(store, route);
    }

    routeToSong(id) {
        this.router.navigate(['/', 'band', this.model.band.id, 'song', id]);
    }
}

Removing from the Store in the OnDestroy hook

Similar to how we build in a subscription, we’ll want to build in a clean-up process for that Observer so that developers who are extending this BaseView don’t have to worry about it. That’s pretty straight-forward:

// in app/views/base.view.ts
ngOnDestroy() {
    this.modelSubscription.unsubscribe();
}

At this point, my BaseView subscribes to data on initialization, and unsubscribes on destruction. Ok, that’s a good step in the right direction, but we’ll want to go one step further and make sure data is removed form the Store so that it doesn’t get bloated as a user navigates through the application.

In order to do that, we’ll first need to set up a property in the store called dependencyKeys, which will be a list of all of the data types necessary for the most-recently activated view.

Why do this, one might ask? As each view loads into a router-outlet, it’s going to fetch data and add it to the store; we can generally assume that there will always be one or more views active at a time – e.g. if you have a two-column view that shows list data on the left, and a selected item’s detail data on the right. In cases like that, once both views have been loaded, we have no way of knowing which view is responsible for which set of data in the store, unless we have some sort of reference back to the dependencies object in a view that resolved that data to the store in the first place.

With the dependencyKeys property, we can keep a list of the data types that have been resolved for the most recently activated view, based on Object.keys() of the dependencies object as it’s passed into the store.

// in app/reducers/model.reducer.ts
case SET_MODEL:
    const val = Object.assign({}, state, {dependencyKeys: Object.keys(action.payload)}, action.payload);
    return val;

So in clicking on a list item in the left side of a two-column view, the resulting most recently activated view will be the detail column (generally the right side), and the dependencyKeys property of the store from the list view will be overwritten with the dependencyKeys from the detail view.

Once we click from the detail view (right side) into a new child view, both the left and right columns will be destroyed from the DOM, and we can determine what, if anything, needs to be preserved when these two sides clean up after themselves. We can do this in the ngOnDestroy for those view components; and since we have a BaseView that each one inherits from, we can write it once there, and they’ll both get it:

// in app/views/base.view.ts
ngOnDestroy() {
    let viewDependencyKeys = Object.keys(this.route.routeConfig['dependencies']);
    // in this next action, we'll remove all of the data dependencies for this view from the Store
    this.store.dispatch({type: 'REMOVE_DEPENDENCIES', payload: viewDependencyKeys});
    this.modelSubscription.unsubscribe();
    this.baseViewHooks.destroyHooks.forEach((hook: {action: Function, arguments?: any[]}) => {
        hook.action.call(this.baseViewHooks.context || this, ...hook.arguments);
    });
}

And, as one might expect, here’s the action handler for REMOVE_DEPENDENCIES:

// in app/reducers/model.reducer.ts
case REMOVE_DEPENDENCIES:
    let keys = action.payload;
    keys.forEach(key => {
        if(state.dependencyKeys.indexOf(key) === -1) delete state[key];
    });
    return Object.assign({}, state);

So now when each view is destroyed, it’ll remove its data dependencies from the store automatically, so long as another object hasn’t written a new version of the same dependency already.

For example, in my working demo, the details view (right side in my two-column) has a data dependency of band, as does its child view. So when the details view is destroyed, it’ll make sure not to take with it any new instance of band in the store. We can assume that the band in the store is the new view’s object, since by the mantra of Redux we’re always overwriting the store, never modifying directly.

Conclusion

For my purposes, this ends up being a nice clean solution, and a simple way to build out the view model. Since all of the view data goes into one location, it’s easily referenced across views, if I need to for any reason. With all of this logic in place I can check-off fetching data as something that’s done by listing a few commands as I define my routes, and I can spend my time focused on other things.

Be sure to check out the code on GitHub, and feel free to leave comments below or open issues on the repo if you have any thoughts or questions.

Leave a Reply

Your email address will not be published. Required fields are marked *