Custom controls
Bind a custom component with [formNode], just as you would a native input. For a new
component, expose a value = model(...). Existing ControlValueAccessor components
can use the same binding.
Optional input synchronization is experimental and off by default. Your template or component should manage them.
๐ FormValueControl: value models and stateโ
A component implementing Angular's FormValueControl<T> can use [formNode] without
experimental input synchronization. The integration has two separate responsibilities:
| Component design | Value and state integration |
|---|---|
value = model() with useFormNodeState() | Value binding and full access to the bound Form Nodes state without experimental input writes. The component renders the state itself. |
value = model() with state/constraint input() properties | Value binding works by default. Automatically populating those inputs requires experimental syncInputs; use 'all' for every supported input. |
ControlValueAccessor / NG_VALUE_ACCESSOR | Values, change/touch callbacks, and setDisabledState() use the normal CVA contract, independently of syncInputs. |
Full automatic FormValueControl input synchronization is experimental; using the
FormValueControl value contract is not. Merely implementing the interface does not enable
input writes. A component designed around useFormNodeState() can use the bound node's state,
constraints, errors, and interaction operations without enabling them.
This complete example implements FormValueControl<string> with value = model('') and reads
state through useFormNodeState(). It applies disabled, readonly, required, and minimum length
to its native input, renders validation messages, and reports blur. The parent explicitly keeps
syncInputs off, including when a surrounding provider enables it:
import { Component, input, model } from '@angular/core';
import type { FormValueControl } from '@angular/forms/signals';
import { FormNodeDirective, field, form, useFormNodeState, required, minLength } from '@ngblocks/form-nodes';
// text-input.component.ts
@Component({
selector: 'app-text-input',
template: `
<label>
{{ label() }}
@if (formNodeState.required()) {
<span aria-hidden="true">*</span>
}
<input
[value]="value()"
[disabled]="formNodeState.disabled()"
[required]="formNodeState.required()"
[readOnly]="formNodeState.readonly()"
[attr.minlength]="formNodeState.minLength()"
[attr.aria-invalid]="formNodeState.invalid()"
(input)="value.set($any($event.target).value)"
(blur)="formNodeState.markAsTouched()"
/>
</label>
@if (formNodeState.touched()) {
@for (error of formNodeState.errors(); track $index) {
<p role="alert">{{ error.message }}</p>
}
}
`,
})
export class MyTextInput implements FormValueControl<string> {
label = input.required<string>();
value = model('');
formNodeState = useFormNodeState();
}
// profile-editor.component.ts
@Component({
imports: [FormNodeDirective, MyTextInput],
template: `<app-text-input label="Name" [formNode]="form.name" />`,
})
export class ProfileEditor {
form = form({
name: field('', [required, minLength(3)], {
// Keep automatic input writes off, even if an ancestor provider enables them.
syncInputs: false,
}),
});
}
[formNode] discovers the value model automatically. Detection requires a declared signal input
and matching change output that refer to the same model property. Public aliases are supported,
such as actualValue = model('', { alias: 'value' }). An internal value = signal('') is not a
value model and is left untouched. Updating value from the component
sends the user's input to the field; updating the field updates the component. No custom
provider, base class, or Form Nodes interface is required.
useFormNodeState() supports [formNode], [formField], [formControl], [formControlName],
and [(ngModel)]: implement state UI once for every supported binding. Keep the value contract
required by the caller's forms API. See source-specific state support.
useFormNodeState() reads state; it does not apply attributes to the DOM or populate the
component's own disabled = input() properties. An existing control that reads those properties
must adopt the hook in its implementation, receive explicit bindings, or opt into experimental
synchronization. Focus and reset integration still use the optional focus() and reset() hooks.
The component has three responsibilities:
- Render the model value and update it when the user edits the control.
- Apply the state it needs, such as
formNodeState.disabled(), to its interactive element. - Call
formNodeState.markAsTouched()when the user leaves the control.
Initialize the value model with a default, such as model(''), instead of model.required().
The bound field supplies its value during setup. For a checkbox-style component, expose
checked = model(false) instead of value.
๐งช Keep control of your component's inputsโ
Optional custom-control input synchronization is experimental and disabled by default.
This concerns the state and constraint inputs used by Angular's FormValueControl and
FormCheckboxControl contracts. Value/checked models remain connected through their public APIs;
markAsTouched(), the touch output, focus, reset, and normal node validation still work.
Opt in per node with syncInputs: 'declared'. This example synchronizes disabled because it is
an explicit initial option. Required and minLength still validate the node without populating
the component's constraint inputs:
import { Component, input, model, signal } from '@angular/core';
import { field, form, required, minLength, FormNodeDirective } from '@ngblocks/form-nodes';
// text-control.component.ts
@Component({
selector: 'app-text-control',
template: `
<input #text [value]="value()" [disabled]="disabled()" [required]="required()"
[attr.minlength]="minLength()" (input)="value.set(text.value)">
`,
})
export class TextControl {
value = model('');
disabled = input(false);
required = input(false);
minLength = input<number | undefined>(undefined);
}
// profile.component.ts
@Component({
imports: [FormNodeDirective, TextControl],
template: `<app-text-control [formNode]="form.name" />`,
})
export class ProfileComponent {
saving = signal(false);
form = form({
name: field('', [required, minLength(3)], {
// Experimental: only explicit state options; validator inputs are not synchronized.
syncInputs: 'declared',
disabled: () => this.saving(),
}),
});
}
'declared' selects initial disabled, readonly, and hidden options (including false values), plus
disabledReasons with disabled. Validators never select inputs in this preset.
Use 'signal-controls' for all supported inputs on actual value/checked model controls. CVAs take
precedence even when they also expose a model; this preset does not write their additional inputs.
Use 'all' for all inputs on any active custom-control adapter, including CVAs and enabled pairs.
Native controls and standard CVA disabled callbacks remain connected in every mode.
โ Select inputs and target controlsโ
Lists select exactly those inputs. Objects separate the input selection from its target:
import { Component, input, model } from '@angular/core';
import { field, form, FormNodeDirective } from '@ngblocks/form-nodes';
// text-control.component.ts
@Component({
selector: 'app-selected-text',
template: `
<input #text [value]="value()" [disabled]="disabled()" [class.edited]="dirty()"
(input)="value.set(text.value)">
`,
})
export class TextControl {
value = model('');
disabled = input(false);
dirty = input(false);
}
// profile.component.ts
@Component({
imports: [FormNodeDirective, TextControl],
template: `
<app-selected-text [formNode]="form.name" />
<app-selected-text [formNode]="form.nickname" />
<app-selected-text [formNode]="form.notes" />
`,
})
export class ProfileComponent {
form = form({
// Lists always synchronize exactly the selected inputs.
name: field('', { syncInputs: ['disabled', 'dirty'] }),
// Explicit input selection: equivalent to ['disabled'].
nickname: field('', { syncInputs: { inputs: ['disabled'] } }),
// Select disabled only on model controls; dirty remains component-owned.
notes: field('', {
disabled: false,
syncInputs: { inputs: ['disabled'], target: 'signal-controls' },
}),
});
}
The object accepts { inputs: 'declared' | 'all' | readonly SyncInputName[], target?: 'all' | 'signal-controls' | 'cva' }. Target defaults to all; it filters the selected adapter rather than
changing priority. An active paired control only matches all. Empty lists write nothing and never
enable value binding. Selecting disabled in a list does not also select disabledReasons.
Selections update reactively, including validator constraints, and can replace authored input bindings. Missing component inputs are ignored; inputs no longer selected retain their last values. Options work independently on nodes, factory defaults, providers, and global configuration. Node options override inherited settings for their own binding, not descendants. See the full configuration reference.
๐งช Separate input/output pairs (experimental)โ
Components with value/valueChange or checked/checkedChange pairs require
bindInputOutputPairs: true. This experimental option enables value writes, change/touch outputs,
optional focus/reset hooks, and an optional writable node reference. Signal and decorator inputs
and public aliases are supported. Models and CVAs do not require this option.
import { Component, input, output } from '@angular/core';
import { field, form, FormNodeDirective } from '@ngblocks/form-nodes';
// text-input.component.ts
@Component({
selector: 'app-paired-text',
template: `
<input #text [value]="value()" [disabled]="disabled()"
(input)="valueChange.emit(text.value)" (blur)="touch.emit()">
`,
})
export class PairedText {
value = input('');
valueChange = output<string>();
disabled = input(false);
touch = output<void>();
}
// profile.component.ts
@Component({
imports: [FormNodeDirective, PairedText],
template: `<app-paired-text [formNode]="form.name" />`,
})
export class ProfileComponent {
form = form({
// Experimental value transport, without any optional state input writes.
name: field('Ada', { bindInputOutputPairs: true }),
});
}
Use syncInputs separately to select state inputs. For example, { bindInputOutputPairs: true, syncInputs: ['disabled'] } connects the pair plus disabled state. Neither syncInputs: 'all' nor
an empty list activates a pair. Targets signal-controls and cva exclude paired controls.
False/null (including the default) pauses the complete pair connection: no value or state-input writes, no processing of change/touch outputs, no calls to its focus/reset hooks, and no retained writable node reference. Existing input values remain unchanged. Returning to an enabled node resynchronizes its current value. Values, debounce, validation, and dirty/touched transitions follow normal node behavior while connected. Use initialized inputs rather than required inputs.
bindInputOutputPairs inherits independently from syncInputs through node/factory, provider, and global
configuration. See pair configuration and rebinding.
๐ ControlValueAccessorโ
If your component already implements Angular's ControlValueAccessor, bind it directly.
Both NG_VALUE_ACCESSOR providers and hooks that assign inject(NgControl).valueAccessor
during construction are supported:
<app-existing-date-picker [formNode]="myForm.appointment" />
Import FormNodeDirective in the parent component. [formNode] writes values, registers change and
touch callbacks, and forwards disabled state through the normal CVA contract. These standard
CVA operations do not require syncInputs. Automatically writing additional state or constraint
inputs on a CVA component remains subject to the same experimental option.
For utility-based components, see direct NgControl registration.
See the Angular Material and PrimeNG guides for library-specific examples.
๐ Go furtherโ
The optional FormNodeValueControl<T> and FormNodeCheckboxControl types can document a
component's contract consistently on Angular 21 and 22. Runtime discovery does not require them.
The advanced custom-controls guide covers aggregate models,
object and array values, optional state inputs and hooks, wrapper components, Angular
[formField], and detailed CVA integration, including validation and parsing errors.
๐ Related guides and referenceโ
- Build a custom rating control shows a button-based control.
useFormNodeState()documents the available state signals.- Control binding covers native elements and shared binding behavior.
- Advanced custom controls documents the full compatibility contract.
Reading node state inside output handlersโ
With immediate updates, [formNode] processes a custom control's valueChange or checkedChange
before your template handler for that output. The node value, parent value, dirty state, and
synchronous validation are already updated. A touch handler likewise sees the updated touched
state and any value committed by blur debounce. This applies to models (including aliases) and
enabled input/output pairs.
import { Component, inject, model, output } from '@angular/core';
import { field, form, FORM_NODE, FormNodeDirective } from '@ngblocks/form-nodes';
@Component({
selector: 'app-description-input',
template: `
<textarea #text [value]="value()" [attr.aria-invalid]="binding.errors().length ? true : null" (input)="value.set(text.value)" (blur)="touch.emit()"></textarea>
`,
})
export class DescriptionInput {
binding = inject(FORM_NODE, { self: true });
value = model('');
touch = output<void>();
}
@Component({
imports: [DescriptionInput, FormNodeDirective],
template: `
<app-description-input [formNode]="form.description"
(valueChange)="onDescriptionChange()" (touch)="onDescriptionTouch()" />
`,
})
export class DescriptionEditor {
form = form({
description: field.strict(''),
});
lastDescription = '';
descriptionTouched = false;
onDescriptionChange() {
this.lastDescription = this.form.description();
}
onDescriptionTouch() {
this.descriptionTouched = this.form.description.touched();
}
}
Debounce still delays the committed value; value.control() exposes pending control input.
Programmatic writes to a component model can emit its output too, so valueChange alone does not
identify user interaction. Construction-time emissions before binding initialization are not user
interaction and do not have this ordering guarantee.
For a CVA, call the registered onChange callback before emitting a separate event whose consumers
need the updated node. Call onTouched before emitting a corresponding interaction event. Form Nodes
updates synchronously within those callbacks (subject to configured debounce); it cannot update
from a value that the CVA has not delivered yet. The same limitation applies to Reactive Forms.
The same ordering holds when your component injects FORM_NODE or FormNodeDirective during
construction. The injected token still identifies the concrete binding; no deferred injection or
manual microtask is needed.
Observing values from a bound custom controlโ
Consumers can listen to (formNodeControlValueChange) for the immediate value received from the
control and (formNodeValueChange) for its committed value after debounce. The outputs follow the
selected model, enabled input/output pair, or CVA transport. They do not react to programmatic node
writes or model-to-view rendering. A CVA should call its registered onChange callback for
view-to-model edits, and must not call it from writeValue as feedback. Custom code can invoke
callbacks or emit model outputs, so these events do not certify a physical user interaction.
See value outputs for the full contract.
CVA initialization and update timingโ
When [formNode] connects a ControlValueAccessor, it calls writeValue() with the current control value and calls setDisabledState() when implemented, synchronously during directive initialization. These calls happen before registering change and touch callbacks and before child controls run their initialization hooks. This lets controls such as Material radio groups select preloaded values, including when a parent CVA initializes an inner form or an @if creates a new group. The first reactive synchronization does not repeat an unchanged initial write.
This matches Reactive Forms' initialization order. It does not make every later model-to-view update synchronous: node.set(), form patches, and disabled-state changes update node state immediately, while existing CVAs receive changed state during Angular's reactive synchronization. A CVA must accept writeValue() before its view is initialized and during later updates. Reactive Forms' FormControl.setValue() instead invokes the registered model-to-view callback synchronously.
Form Nodes requests a view check after writing a CVA value or disabled state. This also supports controls whose setDisabledState() only assigns a plain property, such as ng-bootstrap rating and timepicker. Rendering still follows Angular's change detection cycle.
After initialization, value and disabled state are synchronized together. Enabling happens before
writing the value; disabling happens after it. Enabling also resends the current value, so a control
that ignored a write while disabled catches up. This covers node.enable(); node.set(nextValue)
in the same turn and enable followed by reset. A control that rejects writes while disabled may
keep its previous display until enabled; Form Nodes does not temporarily enable it to force a write.
CVA user input remains synchronous: call the callback supplied to registerOnChange() for user edits. Form Nodes receives that value immediately; configured debounce can defer its commit. Prefer (formNodeControlValueChange) for immediate control values and (formNodeValueChange) for committed values. Programmatic writeValue() calls must not emit user changes; Form Nodes also guards against synchronous feedback from an accessor.
Reset is an explicit exception to ordinary model-to-view deduplication: reset() and resetToInitial() synchronously call the bound CVA's writeValue() even when the value stays the same. This clears provisional control text that was never emitted to the node. Resetting an ancestor applies this to its bound descendants, and pending debounced input is discarded. Reset-driven writes do not emit formNodeValueChange or formNodeControlValueChange.
Changing [formNode] to a different node forces a fresh value and disabled-state write during binding synchronization, even if both nodes have equal values. Subsequent resets and user callbacks target the new node; resetting the previous node or a node whose binding was destroyed does not write into the control. Ordinary unchanged-value effects still skip redundant writes.
Tested UI library integrationsโ
The browser regression suite uses real controls from Angular Material, PrimeNG, NG-ZORRO, ng-bootstrap, and Ionic. It checks initial values against Reactive Forms, user-value outputs, reset, rebinding, disabled state, and control-specific touch/debounce behavior. Select tests also cover late options and overlay selection; Material tests include native and Moment dates.
The baseline fixtures use Angular 21.0.7 with Material 21.0.6, PrimeNG 21.1.10, NG-ZORRO 21.3.3, ng-bootstrap 20.0.0, and Ionic Angular 9.0.3. An additional isolated matrix runs these tests on Angular 21.2.22 and Angular 22.1.6 with compatible UI versions in Chromium, plus Angular 21.2.22 in Firefox and WebKit. This covers the tested controls and versions, not every component or configuration offered by these libraries. See the repository's UI integration test matrix for the exact controls, scenarios, version selection, and known boundaries.
A CVA decides when it reports touched: for example, ng-bootstrap rating reports it during
selection. With debounce: 'blur', that callback commits the pending value in the same
interaction. Form Nodes follows the accessor's touch notification rather than assuming every
custom control waits for a native DOM blur.
Resetting a checkbox from its own value output can happen before the browser's clicked state has been rendered by the UI component. Material and PrimeNG checkboxes can then retain that visual state even though the node has reset; this is also reproducible with Reactive Forms. The integration tests cover that shared boundary and recovery after a rendered state update. Prefer performing related-field updates from the output and keeping explicit form reset as a separate application action.
The suite also covers PrimeNG formatted numbers, incomplete masks and object autocomplete, Material date ranges, array rows moved or removed during edits and open overlays, multiple controls sharing a node, and an OnPush date CVA with an inner Reactive Forms control. Autocomplete search text can differ from its selected model: user-value outputs follow the CVA's reported value, so typing a search does not necessarily select a new value.
Dialogs follow Angular's injector hierarchy. Pass the appropriate viewContainerRef when
opening a Material dialog that needs useClosestFormState().
For mutable model values such as Date, Moment, or objects, follow the
new-instance update pattern.
IME composition is tested with synthetic composition events; browser fill/clear does not simulate saved-profile autofill or password managers. Playwright WebKit coverage does not replace testing Safari on the devices your application supports.
Report errors from inside a controlโ
Use useFormNodeState({ errors: () => ... }) when the component knows that its current input
cannot be interpreted, such as invalid date text. Return one { kind } error, a message, an array,
or null/undefined/void for success. The callback is reactive and contributes to the bound
control's actual validity without replacing its configured validators.
See component error contributions for a complete
CVA example, lifecycle behavior, and the provideFormNodeStateErrors() provider required for CVAs
used with Angular 22 Signal Forms.
Standalone useโ
Bind an independent field() with [formNode] when the control does not belong to a larger
form. useFormNodeState() and its error contributions observe that field in the same way as a
form child. See Independent fields for an example.
The same controls also support the [formNodeValue] input;
useFormNodeState() then observes the independent field created by the binding.
Built-in error presentationโ
Place FormNodeErrors beneath the input inside your custom control and pass the full
useFormNodeState() result through [state]. The parent continues to bind the custom control
with [formNode] and declares its validators on the field.
This complete example uses a signal-model control. The comments identify suggested application files; both components keep their templates next to their models.
import { Component, input, model, signal } from '@angular/core';
import { email, field, form, required, FormNodeErrors, FormNodeDirective, useFormNodeState } from '@ngblocks/form-nodes';
// email-input.component.ts
@Component({
selector: 'my-email-input',
imports: [FormNodeErrors],
template: `
<label [for]="inputId()">{{ label() }}</label>
<input
type="email"
autocomplete="email"
[id]="inputId()"
[value]="value() ?? ''"
[disabled]="state.disabled()"
[readOnly]="state.readonly()"
[required]="state.required()"
[attr.aria-invalid]="state.invalid()"
[attr.aria-describedby]="inputId() + '-errors'"
(input)="edit($event)" (blur)="state.markAsTouched()"
/>
<form-node-errors [id]="inputId() + '-errors'" [state]="state" [animate]="animateErrors()">
<ng-template #message let-message>
<span aria-hidden="true">!</span> {{ message }}
</ng-template>
</form-node-errors>
`,
host: { '[hidden]': 'state.hidden()' },
})
export class EmailInput {
inputId = input.required<string>();
label = input('Email');
animateErrors = input(true);
value = model<string | null>(null);
state = useFormNodeState();
edit(event: Event) {
this.value.set((event.target as HTMLInputElement).value);
}
}
// contact-editor.component.ts
@Component({
selector: 'app-contact-editor',
imports: [EmailInput, FormNodeDirective],
template: `
<form [formNode]="form">
<my-email-input inputId="primary-email" label="Primary email" [formNode]="form.email" />
<my-email-input inputId="backup-email" label="Backup email (optional)" [formNode]="form.backupEmail" [animateErrors]="false" />
<button type="submit">Continue</button>
<button type="button" (click)="form.resetToInitial()">Start over</button>
</form>
@if (submittedEmail(); as address) {
<p>Submitted primary email: {{ address }}</p>
}
`,
})
export class ContactEditor {
submittedEmail = signal<string | null>(null);
form = form({
email: field('', [required('Enter your primary email address.'), email]),
backupEmail: field('', [email]),
}, {
onSubmit: (value) => {
this.submittedEmail.set(value.email);
},
});
}
The reusable component does not receive the node separately. value handles edits, state
provides validation and availability, and blur calls state.markAsTouched(). The nested error
component then shows one message after touch or an attempted form submission. Each instance uses
a unique input/error ID pair, and the optional backup address demonstrates [animateErrors]="false".
The nested <ng-template #message let-message> receives the first visible message and adds a
decorative icon. It is discovered automatically; let-messages="messages" also exposes the
visible message list when you want to render several messages yourself.
For an existing CVA, keep its normal change and touch callbacks and use the same
state = useFormNodeState() / <form-node-errors [state]="state"> combination. The helper also
includes the state.formSubmitted() shortcut and the optional state.form.formNode() API; the error
component already uses submission history, so no extra visibility condition is needed.
See FormNodeErrors for message limits, custom text, visibility policies, and the native-input example.