Building the directive: a step-by-step approach

And the reasons to reconsider it

Every developer has, at some point, poured hours into automating something that would have taken minutes to handle manually. You convince yourself the investment is worth it — until you realize the manual approach would have cost a fraction of the effort. If you've never experienced this, you're either extremely disciplined or not being entirely honest.

The pattern is familiar to anyone who has fallen for what's often called premature optimization — a concept famously described as the root of all evil, and humorously captured in this well-known xkcd comic:
xkcd salt long run
(Yes, this represents just one flavor of premature optimization, but it's the one we're tackling here).

The scenario we'll explore fits this category perfectly: a recurring need that demands substantial DOM restructuring, all wrapped in tools that are already plenty complex. In real situations, combined with other styling and framework layers, you'll find yourself questioning whether the pile of trade-offs and workarounds justifies the payoff.
So, as our vehicle for exploration, we'll craft a directive that appends a checkbox next to each form control — letting users toggle whether that control is disabled. Along the way, we'll touch on some Angular capabilities that stretch beyond everyday usage.


A stripped-down test setup

For this initial installment, we'll work with a bare-bones HTML form: unstyled controls, separated by plain <hr> tags (no list semantics involved). Hardly something you'd encounter in production — unless you're dealing with a legacy codebase straight from the early '90s — but it gives us the perfect environment to concentrate on the core technique.

On the left sits our plain form; on the right, the expected outcome — achieved by simply applying a single directive to each control in the template. (We could design the directive to attach to the form itself and cascade logic to every child control, but simplicity guides us here.)

plain forms before after
The template for that setup is:

<form [formGroup]="plainForm" (ngSubmit)="showSubmitObject()">
    <label>A text control</label><br>
    <input formControlName="text" selectablePlain><hr>
    <label>A number control</label><br>
    <input formControlName="number" type="number" selectablePlain><hr>
    <label>A radio control</label><br>
    <div>
        <label for="yes">Yes</label>
        <input formControlName="radio" type="radio" value="yes" id="yes">
        <label for="no">No</label>
        <input formControlName="radio" type="radio" value="no" id="no" selectablePlain>
    </div><hr>
    <label>A range control</label><br>
    <input formControlName="range" type="range" selectablePlain><hr>
    <label>A single selection control</label><br>
    <select formControlName="singleSel" selectablePlain>
        <option value="">--Please choose an option--</option>
        <option value="dog">Dog</option>
        <option value="cat">Cat</option>
        <option value="hamster">Hamster</option>
    </select><hr>
    <label>A multiple selection control</label><br>
    <select formControlName="multiSel" multiple selectablePlain>
        <option value="">--Please choose multiple options--</option>
        <option value="dog">Dog</option>
        <option value="cat">Cat</option>
        <option value="hamster">Hamster</option>
    </select><hr>
    <br>
    <button>Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Notice that every control carries not just the standard formControlName directive linking to its corresponding FormControl in the component, but also a selectablePlain attribute — that's the selector for the directive we're about to create.
Nothing unusual here; I picked the most common control types: text, number, radio, range, select, and multi-select.
And the TypeScript bindings look like this:

export class PlainControlsComponent {

  plainForm = new FormGroup({
    text: new FormControl(''),
    number: new FormControl(0),
    radio: new FormControl('no'),
    range: new FormControl(0),
    singleSel: new FormControl(''),
    multiSel: new FormControl([]),
  });
}
Enter fullscreen mode Exit fullscreen mode

Defining what we're trying to accomplish

The directive's expected behavior splits cleanly into two parts:

  • DOM work: inserting an <input type="checkbox"> node alongside each control element found in the template
  • model changes: toggling the enabled/disabled state of the associated formControls when the user interacts with those checkboxes

The second part is hardly a challenge — that's precisely what form interaction layers in modern frameworks are expected to do. The first, however, brings up several implementation hurdles, even if in this deliberately trimmed-down test scenario we'll sidestep the trickier ones.

The code, step by step

Directive declaration and dependency injection

@Directive({
  selector: '[selectablePlain]',
  standalone: true,
})
export class SelectablePlainDirective {

  constructor(
      private renderer: Renderer2,
      private hostEl: ElementRef,
      private ctrl: NgControl
  )
Enter fullscreen mode Exit fullscreen mode

The selector is set to selectablePlain, and the directive is marked as standalone — the modern Angular approach. Declaring it inside an @NgModule, as was required before Angular v14, would work just as well.

Several dependencies are injected to drive the logic:

  • Renderer2 — an Angular wrapper around the browser's Document and Node APIs, giving us safe low-level methods to manipulate the DOM programmatically.
  • ElementRef — once injected, this gives us a reference to the host element. The object we get wraps the actual DOM node the directive sits on, which in our case is an <input> or a <select>.
  • NgControl — the common base class for NgModel, FormControlDirective, and FormControlName. Injecting this rather than a concrete subclass keeps the directive agnostic to which form approach the template uses. If every control had been bound with formControlName, injecting FormControlName directly would have been equivalent.

Inside the constructor

this.checkBox = this.renderer.createElement('input');
this.renderer.setAttribute(this.checkBox, 'type', 'checkbox');
Enter fullscreen mode Exit fullscreen mode

Via the injected Renderer2, we create the new element. Its createElement method only needs the tag name. Then setAttribute assigns type="checkbox" to the freshly created <input>.

this.renderer.insertBefore(
    this.renderer.parentNode(this.hostEl.nativeElement),
    this.checkBox,
    this.renderer.nextSibling(this.hostEl.nativeElement)
);
Enter fullscreen mode Exit fullscreen mode

That seemingly awkward chain of calls is a workaround for a missing insertAfter method. Taking the host element from nativeElement, we feed it into the arguments of insertBefore.

insertBefore places a new node immediately before a reference node, and takes three parameters:

  • the parent of the reference node
  • the node to insert
  • the reference node itself

The final argument is where the trick lives: instead of passing the host element itself, we pass its next sibling. The renderer then drops the new checkbox right between the host element and whatever follows it — or, if nothing follows, at the end.

this.renderer.listen(
    this.checkBox,
    'change', 
    () => this.ctrl.disabled ? this.ctrl.control?.enable() : this.ctrl.control?.disable()
);
Enter fullscreen mode Exit fullscreen mode

Finally, we bind a listener to the checkbox's change event. This is the actual payoff: when the checkbox is clicked and the control is disabled, we enable it; otherwise, we disable it.

Delayed setup with ngOnInit

As this open ticket shows, NgControl.control is only populated after the OnChange lifecycle hook. Any property of NgControl that depends on runtime control values is undefined during construction.

That is why some of the logic has to live inside the ngOnInit hook.

if (this.ctrl.control?.enabled) 
  this.renderer.setProperty(this.checkBox, 'checked', true);
Enter fullscreen mode Exit fullscreen mode

Here we set the initial checked state. The goal: a control that starts disabled should not show its checkbox as checked, and vice versa. So we read the control's initial status and set the checkbox's checked property to match.

this.ctrl.statusChanges?.pipe(
  tap((status: string) => status === "DISABLED" 
        ? this.renderer.setProperty(this.checkBox, 'checked', false)
        : this.renderer.setProperty(this.checkBox, 'checked', true)
  )
).subscribe()
Enter fullscreen mode Exit fullscreen mode

The last piece is ensuring that programmatic changes to the control's status also update the checkbox. If some TypeScript code disables a control after the fact, it would be confusing for the checkbox to still look active.

To handle this, we subscribe to NgControl.statusChanges. Whenever it emits "DISABLED" we clear the checkbox; for any other status ("PENDING" | "VALID" | "INVALID") we check it.

Why not use this stream for the initial value too? Because statusChanges is an emitter — it does not replay the control's current state when you subscribe.

But since we're working in Angular, the RxJs toolbox is at hand. A startWith operator solves the issue neatly, so the two previous snippets collapse into one:

this.ctrl.statusChanges?.pipe(
  startWith(this.ctrl.control?.status),
  tap((status: string) => status === "DISABLED" 
        ? this.renderer.setProperty(this.checkBox, 'checked', false)
        : this.renderer.setProperty(this.checkBox, 'checked', true)
  )
).subscribe()
Enter fullscreen mode Exit fullscreen mode

Now we can confirm the controls are properly disabled:

Submit without disabled controls


Wrapping up

The outcome is clean, but the example was deliberately simplified. Interactions driven by the model can often be bent to behave correctly; DOM manipulation is a different story. A slightly less pristine template than the one used here can easily lead to completely unexpected behavior.

In practice, turning this into a general-purpose directive is rarely worth the effort. Hand-placing the needed checkboxes where they belong is often simpler, possibly pairing them with a purpose-built model-only directive tailored to the situation.

The next article will look at how this same task plays out when the form is styled with Angular Material, where the challenge takes on a different shape.