More Fun With Recursive Components, Tree State, And One-Way Data Flow In Angular 7.2.13
A couple of months ago, I looked at using recursive ng-template references in an Angular 6 app. And then, I followed that up with a look at recursive components. On its own, Recursion is plenty exciting. But, there's something particularly thrilling about rendering an application View recursively. In the comments for one of those posts, Munindar Reddy asked me for an example of using recursive rendering in conjunction with dynamic state. As such, I wanted to follow-up with one more exploration in Angular 7.2.13, this time using a "Folder" tree in which each folder can be expanded or collapsed by the user.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
For this exploration, I'm creating a very simple data structure with Folders and Files. Both Folders and Files live within Folders, allowing the Folder structure to be deeply and arbitrarily nested below a root Folder. Here are the TypeScript interfaces for these two data types:
export interface Folder {
uid: string;
name: string;
folders: Folder[];
files: File[];
}
export interface File {
uid: string;
name: string;
}
As you can see, each Folder can contain zero-or-more files as well as zero-or-more sub-folders.
What you don't see defined in these Interfaces is any concept of the "expanded" or "collapsed" state of a Folder. For this particular exploration, I have chosen to store that information outside of the Folder tree. This approach is neither right nor wrong - it's simply a set of calculated trade-offs.
I wanted to keep the Folder tree as an immutable data-structure. And, by keeping the "expanded" state outside of the data-structure, I wouldn't have to jump through data-duplication hoops or deal with complex APIs like Immutable.js. Instead, I am just going to store the "expanded folder" state as a collection of Folder UIDs (universal IDs). This makes expanding and collapsing folders as easy as adding or removing UIDs from a simple, string Array.
But, this also means that as I recursively render the Folder tree component, I have to pass-down both the Folder structure and the collection of expanded folder UIDs. Luckily, with recursion, the code for this is only one-level deep - the "nested" portion is strictly a runtime concern. As such, this is much less complicated than it may sound.
As we pass the data down through the Recursive rendering, I want to keep the data immutable. This means that as a user toggles the expanded state of any given folder, I don't want to the contextual Folder component to mutate the collection of expanded Folder UIDs directly. Instead, I want the Folder component to emit a "toggle" event, which will bubble-up through the Recursive rendering until it is captured by the App component, which will, in turn, handle the mutation of the View-Model.
And, to add a little flair to this demo, I'm going to persist the collection of expanded Folder UIDs to the browser location. This way, if you refresh the browser - or pass the URL to another person - the Folder tree will re-render to the previously-expanded state.
NOTE: In order to keep the persistence simple, I'm skipping the Router and just using the Location service directly. This leads to code that is a bit janky; but, gets the job done.
With that said, let's look at the App component. Notice that the App component maintains both the tree structure as well as a collection of Folder UIDs. These UIDs are then mutated based on Expand and Collapse events:
// Import the core angular services.
import { Component } from "@angular/core";
import { Location } from "@angular/common";
// Import the application components and services.
import { Folder } from "./demo-data";
import { generateData } from "./demo-data";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<p class="toggles">
<a (click)="expandAll()" class="toggle">Expand All</a>
—
<a (click)="collapseAll()" class="toggle">Collapse All</a>
</p>
<my-folder-tree
[rootFolder]="rootFolder"
[expandedFolders]="expandedFolders"
(toggleFolder)="toggleFolder( $event )">
</my-folder-tree>
`
})
export class AppComponent {
public expandedFolders: string[];
public rootFolder: Folder;
private location: Location;
// I initialize the app component.
constructor( location: Location ) {
// In order to make the demo more exciting, we're going to track the expanded
// folders using the browser URL. This way, if you refresh the page, or pass the
// URL to another team-member, the folder tree will return to the same expanded
// state.
// --
// CAUTION: Since we are hacking this feature without the Router, the approach is
// janky and is not intended to be seen as a best practice.
this.location = location;
this.expandedFolders = [];
this.rootFolder = generateData();
}
// ---
// PUBLIC METHODS.
// ---
// I collapse all of the folders.
public collapseAll() : void {
// Since we are storing the expanded status of folders outside of the folder
// tree data, in order to collapse all folders all we have to do is reset the
// collection of ids.
this.expandedFolders = [];
this.setExpandedFoldersToLocation( this.expandedFolders );
}
// I expand all of the folders.
public expandAll() : void {
var uids: string[] = [];
// Since the folder data is stored in a tree structure, in order to locate all
// the UIDs (to track them as expanded), we'll have to start at the root of the
// tree and then traverse all of the nodes.
var foldersToExplore: Folder[] = [ this.rootFolder ];
while ( foldersToExplore.length ) {
var folder = foldersToExplore.shift() !; // NOTE: Non-null assertion.
uids.push( folder.uid );
// Push all sub-folders onto the collection of folders to traverse while
// looking for UIDs.
foldersToExplore.push( ...folder.folders );
}
this.expandedFolders = uids;
this.setExpandedFoldersToLocation( this.expandedFolders );
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
this.expandedFolders = this.getExpandedFoldersFromLocation();
}
// I toggle the expanded status of the given folder.
public toggleFolder( target: Folder ) : void {
var index = this.expandedFolders.indexOf( target.uid );
// If the folder is currently collapsed, expand it.
if ( index === -1 ) {
this.expandedFolders = this.expandedFolders.concat( target.uid );
// If the folder is currently expanded, collapse it.
} else {
this.expandedFolders = [
...this.expandedFolders.slice( 0, index ),
...this.expandedFolders.slice( index + 1 )
];
}
this.setExpandedFoldersToLocation( this.expandedFolders );
}
// ---
// PRIVATE METHODS.
// ---
// I get the list of expanded folder UIDs from the browser URL.
// --
// CAUTION: I am using a very naive approach for this demo, in so much as I am not
// using the Router and am, instead, just assuming the folder UIDs will be the only
// values present in the entire query-string.
private getExpandedFoldersFromLocation() : string[] {
var search = this.location.path().split( "?" );
if ( search.length === 2 ) {
return( search[ 1 ].split( "," ) );
} else {
return( [] );
}
}
// I save the list of expanded folder UIDs to the browser URL.
// --
// CAUTION: I am using a very naive approach for this demo, in so much as I am not
// using the Router and am, instead, just assuming the folder UIDs will be the only
// values present in the entire query-string.
private setExpandedFoldersToLocation( uids: string[] ) : void {
this.location.replaceState( "", uids.join( "," ) );
}
}
When it comes to Recursion, you almost always want to separate the consumption of the recursive process from the implementation of the recursive call. This provides a layer of encapsulation that allows the recursive API to evolve independently of the calling context. In this exploration, we are upholding this practice by rendering a "FolderTree" component rather than attempting to render the root "Folder" directly. We then pass the root Folder and the collection of expanded UIDs to the FolderTree component without having to worry about how they are being consumed under the hood.
We also bind to the (toggleFolder) event on the FolderTree. This event is used to mutate the View-Model - expandedFolders - which is then passed back into the FolderTree using a unidirectional data-flow. This puts the AppComponent in complete control over how the data is maintained over time.
Now, let's look at the FolderTreeComponent - the start of our recursive view-rendering. This layer of encapsulation over the recursion is very thin. All it does it render the root Folder (the recursive component) and propagate toggle events:
// Import the core angular services.
import { ChangeDetectionStrategy } from "@angular/core";
import { Component } from "@angular/core";
import { EventEmitter } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export interface Folder {
uid: string;
name: string;
folders: Folder[];
files: File[];
}
export interface File {
uid: string;
name: string;
}
@Component({
selector: "my-folder-tree",
inputs: [
"expandedFolders",
"rootFolder"
],
outputs: [
"toggleFolderEvents: toggleFolder"
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: [ "./folder-tree.component.less" ],
template:
`
<my-folder
[folder]="rootFolder"
[expandedFolders]="expandedFolders"
(toggleFolder)="toggleFolder( $event )">
</my-folder>
`
})
export class FolderTreeComponent {
public expandedFolders!: string[];
public rootFolder!: Folder;
public toggleFolderEvents: EventEmitter<Folder>;
// I initialize the folder-tree component.
constructor() {
this.toggleFolderEvents = new EventEmitter();
}
// ---
// PUBLIC METHODS.
// ---
// I emit a toggle event for the given folder.
public toggleFolder( target: Folder ) : void {
this.toggleFolderEvents.emit ( target );
}
}
As you can see, there's almost nothing going on here. In fact, it would have been trivial to remove this component altogether and just move the root folder rendering to the App component. But, again, I would recommend that you always create a layer of encapsulation around the recursive implementation. It will make the long-term maintenance easier.
With that said, let's get to the recursion! In order to make the FolderComponent a little easier to reason about, I've split the HTML template into its own file. This way, we can see that code behind the template is almost as simple as it is in the FolderTreeComponent:
// Import the core angular services.
import { ChangeDetectionStrategy } from "@angular/core";
import { Component } from "@angular/core";
import { EventEmitter } from "@angular/core";
import { SimpleChanges } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export interface Folder {
uid: string;
name: string;
folders: Folder[];
files: File[];
}
export interface File {
uid: string;
name: string;
}
@Component({
selector: "my-folder",
inputs: [
"expandedFolders",
"folder"
],
outputs: [
"toggleFolderEvents: toggleFolder"
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: [ "./folder.component.less" ],
templateUrl: "./folder.component.htm"
})
export class FolderComponent {
public expandedFolders!: string[];
public folder!: Folder;
public isExpanded: boolean;
public toggleFolderEvents: EventEmitter<Folder>;
// I initialize the folder component.
constructor() {
this.isExpanded = false;
this.toggleFolderEvents = new EventEmitter();
}
// ---
// PUBLIC METHODS.
// ---
// I get called when any of the inputs bindings change.
public ngOnChanges( changes: SimpleChanges ) : void {
// When either the folder or the list of expanded folders changes, let's check
// to see if the current folder's expanded status has changed.
this.isExpanded = ( this.expandedFolders.indexOf( this.folder.uid ) !== -1 );
}
// I emit a toggle event for the given folder.
// --
// CAUTION: This method may be invoked due to a local toggle action; or, as part of
// the bubbling-up of a toggle action in a nested folder. In order to follow a one-
// way data-flow, all toggle requests are bubbled-up instead of being applied
// directly to the local view-state.
public toggleFolder( target: Folder ) : void {
this.toggleFolderEvents.emit( target );
}
}
As you can see the, Inputs and Outputs of the FolderComponent match the FolderTreeComponent. But, the FolderComponent also defines some local view-model state - isExpanded. This state determines, at least to some degree, whether or not this Folder attempts to render itself recursively. If the Folder is collapsed, then there's no reason to recursively render sub-folders.
The "isExpanded" state is calculated based on the Input bindings. Which is why, in part, we need to keep the "expandedFolders" collection immutable. If we attempted to mutate the expandedFolders collection directly, our ngOnChanges() life-cycle method wouldn't be invoked and we'd have to use the less-performant ngDoCheck() life-cycle method. As such, any request by the user to toggle the state of a Folder is emitted as a (toggleFolder) event, which is then handled by the App component, which, in turn, passes-down a new "expandedFolders" reference.
Now, let's look at the FolderComponent template. This is where the recursive magic happens:
<header class="header">
<a (click)="toggleFolder( folder )" class="header__toggle">
<svg
viewBox="0 0 32 32"
class="header__icon"
[class.header__icon--expanded]="isExpanded">
<path fill="currentColor" d="M24.291,14.276L14.705,4.69c-0.878-0.878-2.317-0.878-3.195,0l-0.8,0.8c-0.878,0.877-0.878,2.316,0,3.194 L18.024,16l-7.315,7.315c-0.878,0.878-0.878,2.317,0,3.194l0.8,0.8c0.878,0.879,2.317,0.879,3.195,0l9.586-9.587 c0.472-0.471,0.682-1.103,0.647-1.723C24.973,15.38,24.763,14.748,24.291,14.276z" />
</svg>
<span class="header__label">
{{ folder.name }}
</span>
</a>
</header>
<section *ngIf="isExpanded" class="contents">
<!-- Sub-folders. -->
<ul *ngIf="folder.folders.length" class="folders">
<li *ngFor="let subfolder of folder.folders" class="folders__item">
<!--
RECURSIVE COMPONENT: The Folder component is using itself to render the
nested folders within its own contents.
--
Notice that all "toggle" events from the sub-folders are simply being
passed-up through the event-emitter chain.
-->
<my-folder
[folder]="subfolder"
[expandedFolders]="expandedFolders"
(toggleFolder)="toggleFolder( $event )">
</my-folder>
</li>
</ul>
<!-- Files. -->
<ul *ngIf="folder.files.length" class="files">
<li *ngFor="let subfile of folder.files" class="files__item">
{{ subfile.name }}
</li>
</ul>
<!-- If there are NO folders and NO files, show an empty message. -->
<ng-template [ngIf]="( ! folder.folders.length && ! folder.files.length )">
<em class="empty">
Folder is empty.
</em>
</ng-template>
</section>
As you can see, as part of the FolderComponent rendering, the FolderComponent iterates over its own sub-folders and then uses itself (<my-folder>) to recursively render each sub-folder. Notice also that any (toggleFolder) event that is emitted from the recursive call is then used to trigger a local (toggleFolder) event. Essentially, in a recursive component, each EventEmitter becomes a chain of EventEmitters that bubble events back-up to the calling context (the AppComponent).
Now, if we load this Angular application in the browser and toggle some of the folders, we get the following output:
Isn't recursion just the bee's knees?! In this case, the recursion ends when a Folder either has no sub-folders; or, it is in a "collapsed" state and therefore does not render its own contents.
As I stated at the beginning of this exploration, my approach is just one of many valid approaches. The concept of the "isExpanded" state could have been baked easily into the actual Folder data-type. Or, it could have been baked into a derived data-type owned by the AppComponent. Each choice comes with different trade-offs. For my exploration, I opted to keep the data-structures immutable and track the expanded folders in a separate collection. Of course, the real goal of this post was to look at recursive component render in Angular 7.2.13; so, hopefully my choices don't distract too much from the primary focus.
Want to use code from this post? Check out the license.
Reader Comments
I'm doing same thing for nested json with Expand/Collapse. toggleSection2() is function in which hide/show logic is working based on two classes. My code is much simpler. You can use ng-Container as below:
1.) selectedRuleList : is your json data
2.) item.SubRuleList : is my child record data
3.) replace hyperlink with a tag
<ng-template #recursiveList let-selectedRuleList let-isParent="true">