Wrapping Immutable Arrays In Mutable Arrays For Easier Processing In Angular 8.2.0-next.0
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:
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.
Want to use code from this post? Check out the license.
Reader Comments
The title of this article makes my head hurt.
@Tim,
Ha ha, just let it marinate for while :D
Hi. We do a lot of transformation on data we receive via APIs where I work, but our concept of immutability is different from above I think.
When we retrieve data from a service, the collection is mapped and each object cloned before being returned (using lodash/fp - compose map assign etc)
If we add
isVisible
, we do so withfp.assign
so again a new object is returned. We have a new type as well, which would be aninterface INewModel extends IOriginalModel { isVisible: boolean}
or something to that effectIt's the same as what you're doing really, but it's less explicit. Basically no object is ever mutated directly. Works really well with OnPush, and cuts down on weird bugs too??
@Brian,
I've definitely used that approach with good success. The issues that we would run into would be that some services would clone objects before returning them, and other services would be passing-through the original reference. This inconsistency lead to some very mysterious bugs where direct mutations were accidentally propagated to a cached-value, which made subsequent renderings all funky.
Of course, it was definitely a bug (on behalf of the devs); so, more consistent cloning would have fixed the issue.
I think both approaches work well, with some slightly different trade-offs. But, for sure, all of this really helps
OnPush
optimizations.