Components

Directive Best Practices

There are many articles online about Angular best practices, or even best practices for components specifically. Of course, components are the most important building blocks of the framework, but we know that directives are almost as important, and when it comes to them, there are also certain patte

Directive Best Practices — Components article by Armen Vardanyan on Angular In Depth
Directive Best Practices — Components article by Armen Vardanyan on Angular In Depth
On this page · 7 sections

Countless articles explore Angular best practices, often zooming in on components. While components are undeniably the core structural units of the framework, directives run a close second in importance, and they too come with their own set of preferable coding patterns.

Today, we'll explore those directive-specific best practices, aiming to craft the cleanest code for enriching our templates.

Align input aliases with directive selectors

A recurring pattern for directives is having a single input that drives a specific behavior. Developers frequently name this input the same as the directive's attribute selector to keep templates clean. Consider a basic directive for displaying a tooltip:

@Directive({
    selector: '[appTooltip]',
})
export class TooltipDirective implements AfterViewInit {
    // input for custom text 
    appTooltip = input.required<string>();

    ngAfterViewInit() {
        // code that adds the tooltip
    }
}

This naming convention makes the template usage very straightforward:

<div appTooltip="Some text">Content</div>

However, the directive's internals suffer because appTooltip isn't descriptive, potentially confusing code readers. To maintain clarity in both template and directive code, we can alias the input to match the attribute selector:

@Directive({
    selector: '[appTooltip]',
})
export class TooltipDirective implements AfterViewInit {
    // aliased input for custom text 
    tooltipText = input.required<string>({alias: 'appTooltip'});

    ngAfterViewInit() {
        // code that adds the tooltip
    }
}

The template usage remains unchanged, but the directive's code becomes more readable.

Leverage a wider range of selectors

Expanding on our selector discussion, a widespread (anti)pattern is inventing a unique custom attribute for every new directive. But, as we will discover, this approach is often unnecessary.

Take this directive, which validates an email address on an input field:

@Directive({
    selector: '[appEmailValidator]',
})
export class EmailDirective implements Validator {
    validate(control: AbstractControl) {
        // validate with some custom regex
        return control.value.match(/.+@.+\..+/) ? null : {
            email: true,
        };
    }
}

It could be applied to email inputs like so:

<input type="email" appEmailValidator>

Yet, directive selectors are much more powerful than just simple attributes. Instead of cluttering inputs with an appEmailValidator attribute, we can directly target all inputs of type "email":

@Directive({
    selector: 'input[type=email]',
})
export class EmailDirective implements Validator {...}

Consequently, any input with type="email" will have the validator applied automatically, provided the directive is imported where needed.

Use host meta for dynamic styling

Applying conditional styles to a host element is a common directive task. Often, this is done "manually." Let's examine a directive that blocks the UI for elements when a user lacks a permission:

@Directive({
    selector: '[appBlockUI]',
})
export class BlockUIDirective implements OnInit {
    private readonly permissionsService = inject(PermissionService);
    private readonly elementRef = inject(ElementRef);

    permissionName = input.required<string>({alias: 'appBlockUI'});

    ngAfterViewInit() {
        this.permissionsService.getPermissions()
            .subscribe(permissions => {
                if (!permissions.has(this.permissionName())) {
                    this.elementRef.nativeElement.style.opacity = '0.5';
                    this.elementRef.nativeElement.style.pointerEvents = 'none';
                } else {
                    this.elementRef.nativeElement.style.opacity = '1';
                    this.elementRef.nativeElement.style.pointerEvents = 'auto';
                }
            });
    }
}

While this works, the code is heavily "imperative," directly manipulating the DOM, which goes against common Angular practices. This can be entirely avoided. By using signals and the RxJS interop, we can rely on the host metadata property to declaratively bind results from the service:

@Directive({
    selector: '[appBlockUI]',
    host: {
        '[style.pointerEvents]': 'styles().pointerEvents',
        '[style.opacity]': 'styles().opacity',
    },
})
export class BlockUIDirective {
    private readonly permissionsService = inject(PermissionService);

    permissionName = input.required<string>({alias: 'appBlockUI'});
    permissions = toSignal(this.permissionsService.getPermissions());
    styles = computed(() => {
        const hasPermission = this.permissions().has(this.permissionName());
        return ({
            pointerEvents: hasPermission ? 'auto' : 'none',
            opacity: hasPermission ? 1 : 0.5,
        });
    });
}

As shown, this version is much more readable and maintainable. Directives can often be even more declarative than components, making code clarity a top priority.

Avoid boolean inputs for conditional application

Sometimes, a selector may match all desired elements but also catch scenarios where the directive isn't wanted. For instance:

@Directive({
    selector: '[routerLink]',
    host: {
        '(mouseover)': 'showPreview()',
        '(mouseout)': 'hidePreview()',
    },
})
export class PreviewLinkDirective {
    routerLink = input.required<string>();

    showPreview() {
        // some logic that generates a preview on hover
    }

    hidePreview() {
        // some logic that hides the preview on mouseout
    }
}

This directive shows a link preview on hover and hides it on mouse-out. However, we might want to disable the preview for specific links, like a login page where it makes little sense. One possible solution is:

@Directive({
    selector: '[routerLink]',
    host: {
        '(mouseover)': 'showPreview()',
        '(mouseout)': 'hidePreview()',
    },
})
export class PreviewLinkDirective {
    routerLink = input.required<string>();
    showPreview = input(true);

    showPreview() {
        if (this.showPreview()) {
            // some logic that generates a preview on hover
        }
    }

    hidePreview() {
        if (this.showPreview()) {
            // some logic that hides the preview on mouseout
        }
    }
}

This allows passing a false value to disable the preview logic. This approach, however, has two significant drawbacks:

  1. The directive becomes unnecessarily verbose, as we'll see.
  2. The directive is still instantiated; if a new host listener is added later and forgets to check the showPreview input, it could introduce a bug.

A better solution? We can use the :not() CSS selector, which directive selectors support, to exclude the directive entirely from certain elements. Here's how:

@Directive({
    selector: '[routerLink]:not([noPreview])',
    host: {
        '(mouseover)': 'showPreview()',
        '(mouseout)': 'hidePreview()',
    },
})
export class PreviewLinkDirective {
    routerLink = input.required<string>();

    showPreview() {
        // some logic that generates a preview on hover
    }

    hidePreview() {
        // some logic that hides the preview on mouseout
    }
}

And in the template, for links that shouldn't have a preview, we simply add the attribute:

<a routerLink="some-link" noPreview>Some link</a>

Now, the directive is never applied to those links, eliminating the need for any conditional logic inside it.

Important to know

While this approach is the best practice in the vast majority of cases, it's not without exceptions. Boolean inputs that disable directive functionality are still sometimes required.

To understand why, we need to examine how Angular directives actually work. The reality is more nuanced than it appears.

It might seem that Angular searches the DOM for elements matching a selector and then applies the directive. But this is not how it works at all.

Angular directives are applied to DOM elements at compile-time, not run-time. This means that when executing ng serve or ng build, Angular compiles templates into executable JavaScript, considering the directives imported by the component. If a matching element is found, the directive is applied then.

This distinction has profound implications. Most importantly, directives cannot be applied dynamically. Even using Renderer2 to add an attribute that matches a directive selector won't cause Angular to recognize and apply it at runtime.

This also means that using a negation selector (like :not(noPreview) in our example) permanently excludes the element from that directive. There is no dynamic way to re-apply it later.

In most scenarios where this pattern is used, the intent is to permanently exclude an element, perhaps because the directive selector is too broad for specific cases, hence the negation.

However, if the directive's logic depends on an external, changing condition, and we need the logic to re-execute, then using a boolean input is entirely justified.

Prefer plugins over directives for custom events

There may be times when we want a directive to emit a custom event from its host element. For example, an application might heavily utilize keyboard shortcuts like Ctrl + Click. We might be tempted to write a directive for this:

@Directive({
    selector: '[appCtrlClick]',
    host: {
        '(click)': 'handleClick($event)',
    },
})
export class CtrlClickDirective {
    appCtrlClick = output<MouseEvent>();

    handleClick(event: MouseEvent) {
        if (event.ctrlKey) {
            this.appCtrlClick.emit(event);
        }
    }
}

It could be used in a template like this:

<button (appCtrlClick)="log($event)">Content</button>

While functional, this has several drawbacks:

  1. The directive isn't generic; handling other event combinations would require a new directive or added complexity.
  2. It must be imported everywhere it's used.
  3. Angular provides a more conventional method for these cases.

Instead of a directive, we can use the EventManagerPlugin to define a custom plugin for Ctrl + Click:

export class CtrClickPlugin extends EventManagerPlugin {
  override supports(eventName: string): boolean {
    // catch all ctrl.click events
    return eventName === 'ctrl.click';
  }

  override addEventListener(
    element: HTMLElement,
    eventName: string,
    originalHandler: EventListener
  ) {
    // wrap the original handler with Ctrl check 
    const handler = (event: MouseEvent) => {
      if (event.ctrlKey) {
        originalHandler(event);
      }
    };
    element.addEventListener('click', handler);

    return () => {
      // remove the listener 
      element.removeEventListener('click', handler);
    };
  }
}

Next, we provide this plugin in the application config:

export const appConfig: ApplicationConfig = {
    providers: [
        { 
            provide: EVENT_MANAGER_PLUGINS, 
            useClass: CtrClickPlugin, 
            multi: true,
        },
    ],
}  

Then, we can use the event in any component template:

<button (ctrl.click)="log($event)">Content</button>

This makes the behavior fully reusable, simplifies customization (you can create multiple plugins or route events like Ctrl + Enter, Ctrl + Right Click, etc., within the same plugin), and keeps it outside the component logic.

Conclusion

Directives are a powerful and versatile feature, but they can also be misused. By following the guidelines set out in this article, we hope readers will be able to build simpler, more robust, and more understandable directives, fully harnessing their potential.


Directive Best Practices — figure 1
AV
Armen Vardanyan

Writes about RxJS, State, Dependency Injection. Active 2019–2026.

All 57 articles →