Skip to main content
Ben Nadel at the jQuery Conference 2010 (Boston, MA) with: Eileen Koven
Ben Nadel at the jQuery Conference 2010 (Boston, MA) with: Eileen Koven

Sub-Classing NgForOf In Order To Make It A "Pure" Directive In Angular 7.2.13

By
Published in

Angular has done a wonderful job of making the native NgForOf directive fast and efficient. However, if you are working with immutable data, the NgForOf directive is doing more work than it has to. This is because the internal IterableDiffer is inspected on every single change-detection digest that is triggered in the parent View. But, if we know that our NgForOf collection is immutable, then we know that the IterableDiffer doesn't actually need to be inspected unless the top-level collection reference changes. In order to squeeze just a little bit more performance out of the NgForOf directive, we can sub-class it in Angular 7.2.13 and override the change-detection strategy, inspecting the NgForOf collection if and only if the Input bindings have been updated.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

Internally, the NgForOf structural directives defines the ngDoCheck() life-cycle method. This method gets invoked during every change-detection digest that is triggered in the parent View container. It's within this life-cycle method that the NgForOf directives checks to see if anything has changed in the [ngForOf] input collection. Which, of course, means that the input collection is checked on every singe change-detection digest.

To limit the degree to which the input collection is checked, we can sub-class the NgForOf directive and override the ngDoCheck() life-cycle method with a no-op (No Operation) method. This way, if a change-detection digest is triggered, our NgForOf sub-class won't incur any processing cost.

Of course, we do want to check the collection if the input bindings change; as such, our sub-classed NgForOf directive will define an ngOnChanges() life-cycle method, which will turn around and invoke the change-detection handler on the super class (ie, the built-in NgForOf directive).

To experiment with this idea, I'm going to create a directive called "ngPureForOf". Here, the "pure" nomenclature is intended to indicate that the directive responds only when its inputs are changed. These inputs include:

  • [ngPureForOf]
  • [ngPureForTrackBy]
  • [ngPureForTemplate]

Thanks to the input "aliasing" features of Angular, we don't actually have to code up any of these properties. All we have to do it tell Angular that these properties map to the public properties inherited by the NgForOf directive:

  • [ngPureForOf] is an alias for [ngForOf].
  • [ngPureForTrackBy] is an alias for [ngForTrackBy].
  • [ngPureForTemplate] is an alias for [ngForTemplate].

This way, we can use unique "ngPureForXXX" attributes within our Component templates, but leverage all of the existing properties in the underlying class hierarchy.

To see what I mean, let's look at my implementation of my NgPureForDirective sub-classing of the native NgForOf directive:

// Import the core angular services.
import { Directive } from "@angular/core";
import { DoCheck } from "@angular/core";
import { Input } from "@angular/core";
import { NgForOf } from "@angular/common";
import { NgForOfContext } from "@angular/common";
import { NgIterable } from "@angular/core";
import { OnChanges } from "@angular/core";
import { TemplateRef } from "@angular/core";
import { TrackByFunction } from "@angular/core";

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

@Directive({
	selector: "[ngPureFor][ngPureForOf]"
})
export class NgPureForDirective<T> extends NgForOf<T> implements OnChanges, DoCheck {

	// This directive is really a very very very thin wrapper around the existing NgForOf
	// directive. It's using all the inherited functionality; only, it needs to alias
	// the input bindings since we're using a slightly different selector "ngPureFor".
	// And, since the native NgForOf directive uses the @Input() decorator, we need to
	// use it as well in order to setup the aliases.

	@Input( "ngPureForOf" )
	public ngForOf!: NgIterable<T>;

	@Input( "ngPureForTrackBy" )
	public ngForTrackBy!: TrackByFunction<T>;

	@Input( "ngPureForTemplate" )
	public ngForTemplate!: TemplateRef<NgForOfContext<T>>;

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

	// I get called whenever change-detection is triggered.
	public ngDoCheck() : void {

		// In the native NgForOf directive, the Differ for the NgForOf collection is
		// checked on every change-detection digest. However, since we're turning this
		// into a "pure" directive (so to speak), we want to override the ngDoCheck()
		// method such that IT DOES NO WORK during arbitrary change-detection digests.

	}


	// I get called whenever one of the input bindings is changed.
	public ngOnChanges() : void {

		// The ngOnChanges() method will be called if the ngForOf, ngForTrackBy, or
		// ngForTemplate input bindings get changed. Since this is when we want to
		// actually perform our internal change-detection test, we can now turn around
		// and call the inherited ngDoCheck() method (on SUPER). This will inspect the
		// Differ and update the template rendering.
		super.ngDoCheck();

	}

}

As you can see, this sub-class of the native NgForOf directive does almost nothing. It simply aliases the input bindings to use the "pure" nomenclature. Then, it overrides the ngDoCheck() life-cycle method to cut down on the change-detection work, which it subsequently consumes if and only if the ngOnChanges() life-cycle method is invoked.

Now, let's see how this affects collection rendering. To demonstrate, I've created a simple App component that renders a collection of Users. The App component offers two gestures: changing the collection reference; and, changing the reference of each lower-level item:

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

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

interface User {
	id: number;
	name: string;
}

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p>
			<a (click)="swapItems()">Swap Items</a>
			&mdash;
			<a (click)="swapCollection()">Swap Collection</a>
		</p>

		<ul>
			<li *ngPureFor="let user of users">
				{{ user.id }} - {{ user.name }}
			</li>
		</ul>
	`
})
export class AppComponent {

	public users: User[];

	// I initialize the app component.
	constructor() {

		this.users = this.generateUsers();

	}

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

	// I keep the same data, but swap the top-level collection reference.
	public swapCollection() : void {

		this.users = this.users.slice();

	}


	// I keep the same top-level collection reference, but swap the low-level item
	// references. As the items are swapped, the IDs are incremented so that we can see
	// if the DOM nodes are being updated in the View.
	public swapItems() : void {

		for ( var i = 0, length = this.users.length ; i < length ; i++ ) {

			var user = this.users[ i ];

			// Mutate the users collection, swapping in a new item at the current index.
			this.users[ i ] = {
				id: ( user.id + 1 ),
				name: user.name
			};

		}

	}

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

	// I generate the collection of users using the given size.
	private generateUsers( count: number = 10 ) : User[] {

		var users: User[] = [];

		for ( var i = 0 ; i < count ; i++ ) {

			users.push({
				id: i,
				name: `User ${ i }`
			});

		}

		return( users );

	}

}

As you can see, our NgPureForOf syntax looks exactly like the NgForOf syntax; and that's because it is the NgForOf directive; only, with some behavioral modifications. In this case, it only updates the DOM (Document Object Model) tree if the "users" collection actually changes. That's why we see no affect when we call the .swapItems() method. The underlying item references are only reflected in the DOM tree when we call the .swapCollection() method, which will change the input bindings on our NgPureForOf directive:

NgPureForOf sub-classes the native NgForOf directive in Angular to make it respond to input changes only.

As you can see, the changes created by the .swapItems() method aren't propagated to the View until we call .swapCollection(). At that point, the input bindings of our NgPureForOf directive are changed, which causes the ngDoCheck() method on the super-class (NgForOf) to be invoked.

This approach is still running change-detection on the NgPureForOfTemplate content itself. So, if we changed the ID or Name property of each user without changing any references, those changes would still be propagated to the view. This is because NgPureForOf directive is operating within the App component's change-detection context. If we wanted to prevent this, we could wrap our list inside of a Component that uses an OnPush change detection strategy. This would be the most performant solution. But, would require a little bit more complexity. The intent of the NgPureForOf directive is only to add a micro-optimization on top of the native NgForOf directive when immutable data structures are being used.

If nothing else, I hope this was a fun exploration that demonstrated how native Angular constructs can be extended and customized for fun and profit.

Epilogue On Input Aliasing

Yesterday, I demonstrated that the @Directive().inputs decorator and the @Input() decorator are not functionally equivalent in Angular. This was the context in which is discovered that. At first, when defining my NgPureForOf directive, I attempted to alias the NgForOf inputs within the @Directive() decorator. Unfortunately, the @Input() decorators in the super-class were taking precedence; so, I had to drop down to using @Input() decorators in my sub-class.

Want to use code from this post? Check out the license.

Reader Comments

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel