Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Manil Chowdhury
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Manil Chowdhury

Creating A Transient View Helper In Angular 18

By
Published in

One of the really nice features of Angular 18 is the ability to use a DestroyRef injectable to define your component's clean-up logic. This allows your setup and teardown logic to be collocated within the ngOnInit() life-cycle method. In order for the DestroyRef functionality to work, its own life-cycle has to be married to the host component's life-cycle. Which got me thinking about creating my own transient "View Helper" service that might make other workflows easier.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

In addition to the teardown logic which has been greatly simplified by the DestroyRef service, two other common gestures spring to mind:

  • Reading and consuming the live document after a view-model change has been reconciled with the DOM (Document Object Model).

  • Clearing pending timers that no longer make sense after a component has been destroyed.

Let's look at these concepts in turn.

DOM Reconciliation

Angular works by maintaining a view-model that is used to drive the state of the DOM. When the view-model is updated, Angular reconciles the DOM with the view-model by adding and removing elements, updating attributes, and replacing text nodes. This is akin to the way in which React uses a virtual-DOM to drive the state of the page.

This approach greatly reduces the mechanical work that the developer needs to perform. But, it also means that accessing the underlying DOM tree comes with some caveats. The biggest of which being that when the developer changes the view-model, the DOM doesn't actually change until some point in the future (during the reconciliation phase).

Historically, Angular developers have gotten around this by using a setTimeout() to defer DOM access. Everyone agreed that this was a super janky approach; but, it was the best approach that we had available to us.

As of Angular 18, there's now a native solution to this problem: afterNextRender(). This method allows us to register a callback to be executed after Angular's next reconciliation phase. Which means, when we need to access new DOM structures based on the latest view-model changes, we can now do this safely within a afterNextRender() callback.

Note: The afterNextRender() callback will only execute once and is then implicitly deregistered.

The problem with the afterNextRender() method is that it relies on dependency-injection (DI) internally. Which means, it has to be called from within an injection context (such as a component constructor). But, oftentimes in our application, we need to react to view-model changes outside of an injection context.

And, this is where my View Helper comes into play. I want my view helper to expose a .tick() method which hides this stumbling block. Essentially, I want my view helper to ensure that the afterNextRender() method is always invoked inside an injection context regardless of what's happening in the calling context.

To do this, it will use runInInjectionContext() and provide the same Injector that the view helper is using:

export class ViewHelper {

	// ... truncated ...

	/**
	* I run the given callback after the next view-model reconciliation.
	*/
	public tick( callback: VoidFunction ) : VoidFunction {

		var afterRenderRef = runInInjectionContext(
			this.injector,
			() => {

				return afterNextRender( callback );

			}
		);

		// Store the callback to clear this render-timer in ngOnDestroy().
		return this.destroyables[ "tick:afterRenderRef" ] = () => {

			afterRenderRef.destroy();

		};

	}

}

As you can see, the passed-in callback is being passed onto the native afterNextRender() method. But, this operation is being performed inside the runInInjectionContext() method in order to ensure dependency-injection is available within the afterNextRender() logic.

Of course, there's no guarantee that the host component will still be relevant by the time the next render occurs. As such, we want to make sure that we call the afterRenderRef.destroy() function when the host component is destroyed (in order to prevent any unwanted side-effects). And that's where the view helper really starts to shine.

Since thew view helper is transient—that is, it lives and dies with the host element—its own life-cycle methods line-up with the host elements life-cycle methods. Which means, the view helper can expose its own ngOnDestroy() life-cycle hook.

If you look in the above code, we're storing .destroy() calls in a collection called destroyables. This collection will be "flushed", so to speak, inside the view helper's ngOnDestroy() method:

export class ViewHelper {

	// ... truncated ...

	/**
	* I get called once when this SERVICE is being destroyed; which, in this case, since
	* the service is being provided as a view-specific injectable, is when the associated
	* component (and its injector) are being destroyed. This gives us a chance to clean-up
	* and view-specific helpers.
	*/
	public ngOnDestroy() {

		for ( var [ name, destroy ] of Object.entries( this.destroyables ) ) {

			destroy();

		}

	}

}

Notice that this loop doesn't care what the destroy() is doing, it only cares that it has a collection of Functions that it can invoke. This generic nature makes it helpful for other transient workflows, such as timers.

Pending Timers

Timers are typically used to represent a state change in the future. For example, we might need to show a success message to the user; and then, remove this message in a few seconds. When using timers, there are usually two points of friction:

  1. A pending timer often needs to be reset based on a user action.

  2. A pending timer often needs to be canceled when the host component is destroyed.

As we saw a above, the view helper is wired into the host component's life-cycle. This makes canceling pending timers on component destruction rather straightforward—we just need to add the clearTimeout() call to the internal destroyables collection.

And, the workflow of resetting (ie, canceling and then setting) a timer can be encapsulated within a view helper function:

export class ViewHelper {

	// ... truncated ...

	/**
	* I clear and then set the timer with the given name.
	*/
	public resetTimeout(
		name: string,
		operator: Function,
		delay: number
		) : VoidFunction {

		this.destroyables[ name ]?.call( null );

		var timeoutID = setTimeout( operator, delay );

		// Store the callback to clear this timer in ngOnDestroy().
		return this.destroyables[ name ] = () => {

			clearTimeout( timeoutID );

		};

	}

}

Since the reset logic is being pushed into the view helper, the calling context needs to provide a name for the helper. This way, when clearing the timer, the view helper will know which timer is being referenced.

A Transient View Helper

Here's the full code of my attempt at a transient view helper. Notice that I've included a few console.log() calls so that we can see the setup and teardown events as they happen.

// Import vendor modules.
import { afterNextRender } from "@angular/core";
import { inject } from "@angular/core";
import { Injectable } from "@angular/core";
import { Injector } from "@angular/core";
import { runInInjectionContext } from "@angular/core";

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

interface Destroyables {
	[ key: string ]: VoidFunction;
}

@Injectable()
export class ViewHelper {

	private destroyables: Destroyables = Object.create( null );
	// This is the injector associated with the host component.
	private injector = inject( Injector );

	/**
	* I handle the service construction.
	*/
	constructor() {

		console.log( "View Helper Construction." );

	}

	// ---
	// LIFE-CYCLE METHODS.
	// ---

	/**
	* I get called once when this SERVICE is being destroyed; which, in this case, since
	* the service is being provided as a view-specific injectable, is when the associated
	* component (and its injector) are being destroyed. This gives us a chance to clean-up
	* and view-specific helpers.
	*/
	public ngOnDestroy() {

		console.group( "View Helper Destruction." );

		for ( var [ name, destroy ] of Object.entries( this.destroyables ) ) {

			console.log( name );
			destroy();

		}

		console.groupEnd();

	}

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

	/**
	* I clear and then set the timer with the given name.
	*/
	public resetTimeout(
		name: string,
		operator: Function,
		delay: number
		) : VoidFunction {

		this.destroyables[ name ]?.call( null );

		var timeoutID = setTimeout( operator, delay );

		// Store the callback to clear this timer in ngOnDestroy().
		return this.destroyables[ name ] = () => {

			clearTimeout( timeoutID );

		};

	}


	/**
	* I run the given callback after the next view-model reconciliation.
	*/
	public tick( callback: VoidFunction ) : VoidFunction {

		var afterRenderRef = runInInjectionContext(
			this.injector,
			() => {

				return afterNextRender( callback );

			}
		);

		// Store the callback to clear this render-timer in ngOnDestroy().
		return this.destroyables[ "tick:afterRenderRef" ] = () => {

			afterRenderRef.destroy();

		};

	}

}

To see this ViewHelper in action, I've created a small list component that can be adding and removed from the root view. This will allow us to clearly see that the ViewHelper lives and dies along with its host component.

This list component will exercise both aspects of the ViewHelper. It will use the tick() method to scroll-down to the mostly recently-added item (after the view has been reconciled). And, it will use the resetTimeout() method to temporarily show a toast message after an item is added.

First, let's look at the demo template:

<h2>
	Items
</h2>

<form (submit)="addItem()">
	<input type="text" [(ngModel)]="form.newItem" name="newItem" />
	<button type="submit">
		Add Item
	</button>
</form>

<ul>
	@for ( item of items ; track item.id ) {

		<li
			id="item-{{ item.id }}"
			[class.selected]="( latestItemID === item.id )">
			{{ item.name }}
		</li>

	}
</ul>

@if ( toast ) {

	<p class="toast">
		{{ toast }}
	</p>

}

Notice that each <li>—within the @for() loop—has an item-specific id attribute. After each item is added to the underlying view-model, Angular will take a beat to render the new list item into the DOM; and, when it does, we'll use this id to select and then scroll-to the given item.

After each item is added, a temporary toast message (success) message is show to the user. This toast will be visible for 3 seconds; and then will be removed. But, if the user adds a new item inside that 3 second window, the same toast will remain in place and only its text will change.

Here's the demo component logic. All of the interesting stuff happens inside the addItem() method. But, notice that I'm using the component-level providers[] collection to provide the ViewHelper. This is what allows each instance of the component to receive its own instance of the ViewHelper (as opposed to be cached in the root injector).

// Import vendor modules.
import { Component } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { inject } from "@angular/core";

// Import app modules.
import { ViewHelper } from "./view-helper.service";

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

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

interface ItemForm {
	newItem: string;
};

@Component({
	selector: "app-demo",
	standalone: true,
	// By providing the ViewHelper in the component-level providers, it will uniquely
	// instantiate the service for each instance of this component. This is important
	// because it means that the ViewHelper service will also be DESTROYED when this
	// component is destroyed, which gives the ViewHelper full-access to the component's
	// life-cycle (construction and destruction).
	providers:[
		ViewHelper
	],
	imports: [
		FormsModule
	],
	styleUrl: "./demo.component.less",
	templateUrl: "./demo.component.html"
})
export class DemoComponent {

	private viewHelper = inject( ViewHelper );

	public items: Item[] = [
		{ id: 1, name: "One" },
		{ id: 2, name: "Two" },
		{ id: 3, name: "Three" },
		{ id: 4, name: "Four" },
		{ id: 5, name: "Five" },
		{ id: 6, name: "Six" }
	];
	public latestItemID: number = 0;
	public toast: string = "";
	public form: ItemForm = {
		newItem: ""
	};

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

	/**
	* I process the new item form and add a new item to the collection.
	*/
	public addItem() {

		if ( ! this.form.newItem ) {

			return;

		}

		var newItem = {
			id: Date.now(),
			name: this.form.newItem
		};

		this.form.newItem = "";
		this.items.push( newItem );
		this.latestItemID = newItem.id;
		this.toast = `Your item (${ newItem.name }) has been added!`;

		// After we add the new item, we need to give Angular a chance to reconcile the
		// view and the view-model. If we try to scroll to the new item too early, it
		// won't yet exist in the DOM. We have to wait until Angular has finished the next
		// render before we know that our <LI> will be accessible.
		this.viewHelper.tick(
			() => {

				document.querySelector( `#item-${ newItem.id }` )
					?.scrollIntoView({
						behavior: "smooth",
						block: "center"
					})
				;

				console.log(
					`%cScrolling to new item (${ newItem.name }).`,
					"color: #999999; font-style: italic"
				);

			}
		);

		// After the new item is added, we're showing a toast message. In a few seconds,
		// we need to hide the toast message. However, if the user adds several items in
		// quick succession, we want to CLEAR and RESET the timer so that we don't close
		// the latest toast message too quickly.
		this.viewHelper.resetTimeout(
			"clear-toast",
			() => {

				this.toast = "";

				console.log(
					"%cClearing toast message.",
					"color: #999999; font-style: italic"
				);

			},
			3000
		);

	}

}

To see this in action take a look at the video above. I walk through both the UI experience and the evidence provided by the console logging. In the meantime, you can see from the following GIF that the ViewHelper is indeed instantiated and then destroyed along with the demo component:

Console logging showing that as the demo component is toggled, the ViewHelper is created and then subsequently destroyed along with the host component.

These feel like pretty common problems to me. But, I'm always surprised when I find that other people approach application in a radically different way. I'm curious to hear how other people are currently solving these kind of timing problems in Angular.

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

Reader Comments

Post A Comment — I'd Love To Hear From You!

Post a Comment

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