bhylu.com

Advertisements

Angular Custom Form Controls

ControlValueAccessor connects a reusable control to Angular forms. It must handle values written by the form, user changes, touched state and disabled state without creating a feedback loop.

How it works

The quantity control exposes a native button while the parent owns a typed FormControl. writeValue updates the control’s display without reporting the same incoming value as a new user change. The user operation reports changes through the registered callback. The disabled callback must also reach the actual interactive element, or the form model and keyboard behavior will disagree.

Run the code

Use a separate empty project folder with a compatible Node version: ^22.22.3, ^24.15.0 or a supported 26+ runtime. The review uses Node 24.22.1, Angular 22.2.4, CLI/build 22.2.5 and TypeScript 6.0.3. Node 22.15.1 is too old for Angular 22. Save every download at its displayed path; browsers may keep only the basename, so recreate the src folder. Run npm install, npm run build and npm start, then open the loopback address printed by the server. Run npm test for the supplied component tests. These are local teaching projects, not Bhylu publishing files.

The small loopback server serves the compiled app and fixed, read-only tool API fixtures. It is not a database, authentication or lending service. Angular examples do not run in Bhylu’s current HTML or TypeScript playground. No Apache, MySQL or PHP configuration change is needed.

Example

src/app.ts

import { Component } from '@angular/core';
import { forwardRef, signal } from '@angular/core';
import {
  ControlValueAccessor,
  FormControl,
  NG_VALUE_ACCESSOR,
  ReactiveFormsModule,
} from '@angular/forms';
@Component({
  selector: 'quantity-control',
  standalone: true,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => QuantityControl),
      multi: true,
    },
  ],
  template:
    '<button type="button" [disabled]="disabled()" (click)="add()">Increase quantity</button>',
})
class QuantityControl implements ControlValueAccessor {
  count = signal(0);
  disabled = signal(false);
  private changed: (value: number) => void = () => {};
  private touched: () => void = () => {};
  writeValue(value: unknown): void {
    this.count.set(typeof value === 'number' && Number.isInteger(value) ? value : 0);
  }
  registerOnChange(fn: (value: number) => void): void {
    this.changed = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.touched = fn;
  }
  setDisabledState(value: boolean): void {
    this.disabled.set(value);
  }
  add(): void {
    if (this.disabled() || this.count() >= 20) return;
    this.count.update((v) => v + 1);
    this.changed(this.count());
    this.touched();
  }
}
@Component({
  selector: 'bh-custom-controls',
  standalone: true,
  imports: [QuantityControl, ReactiveFormsModule],
  template: `<quantity-control [formControl]="quantity"></quantity-control
    ><button data-disable type="button" (click)="quantity.disable()">
      Disable quantity
    </button>
    <p data-result>{{ quantity.value }} selected</p>`,
})
export class App {
  quantity = new FormControl(2, { nonNullable: true });
}

Expected result

2 selected

Increasing the child quantity changes the parent model to three. Disabling the parent control disables the child button. The test verifies both the value round trip and the actual disabled property, rather than only checking that an interface method exists.

Advertisements