Angular directives often fly under the radar, likely because their full potential isn't widely understood. Most Angular developers are comfortable with the ubiquitous structural directives like *ngIf and *ngFor, but custom directives are a rarer sight. When faced with a recurring configuration need, the go-to solution is frequently a wrapper component, simply because it feels more familiar. This post demonstrates a cleaner approach: using directives to configure third-party components in a consistent, centralized manner. It's an elegant alternative to wrapping components in your own code. Let's dive into a concrete scenario.

The default directive approach

In a current project, we rely on the PrimeNG component library. I've noticed the same verbose block of markup being repeated for every date picker instance.
<p-calendar
    [(ngModel)]="date"
    required
    id="date"
    name="date"
    dateFormat="dd/mm/yy"
    [showIcon]="true"
    [showButtonBar]="true"
    [monthNavigator]="true"
    [yearNavigator]="true"
    yearRange="1900:2050"
    [firstDayOfWeek]="1"
>
</p-calendar>
This is the markup needed to make the component behave as we intend. In my view, this is an excessive amount of code that not only clutters the template but also makes things appear more complicated than they actually are. There's a risk of forgetting to add an attribute to a new date picker, which would result in an inconsistent user experience. Furthermore, if the component library changes or removes an attribute, we'd be forced to update every single p-datepicker element across the codebase. This impacts both developer productivity and the end-user experience. By refactoring this logic into a directive, the template becomes clean and simple again, while also guaranteeing that every user gets the same consistent experience. The refactored template looks like this:
<p-calendar [(ngModel)]="date" required id="date" name="date"></p-calendar>
So, how do we go from those 14 lines of HTML down to just one? The answer lies in a directive. This directive is applied to all calendar elements by matching the p-calender component selector. It achieves this by injecting the Calendar instance directly into the directive and configuring it according to our specific requirements.
import { Directive } from '@angular/core';
import { Calendar } from 'primeng/calendar';

@Directive({
    selector: 'p-calendar',
})
export class CalenderDirective {
    constructor(private calendar: Calendar) {
        this.calendar.dateFormat = 'dd/mm/yy';
        this.calendar.showIcon = true;
        this.calendar.showButtonBar = true;
        this.calendar.monthNavigator = true;
        this.calendar.yearNavigator = true;
        this.calendar.yearRange = '1900:2050';
        this.calendar.firstDayOfWeek = 1;
    }
}

Overriding the default configuration

This directive establishes a solid, standard baseline for all date pickers. However, for those occasional, one-off cases, you can still override the directive's default values on specific elements that need a different setup. In the example below, the navigator options are turned off by explicitly setting their values to false.
<p-calendar [monthNavigator]="false" [yearNavigator]="false"></p-calendar>

The opt-in directive strategy

Instead of a directive that applies to every instance, you can modify the selector to target only elements with a specific, distinct use case. Consider dropdown elements that share a generic contract, like a "codes" dropdown. We can configure the behavior for just those elements by using an attribute selector. Notice how the [codes] attribute in the selector ensures this directive only picks up the intended dropdowns.
import { Directive, OnInit } from '@angular/core';
import { Dropdown } from 'primeng/dropdown';
import { sortByLabel } from '@core';

@Directive({
    selector: 'p-dropdown[codes]',
})
export class CodesDropdownDirective implements OnInit {
    constructor(private dropdown: Dropdown) {
        this.dropdown.optionLabel = 'label';
        this.dropdown.optionValue = 'key';
        this.dropdown.showClear = true;
    }

    public ngOnInit(): void {
        this.dropdown.options = [...this.dropdown.options].sort(sortByLabel);
        if(this.dropdown.options.length > 10) {
            this.dropdown.filter = true;
            this.dropdown.filterBy = 'label';
            this.dropdown.filterMatchMode = 'startsWith';
        }
    }
}
This way, only the p-dropdown elements that possess the codes attribute are controlled by this directive. To use it in your template, you simply need to add the codes attribute to the relevant p-dropdown element.
<p-dropdown [(ngModel)]="favoriteSport" codes required id="sport" name="sport"></p-dropdown>

The opt-out directive strategy

Alternatively, you can use the :not() selector. This is particularly useful when most elements require the same configuration, but a small subset needs a unique one-off setup. For instance, if 90% of the dropdowns in your app pull data from a "codes" source, it makes sense to make this the default behavior. In that case, you wouldn't want to force developers to add a codes attribute everywhere. Instead, you would define an opt-out flag. So, rather than using the codes attribute to mark the standard dropdowns, we assume it's the default behavior and use a new resetDropdown attribute to explicitly exclude a specific dropdown from this logic.
import { Directive, OnInit } from '@angular/core';
import { Dropdown } from 'primeng/dropdown';
import { sortByLabel } from '@core';

@Directive({
    selector: 'p-dropdown:not(resetDropdown)',
})
export class CodesDropdownDirective implements OnInit {
    constructor(private dropdown: Dropdown) {
        this.dropdown.optionLabel = 'label';
        this.dropdown.optionValue = 'key';
        this.dropdown.showClear = true;
    }

    public ngOnInit(): void {
        this.dropdown.options = [...this.dropdown.options].sort(sortByLabel);
        if(this.dropdown.options.length > 10) {
            this.dropdown.filter = true;
            this.dropdown.filterBy = 'label';
            this.dropdown.filterMatchMode = 'startsWith';
        }
    }
}
In the HTML template, this translates to the following markup.
<!-- uses the codes dropdown -->
<p-dropdown [(ngModel)]="favoriteSport" required id="sport" name="sport"></p-dropdown>
<!-- opt-out of the codes dropdown and use the default p-dropdown behavior -->
<p-dropdown
    [(ngModel)]="preference"
    resetDropdown
    required
    id="preference"
    name="preference"
></p-dropdown>

Directives for fetching data

The directive's body can handle significantly more logic.
The following example shows a directive responsible for filling a dropdown with options, a practical approach for frequently reused data sources.
One way to extend this pattern is by making the data source itself a configurable input.

In the code below, a [countries] attribute is introduced, letting us attach the directive to particular dropdowns so they pull from a country list. This directive works alongside the other dropdown directives already shown.
Additionally, the directive exposes an @Output event that fires once the country data has finished loading.

import { Directive, EventEmitter, OnInit, Output } from '@angular/core';
import { Dropdown } from 'primeng/dropdown';
import { GeoService, sortByLabel } from '@core';

@Directive({
    selector: 'p-dropdown[countries]',
})
export class CountriesDropdownDirective implements OnInit {
    @Output() loaded = new EventEmitter<ReadonlyArray<Countries>>();

    constructor(private dropdown: Dropdown, private geoService: GeoService) {}

    public ngOnInit(): void {
        this.geoService.getCountries().subscribe((result) => {
            this.dropdown.options = result.map((c) => ({ label: c.label, key: c.id })).sort(sortByValue);
            this.loaded.emit(this.dropdown.options);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
<p-dropdown
    [(ngModel)]="country"
    countries
    required
    id="country"
    name="country"
    (loaded)="countriesLoaded($event)"
></p-dropdown>
Enter fullscreen mode Exit fullscreen mode

Wrap-up

Directives remain a powerful yet frequently overlooked feature of Angular.

They embody the Open-Closed Principle: a component stays frozen against modification, while a directive opens it up for extension without touching its internals.

This means we can alter the behavior of third-party libraries or an internal component library even when we have no access to the component's source code.

Building wrapper components or designing components with extensive configuration inputs could achieve similar ends, but those approaches demand more code and become more cumbersome to maintain.

By relying on selectors, we can pinpoint elements that need a distinct configuration. And because directives can be composed on a single element, each one can stay narrowly focused on a single responsibility.


Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.