Building a Lightweight Context Help Component in Angular
Suppose we want a reusable context help component that can be attached as an attribute to any DOM element, like this:
<h3 context-help="Some description 1">Some title 1</h3>
Internally, the component should append a help icon at the end of the wrapped content. When a user clicks that icon, a help dialog should pop up. The dialog should vanish when the user clicks anywhere outside of it or presses the Escape key. That's the whole requirement.

We aim to stick to pure Angular code and skip any third-party dependencies. Let's get to it.
To make this work, we will take advantage of <ng-content> to keep the wrapped content intact. Additionally, we’ll build a separate container that houses both the icon and the dialog.
The full template looks like this:
<ng-content></ng-content>
<div class="context-help-container" #container>
<i (click)="showHelp = true;"></i>
<div *ngIf="showHelp" class="context-help-dialog">{{ content }}</div>
</div>
Decorators have become second nature in Angular development, so it's tempting to apply them liberally. Here, we'll rely on @HostListener to handle the interactions. This should be fairly straightforward — you've probably written similar code before:
import {
Component,
OnInit,
Input,
ElementRef,
HostListener,
ViewChild,
} from '@angular/core';
@Component({
selector: '[context-help]',
templateUrl: './context-help.component.html',
styleUrls: ['./context-help.component.css'],
})
export class ContextHelpComponent {
@Input('context-help') content: string;
@ViewChild('container') containerRef: ElementRef;
showHelp = false;
@HostListener('document:click', ['$event'])
documentClicked({ target }: MouseEvent) {
if (!this.containerRef.nativeElement.contains(target)) {
this.showHelp = false;
}
}
@HostListener('window:keydown.Escape')
escapedClicked(): void {
this.showHelp = false;
}
}
At first glance, everything appears fine — until a performance problem sneaks in.
Where Is the Catch?
If we only have a handful of ContextHelp instances, we might never notice an issue. But in practice, pages often end up with 100 or more such attributes.
A common first step for me when profiling an Angular app is to drop a log statement inside the ApplicationRef.tick() method:

It's worth recalling that any event listener registered within the Angular Zone will prompt a change detection pass over the component tree.
What does that mean in our scenario?
We've attached two HostListeners that listen for global events. Every click on the document or every Escape key press triggers a change detection cycle. As the number of ContextHelpComponent instances grows, so does the volume of change detection runs.
Here's what occurs when 100 context-help attributes are present on a page:

If you're not already using onPush change detection across most of your components, you're likely running into trouble.
Potential Fixes
To curb these extra change detection cycles, several approaches come to mind:
- event coalescing feature
Once this feature is enabled:
platformBrowser().bootstrapModule(AppModule, { ngZoneEventCoalescing: true });
Angular will delay the change detection cycle, scheduling it via requestAnimationFrame. This means only a single tick gets executed.
For a deeper dive, be sure to read the excellent piece by Netanel Basal.
- a reusable clickOutside directive that shares the same pair of @HostListener's
In this scenario, we delay subscribing to those events until the dialog is actually shown in the DOM.
- dropping @HostListeners from the TypeScript code and moving the subscriptions into the template via (output) events on the dialog:
<div
*ngIf="showHelp"
class="context-help-dialog"
(document:click)="documentClicked($event)"
(window:keydown.Escape)="escapedClicked()"
>
{{ content }}
</div>
This works like the prior solution — Angular only attaches global listeners when the dialog becomes visible.
That's all there is to it. Happy coding!
