Custom Form Controls & Dynamic Forms
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.
- Create the component and implement
FormValueControl<number>. The only required member isvalue, a model signal:
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();
}
}- Add the optional members you need. The
FormFielddirective fills inputs that match field state by name:disabled,readonly,invalid,errors,required,min,maxand more. Hereminandmaxcome straight from the schema rules, so the buttons disable themselves at the limits. Emitting thetouchoutput tells the form that the user has interacted with the field.
- Use it in a form like any other field:
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);
});
}- 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:
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:
| Method | Called 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) |
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:
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.
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:
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:
{"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
onChangeinsidewriteValue, which marks the form dirty on load and can cause loops. - Forgetting
multi: trueonNG_VALUE_ACCESSOR, which replaces Angular's built-in accessors and breaks other inputs. - Using
<button>withouttype="button"inside a form. The default type issubmit, so every star click submits the form. - Defining
valueandcheckedtogether on a Signal Forms control. PickFormValueControlorFormCheckboxControl. - Rebuilding the dynamic form on every change detection. Build it in a
computedfrom 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 avalue = model(); optional inputs such asdisabled,minandmaxare filled from field state automatically. - For reactive forms, implement
ControlValueAccessorand provideNG_VALUE_ACCESSORwithuseExistingandmulti: true. writeValuereceives values from the form; call the registeredonChangeandonTouchedfunctions only for user actions.- Dynamic forms build a
FormRecordfrom 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
@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?
FormValueControl needs a model signal named value; everything else is optional.
2. In a ControlValueAccessor, when should you call the function passed to registerOnChange?
onChange reports user changes to the form; writeValue handles changes coming from the form.
3. Why is forwardRef(() => StarInput) used in the provider?
forwardRef delays reading the class reference until it exists.
4. Which form class suits a dynamic form whose field names come from the server?
FormRecord is a group with keys that are not known in advance.
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
