In this article I will show how to create a lazy loaded treeview using RxJs Observables.
In a previous article we discussed how to create a recursive treeview from an object model. Since then a lot of people have asked me extend this idea to lazy load the treeview from an api.
I figured this would be a good opportunity to play around with some RxJs and Redux, so I decided to go ahead and build one.
Even though I've been saying it for a while now, I am still learning about RxJs and Redux, so any feedback on the approach is appreciated.
I start by creating a treeview component (TreeView) with a basic template.
The component subscribes to an observable that will start pumping out tree nodes when a node is expanded.
Component
import {Component, Input, OnInit} from '@angular/core';
import {TreeNode} from './tree-node';
import {Store} from './redux/store';
import {TreeNodeService} from './tree-node-service';
@Component({
templateUrl:'./components/lazy-loaded-tree-view/tree-view.html',
selector:'tree-view'
})
export class TreeView implements OnInit{
@Input() root:TreeNode;
children:any;
items = [];
subscription;
constructor(private _store:Store, private _treeNodeService:TreeNodeService){
}
ngOnInit(){
this.subscription = this._store.getTreeNodes(this.root.key).subscribe(res => {
this.items = res;
});
this._treeNodeService.loadTreeNodes(this.root);
}
ngOnDestroy(){
this.subscription.unsubscribe();
}
}
Template
<ul>
<li *ngFor="let node of items">
<span class="iconButton" [ngClass]='{"tree-node-no-children": !node.showIcon}' (click)="node.expand()">{{node.icon}}</span>
<span>{{ node.name }}</span>
<div *ngIf="node.expanded">
<tree-view [root]="node"></tree-view>
</div>
</li>
</ul>
Store
Let's take a look at the store
import {Injectable} from '@angular/core';
import {Subject} from 'rxjs/Subject';
import {Observable} from 'rxjs/Observable';
import {Http,Response} from 'angular2/http';
import {TreeNode} from '../tree-node';
import {treeNodeReducer} from './tree-node-reducer';
@Injectable()
export class Store{
private dispatcher = new Subject
();
private treeNodes = {};
private nodes = {};
constructor(private _http:Http){
this.dispatcher.subscribe((action) => this.handleAction(action));
}
private handleAction(action) {
if(action.name === 'LOAD_NODES') {
if (this.nodes[action.key]) {
this.treeNodes[action.key].next(this.nodes[action.key]);
}
else {
this._http
.get(action.url)
.map((res:Response) => res.json())
.subscribe(res => {
this.nodes[action.key] = treeNodeReducer(res, action);
this.treeNodes[action.key].next(this.nodes[action.key]);
});
}
}
}
getTreeNodes(key){
if(!this.treeNodes.hasOwnProperty(key)){
this.treeNodes[key] = new Subject>();
}
return this.treeNodes[key].asObservable();
}
dispatchAction(action){
this.dispatcher.next(action);
}
}
When a node is expanded my store will make an http request and store the result internally in the store. Before persisting state I pass the response through a reducer to produce view models for the view.
The treeNodeReducer is currently super simple with nothing more than a mapping from the api response to TreeNode models.
Reducer
import {TreeNode} from '../tree-node';
export const treeNodeReducer = (state: any = [], action) => {
switch (action.name) {
case 'LOAD_NODES':
return state.nodes.map(n => {
return new TreeNode(n.key,n.url,n.name);
});
}
};
TreeNode model
export class TreeNode{
showIcon = false;
expanded = false;
icon = null;
constructor(public key, public url, public name){
if(url){
this.showIcon = true;
this.icon = this.getIcon();
}
}
expand(){
this.expanded = !this.expanded;
this.icon = this.getIcon();
}
private getIcon(){
if (this.showIcon === true) {
if(this.expanded){
return '- ';
}
return '+ ';
}
return null;
}
}
The idea is that state will only be persisted in the store and data will flow to the treeview via RxJs observables.
I can't create the observables ahead of time since the tree data is not known. Instead the observables are created on the fly when data comes back from the api. Basically each expanded node has its own observable for delivering data.
The store caches the data, so if a node is expanded again, the data is loaded from cache.
My store exposes a dispatchAction method for passing actions to the store. Currently there is only support for loading actions, but I might add support for adding nodes later.
TreeNodeService
import {Injectable} from '@angular/core';
import {Store} from './redux/store';
@Injectable()
export class TreeNodeService{
constructor(private _store:Store){
}
loadTreeNodes(root){
if(root.url) {
this._store.dispatchAction({key: root.key, url: root.url, name: 'LOAD_NODES'});
}
}
}
In my demo application I have created a hierarchy of a few geographical locations. The data for the treeview is loaded by on demand as the user expands nodes.
The final tree looks like this.
As always my code is also available on Github