Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at NCDevCon 2016 (Raleigh, NC) with: Dan Skaggs
Ben Nadel at NCDevCon 2016 (Raleigh, NC) with: Dan Skaggs@dskaggs )

Wrapping Immutable Arrays In Mutable Arrays For Easier Processing In Angular 8.2.0-next.0

By Ben Nadel on
Tags: ColdFusion

The other day, in my post about fat-arrow and lambda expression support in Lucee 5.3.2.77, I wrapped one Array inside another Array so that I could more easily sort the original Array using a "natural sort". This pattern, of wrapping one Array inside another one for local manipulation, is one that I've begun to use more and more, especially in my Angular code. I've been finding that it makes a lot of operations easier to implement and to understand and maintain. As such, I wanted to share this approach.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

Before we look at this wrapping approach, let me first touch on my old approach to "augmenting" collection functionality. It used to be, when my Angular view would receive a result-set from some API call, I would augment the result-set with view-specific properties:

var projects = await service.getProjects();

for ( var project of projects ) {

	// Add view-specific properties to the result-set so that we can more easily
	// manipulate and consume the data in the local view.
	project.isVisible = true;
	project.isSelected = false;

}

Now, this approach works quite well if the result-set you are receiving from the API call is completely isolated. That is, the object references aren't attached to some cache or other shared-memory space. And, in fact, I've used this approach with much success over the past 7-years of Angular / Angular.js development.

But, there's something about this approach that always felt a bit "dirty" - like I was mixing "concerns" in my Angular component. My new approach, of wrapping collections, feels cleaner; it feels like I have the right data doing the right job. And, that I have the right balance of immutable and mutable functionality.

In my new approach, the same API result-set would get handled like this:

var projects = await service.getProjects();

var results = projects.map(
	( project ) => {

		return({
			isVisible: true,
			isSelected: false,

			// Wrapping the "projects" collection inside this "results" collection.
			project: project
		});

	}
);

Here, instead of mutating the "projects" collection directly, I'm wrapping it inside a "results" collection that is designed to be used by the component's template. Now, instead of the component rendering the "list of projects", it renders the "list of results", which happens to contain a project property.

In the end, we get the same exact functionality; but, the "intent" feels cleaner. I'm no longer rendering "projects", I'm rendering "results". It's a slightly different mental model; but, one that allows the data to be manipulated in a more natural way.

Plus, I can now mutate the results collection in-place without having to worry about the underlying data references. This is perfect for when the underlying data is coming out of a cache or state-store like Redux or my SimpleStore using an RxJS BehaviorSubject. We maintain the immutable data that is shared; but, we allow for the efficiencies of in-place mutation for the local view-model.

To see this more clearly, I've put together an Angular 8 demo in which we retrieve a collection of Friends and then provide the user with the ability to sort and filter that collection. The sorting and filtering is applied to a "wrapper" collection, leaving the underlying Friend objects unchanged.


// Import the core angular services.
import { Component } from "@angular/core";

// Import the application components and services.
import { Friend } from "./friend.service";
import { FriendService } from "./friend.service";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// The FriendResult interface is the View-model that wraps the Friend and makes it easier
// to consume for this particular component. This way, we can leave the Friend data as an
// immutable data-structure, while augmenting / adding functionality that is optimized
// for the interactions in this view. Not the least of which is the fact that the results
// themselves can be MUTATED DIRECTLY, making operation easier.
interface FriendResult {
	// These are the View-specific augmentations for the collection.
	isVisible: boolean;
	keywords: string[];
	sortable: {
		name: string;
		createdAt: number;
	};

	// This is the IMMUTABLE object that we are wrapping.
	friend: Friend;
}

type SortType = "name" | "createdAt";

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p>
			Search:
			<input
				type="text"
				autofocus
				(input)="handleFilterOn( $event.target.value )"
			/>
		</p>

		<p>
			Sort On:
			<a (click)="handleSortOn( 'name' )">Name</a>,
			<a (click)="handleSortOn( 'createdAt' )">Created</a>
		</p>

		<ul class="results">
			<li
				*ngFor="let result of results"
				class="result"
				[hidden]="( ! result.isVisible )">

				<app-friend-card
					[name]="result.friend.name"
					[email]="result.friend.email">
				</app-friend-card>

			</li>
		</ul>
	`
})
export class AppComponent {

	public results: FriendResult[];

	private friends: Friend[];
	private friendService: FriendService;

	// I initialize the app component.
	constructor( friendService: FriendService ) {

		this.friendService = friendService;

		this.friends = [];
		this.results = [];

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I handle the filter on the given query.
	public handleFilterOn( query: string ) : void {

		this.filterResults( this.results, query );

	}


	// I handle the sort on the given field.
	public handleSortOn( field: SortType ) : void {

		this.sortResults( this.results, field );

	}


	// I get called once after the inputs have been bound for the first time.
	public ngOnInit() : void {

		this.friendService.getFriends().then(
			( friends ) => {

				this.friends = friends;
				// We aren't going to render the Friends collection directly in the view.
				// Instead, we are going to wrap it in a "results" collection that we can
				// more efficiently manipulate for our view-behaviors.
				this.results = this.buildResults( friends );

			}
		);

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I build the MUTABLE results collection that wraps the IMMUTABLE friends
	// collection.
	private buildResults( friends: Friend[] ) : FriendResult[] {

		var mappedFriends = friends.map(
			( friend ) => {

				return({
					// The augmented functionality bits.
					isVisible: true,
					keywords: [
						this.normalizeForSearch( friend.name ),
						this.normalizeForSearch( friend.email )
					],
					sortable: {
						name: friend.name.toUpperCase(),
						createdAt: friend.createdAt.getTime()
					},

					// The immutable item from Friends that we are "wrapping".
					friend: friend
				});

			}
		);

		return( this.sortResults( mappedFriends, "name" ) );

	}


	// I filter the given results set using the given query. Returns collection.
	private filterResults( results: FriendResult[], query: string ) : FriendResult[] {

		// The results collection data has already been normalized for search. As such,
		// we can now normalize our search query so that we can consume it more easily.
		// This minimized the amount of processing we have to do for each search.
		var normalizedQuery = this.normalizeForSearch( query );

		// As we iterate over the results, notice that we are MUTATING the collection
		// directly. We can do this since we're never passing the collection into a
		// context that depends on reference-based change-detection (ngFor always checks
		// the ngForOf collection). That said, we are never mutating the underlying
		// Friend reference; that remains IMMUTABLE.
		for ( var result of results ) {

			// If there's no query, reset the visibility of the result.
			if ( ! normalizedQuery ) {

				result.isVisible = true;
				continue;

			}

			// If we have a search query, hide all results by default; then, show a
			// result only if it matches the given input query.
			result.isVisible = false;

			for ( var keyword of result.keywords ) {

				if ( keyword.includes( normalizedQuery ) ) {

					result.isVisible = true;
					continue;

				}

			}

		}

		return( results );

	}


	// I normalize the given input for search filtering.
	private normalizeForSearch( input: string ) : string {

		return( input.toUpperCase() );

	}


	// I sort the given results set using the given sort-type. Returns results.
	private sortResults( results: FriendResult[], field: SortType ) : FriendResult[] {

		switch ( field ) {
			case "createdAt":
				var sortDirection = 1;
			break;
			default:
				var sortDirection = -1;
			break;
		}

		// Sort the results IN PLACE. Since this is being used by ngFor, we don't need
		// to worry about making the results collection immutable; ngFor is going to
		// iterate over it on every digest anyway.
		results.sort(
			( a, b ) => {

				if ( a.sortable[ field ] < b.sortable[ field ] ) {

					return( sortDirection );

				} else if ( a.sortable[ field ] > b.sortable[ field ] ) {

					return( -sortDirection );

				} else {

					return( 0 );

				}

			}
		);

		return( results );

	}

}

Notice that the App component doesn't render the Friend collection directly - it renders the FriendResult collection. This FriendResult collection wraps the underlying Friend collection and provides view-specific functionality for sorting and filtering. And, when the user sorts or filters the list, notice that we are mutating the results collection directly rather than jumping through hoops trying to keep all objects immutable. We can do this because the FriendResult collection is encapsulated entirely within the App component.

Now, if we run this Angular application in the browser and supply a search query, we get the following output:

Filtering a collection using an in-place mutation of a wrapper collection in Angular 8.

As I type, the value of the search query is being used to mutate the isVisible property on the FriendResult collection. This allows me to efficiently affect the results - a view-only concern - without altering the underlying Friend collection in any way.

ASIDE: This works because I know that the ngFor directive in the component template will always re-check the collection. As such, I have no fear of mutating the results in-place. If, however, I was passing the results collection out of scope, such as part of an input-binding to another component, I may have to switch to treating the results as immutable. But, doing so before it is needed is a premature optimization.

In an Angular context, much of this change feels like a shift in the way that I think about the data that is being rendered. I'm not rendering the "core" data - I'm rendering the "results" that reference the "core" data. This is an elevation of the results as a first-class citizen of the component.

But, this doesn't just apply to Angular; more generally, this is the wrapping of one Collection inside another Collection for the purposes of local consumption. In this Angular context, I'm using the approach to sort and filter a list of Friends; but, in my previous Lucee context, I was using the same type of approach to apply a "natural sort" to a normalized set of strings.



Reader Comments

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.