EthicsComputerCourses
Join training +91 90532 10052

Custom Form Controls & Dynamic Forms

Angular · Lesson 19 of 41 Intermediate 7 min read Updated

Browsers give you text boxes, checkboxes and dropdowns. Business apps need more: a "number of days" stepper, a star rating, a mobile number field with a +91 prefix, a city picker that searches as you type. If each screen builds these by hand, you end up with ten slightly different star ratings. The professional answer is a custom form control: a component that plugs into Angular forms exactly like a native <input>, with validation, touched state and disabling all working automatically.

In this lesson you will build one custom control for Signal Forms, one for reactive forms (which most existing enterprise code uses), and then a dynamic form that is generated from configuration data.

Step by step: a control for Signal Forms

Signal Forms (see Signal Forms) define a simple contract called FormValueControl<T>. A component that has a value property created with model() can be bound with [formField], just like an <input>.

You will build a leave-days stepper with minus and plus buttons.

  1. Create the component and implement FormValueControl<number>. The only required member is value, a model signal:
TypeScript
import { Component, computed, input, model, output } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';

@Component({
  selector: 'app-day-stepper',
  template: `
    <button type="button" (click)="change(-1)" [disabled]="disabled() || atMin()" aria-label="Fewer days">-</button>
    <span aria-live="polite">{{ value() }} day(s)</span>
    <button type="button" (click)="change(1)" [disabled]="disabled() || atMax()" aria-label="More days">+</button>
  `,
})
export class DayStepper implements FormValueControl<number> {
  readonly value = model(1);
  readonly disabled = input(false);
  readonly min = input<number | undefined>(undefined);
  readonly max = input<number | undefined>(undefined);
  readonly touch = output<void>();

  protected atMin = computed(() => this.min() !== undefined && this.value() <= this.min()!);
  protected atMax = computed(() => this.max() !== undefined && this.value() >= this.max()!);

  protected change(step: number) {
    this.value.update((v) => v + step);
    this.touch.emit();
  }
}
  1. Add the optional members you need. The FormField directive fills inputs that match field state by name: disabled, readonly, invalid, errors, required, min, max and more. Here min and max come straight from the schema rules, so the buttons disable themselves at the limits. Emitting the touch output tells the form that the user has interacted with the field.
  1. Use it in a form like any other field:
TypeScript
import { Component, signal } from '@angular/core';
import { FormField, form, max, min, required } from '@angular/forms/signals';

@Component({
  selector: 'app-leave-apply',
  imports: [FormField, DayStepper],
  template: `
    <label>Reason <input [formField]="leaveForm.reason" /></label>
    <app-day-stepper [formField]="leaveForm.days" />
    <p>Model: {{ model().days }} days, valid: {{ leaveForm().valid() }}, touched: {{ leaveForm.days().touched() }}</p>
  `,
})
export class LeaveApply {
  protected model = signal({ reason: 'Sister wedding in Jaipur', days: 1 });
  protected leaveForm = form(this.model, (p) => {
    required(p.reason);
    min(p.days, 1);
    max(p.days, 3);
  });
}
  1. Check the behaviour. At first the minus button is disabled because the value equals min. After two clicks on plus, the plus button is disabled too. If code sets the model to 5 days, the form becomes invalid:
Output
Start:          1 day(s)  Model: 1 days, valid: true, touched: false   (minus disabled)
After +, +:     3 day(s)  Model: 3 days, valid: true, touched: true    (plus disabled)
Model set to 5: 5 day(s)  Model: 5 days, valid: false, touched: true

The stepper never talks to the form directly. It only reads and writes its own value model; [formField] keeps the two in sync. For a boolean control such as a toggle switch, implement FormCheckboxControl instead, which uses a checked model in place of value.

A control for reactive forms: ControlValueAccessor

Reactive forms (see Reactive Forms) use an older, more verbose contract: the ControlValueAccessor interface. You will meet it in nearly every existing enterprise codebase. It has four methods, and the component registers itself with the NG_VALUE_ACCESSOR multi token:

MethodCalled by Angular when...
writeValue(value)The form sets a value, for example on load or setValue()
registerOnChange(fn)Once at start; call fn(newValue) whenever the user changes the value
registerOnTouched(fn)Once at start; call fn() when the user leaves the control
setDisabledState(isDisabled)The control is disabled or enabled (optional)
TypeScript
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-star-input',
  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarInput), multi: true }],
  template: `
    @for (star of stars; track star) {
      <button type="button" (click)="select(star)" (blur)="onTouched()" [disabled]="disabled()" [attr.aria-pressed]="star <= value()">
        {{ star <= value() ? '★' : '☆' }}
      </button>
    }
  `,
})
export class StarInput implements ControlValueAccessor {
  protected stars = [1, 2, 3, 4, 5];
  protected value = signal(0);
  protected disabled = signal(false);
  private onChange: (v: number) => void = () => {};
  protected onTouched: () => void = () => {};

  writeValue(v: number | null): void {
    this.value.set(v ?? 0);
  }
  registerOnChange(fn: (v: number) => void): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.disabled.set(isDisabled);
  }

  protected select(star: number) {
    this.value.set(star);
    this.onChange(star);
  }
}

forwardRef is needed because the class is used in its own decorator before it is fully defined. With rating = new FormControl(3, { nonNullable: true }) and <app-star-input [formControl]="rating" />, clicking the fifth star gives:

Output
Before: ★ ★ ★ ☆ ☆  Rating: 3
After:  ★ ★ ★ ★ ★  Rating: 5   (control is now dirty)

Calling rating.disable() calls setDisabledState(true), and every star button becomes disabled. Notice the key difference: in writeValue you must not call onChange, or the form would think the user changed a value that the form itself just set.

Dynamic forms from configuration

Some forms are not known when you write the code. An IT team in Pune wants a "request equipment" form whose fields an admin can change without a new deployment: today it asks for a laptop model, RAM and office; next month it may add a monitor size. The server sends a field configuration and Angular builds the form from it.

TypeScript
export interface FieldConfig {
  key: string;
  label: string;
  type: 'text' | 'number' | 'select';
  required?: boolean;
  min?: number;
  options?: string[];
}

A FormRecord is a form group whose keys are not known in advance, which is exactly what you need. The component builds it from the fields input and renders each field with @switch:

TypeScript
import { Component, computed, input, output } from '@angular/core';
import { FormControl, FormRecord, ReactiveFormsModule, ValidatorFn, Validators } from '@angular/forms';

@Component({
  selector: 'app-dynamic-form',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="group()" (ngSubmit)="submitted.emit(group().getRawValue())">
      @for (f of fields(); track f.key) {
        <label>
          {{ f.label }}
          @switch (f.type) {
            @case ('select') {
              <select [formControlName]="f.key">
                @for (o of f.options; track o) {
                  <option [value]="o">{{ o }}</option>
                }
              </select>
            }
            @case ('number') {
              <input type="number" [formControlName]="f.key" />
            }
            @default {
              <input type="text" [formControlName]="f.key" />
            }
          }
        </label>
      }
      <button type="submit" [disabled]="group().invalid">Save</button>
    </form>
  `,
})
export class DynamicForm {
  readonly fields = input.required<FieldConfig[]>();
  readonly submitted = output<Record<string, unknown>>();

  protected group = computed(() => {
    const record = new FormRecord<FormControl<string | number | null>>({});
    for (const f of this.fields()) {
      const validators: ValidatorFn[] = [];
      if (f.required) validators.push(Validators.required);
      if (f.min !== undefined) validators.push(Validators.min(f.min));
      const start = f.type === 'select' ? (f.options?.[0] ?? null) : null;
      record.addControl(f.key, new FormControl<string | number | null>(start, validators));
    }
    return record;
  });
}

Given three fields (laptop model, RAM with min: 8, office with options Pune, Kochi, Remote), the Save button starts disabled. After typing "ThinkPad E14" and 16, submitting emits:

Output
{"laptopModel":"ThinkPad E14","ramGb":16,"office":"Pune"}

Because group is a computed, a new configuration creates a fresh form automatically. Add new field types by adding @case blocks, and you can even use your custom controls there, for example @case ('rating') { <app-star-input [formControlName]="f.key" /> }.

Common mistakes

  • Calling onChange inside writeValue, which marks the form dirty on load and can cause loops.
  • Forgetting multi: true on NG_VALUE_ACCESSOR, which replaces Angular's built-in accessors and breaks other inputs.
  • Using <button> without type="button" inside a form. The default type is submit, so every star click submits the form.
  • Defining value and checked together on a Signal Forms control. Pick FormValueControl or FormCheckboxControl.
  • Rebuilding the dynamic form on every change detection. Build it in a computed from the configuration, not in a template method.

Key points

  • Custom controls let reusable widgets work with forms exactly like native inputs.
  • For Signal Forms, implement FormValueControl<T> with a value = model(); optional inputs such as disabled, min and max are filled from field state automatically.
  • For reactive forms, implement ControlValueAccessor and provide NG_VALUE_ACCESSOR with useExisting and multi: true.
  • writeValue receives values from the form; call the registered onChange and onTouched functions only for user actions.
  • Dynamic forms build a FormRecord from configuration and render each field type with @switch.

Exercise

Write a MobileInput control for Signal Forms that shows a fixed "+91" label and a text box for 10 digits. Its value is the full string, such as +919876543210. Typing should keep only digits, and leaving the box should emit touch. Use it in a form with a pattern rule that requires ^\+91[6-9]\d{9}$.

Show solution
TypeScript
@Component({
  selector: 'app-mobile-input',
  template: `
    <span>+91</span>
    <input inputmode="numeric" maxlength="10" [value]="digits()" (input)="onInput($event)" (blur)="touch.emit()" [disabled]="disabled()" />
  `,
})
export class MobileInput implements FormValueControl<string> {
  readonly value = model('');
  readonly disabled = input(false);
  readonly touch = output<void>();

  protected digits = computed(() => this.value().replace(/^\+91/, ''));

  protected onInput(event: Event) {
    const raw = (event.target as HTMLInputElement).value.replace(/\D/g, '').slice(0, 10);
    this.value.set(raw ? `+91${raw}` : '');
  }
}

// In the parent:
protected contactForm = form(this.model, (p) => {
  pattern(p.mobile, /^\+91[6-9]\d{9}$/, { message: 'Enter a valid Indian mobile number.' });
});
// <app-mobile-input [formField]="contactForm.mobile" />

The control stores the complete number with the country code, so the rest of the app never has to add "+91" itself. Non-digits are removed as the user types, and the pattern rule still checks the final value.

Quick Quiz

1. What is the one required member of a Signal Forms FormValueControl?

2. In a ControlValueAccessor, when should you call the function passed to registerOnChange?

3. Why is forwardRef(() => StarInput) used in the provider?

4. Which form class suits a dynamic form whose field names come from the server?

Learn Angular with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call