Advanced custom controls
Start with Custom controls for a minimal component integration. This page covers alternative control contracts, state inputs and hooks, Angular forms state observation, detailed CVA behavior, wrappers, and compatibility boundaries.
Optional state and constraint inputs are not synchronized by default. Opt in with syncInputs: 'declared'
for initial declarations or 'all' for all supported inputs. Value/checked models still work
without this Angular-internal adapter. See input synchronization.
π Angular API compatibilityβ
Choose the Angular contract that already fits your control. Conventional components require no Form Nodes interface, base class, or registration provider.
| Angular control API | Recognized shape | Binding support |
|---|---|---|
| Signal Forms value control | value = model<T>() | Fields, forms, and arrays |
| Signal Forms checkbox control | checked = model<boolean>() | Boolean fields |
| Separate input/output control | value/valueChange or checked/checkedChange | Experimental; requires bindInputOutputPairs: true |
| Reactive Forms / Forms API | ControlValueAccessor through NG_VALUE_ACCESSOR | Fields and compatible aggregate values |
| Native form element | input, select, or textarea | Scalar fields |
The value and checked contracts follow Angular's FormValueControl<T> and
FormCheckboxControl shapes. A component does not have to declare that it implements those types;
[formNode] discovers the public Angular inputs and outputs from component metadata.
β FormValueControl support and the experimental boundaryβ
The value = model() contract works without experimental options. Full automatic population
of FormValueControl state and constraint inputs is experimental because those writes use
Angular internals. Enable syncInputs: 'all' to synchronize every supported input, or use
'declared', an input list, or { inputs, target } to limit the selection.
The same option is available on field(), form(), group(), array(), createFormPrimitives()
defaults, provideFormNodesConfig(), and configureGlobalFormNodes(). Node settings apply only
to that node's binding. See selection modes and precedence.
A component implementing FormValueControl can instead combine value = model() with
useFormNodeState(). It then has value binding and full access to Form Nodes state through public
APIs, without enabling input synchronization. The component reads the hook's signals to render
state, constraints, and errors and calls markAsTouched() on blur. The hook does not write the
component's existing input properties or render its DOM for it. The same approach works with a
checked = model() checkbox. See the complete FormValueControl example.
ControlValueAccessor, including NG_VALUE_ACCESSOR registration, retains its standard value,
touch, and disabled-state integration without experimental options. Only additional automatic
state/constraint input writes require syncInputs.
Binding precedence is deterministic when a component exposes more than one mechanism:
ControlValueAccessor- An automatically discovered model or input/output pair (pairs require
bindInputOutputPairs: true) - Native element handling
π Signal model controlsβ
The zero-configuration approach is a component exposing value = model<T>() or, for a checkbox, checked = model<boolean>():
import { Component, model, output } from '@angular/core';
@Component({
selector: 'app-rating',
template: `...`,
})
export class Rating {
readonly value = model(0);
readonly touch = output<void>();
focus(options?: FocusOptions) {
// Focus the component's interactive element.
}
}
<app-rating [formNode]="review.rating" />
Model value binding uses public set() and subscribe() APIs. Separate value/valueChange
or checked/checkedChange input/output properties require experimental bindInputOutputPairs: true.
Use syncInputs independently to select state inputs. An empty list never enables a pair. False/null
for bindInputOutputPairs pauses value and state writes, change/touch processing, and optional component
focus/reset hooks. See the complete paired-control example.
A custom control intended for both formField and formNode must initialize its value or
checked model itself. Making that model required can compile with Angular's directive while
rejecting the equivalent third-party binding because third-party directives cannot participate in
Angular's special required-model rule. The bound node replaces the initial value during setup.
β Aggregate value modelsβ
A value = model<T>() control may bind directly to a form() or array() when T is the complete
aggregate value:
@Component({
selector: 'app-address-editor',
template: `...`,
})
export class AddressEditor {
readonly value = model({ city: '', country: '' });
}
<app-address-editor [formNode]="myForm.address" />
A value emitted by the control marks the directly bound aggregate node dirty and distributes or reconciles the complete value through its children. The descendants are not individually marked dirty solely because the aggregate control changed them.
Optional standard state inputsβsuch as errors, disabled, dirty, hidden, invalid, min, max, name, pending, readonly, required, and touchedβreceive node state only with experimental syncInputs: 'all' or when selected by another experimental mode. 'signal-controls' synchronizes every supported input on a model control while excluding CVAs and paired input/output controls. Optional touch, focus(), and reset() hooks integrate with interaction and reset behavior.
The complete recognized state surface is errors, disabled, disabledReasons, dirty,
hidden, invalid, max, maxLength, min, minLength, name, pattern, pending,
readonly, required, and touched.
Declare only the inputs the component uses. Public input aliases and transforms are preserved for
both input() and decorator inputs, and components implementing ngOnChanges receive the state
changes. The optional touch output marks the node touched; focus(options?) is used by
node.focus(), and reset() is called during the binding reset lifecycle.
β‘ Read bound state without state inputsβ
The useFormNodeState() reference lists the complete API,
defaults, source precedence, and lifecycle behavior.
useFormNodeState() is the stable alternative when a component does not want [formNode] to
write optional disabled, readonly, required, or error inputs through Angular internals. Call
it in the component injection context and read its signals directly:
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,
}),
});
}
The same state implementation works with [formNode], [formField], [formControl],
[formControlName], and [(ngModel)]. This makes the hook suitable for reusable custom controls
whose callers use different Angular forms APIs; no manual state adapter selection is needed.
Use hasError(kind) and getError(kind) to query the same normalized errors across those bindings.
The latter returns the first full error object or undefined; names are preserved, including
Angular minlength versus Form Nodes minLength.
hasValidator(required) and hasValidator(Validators.required) both query the active required
state. Other functions use direct registration identity for Form Nodes and Angular AbstractControl
bindings; Angular Signal Forms returns undefined for unsupported reference queries.
Pass { resolve: true } as the second argument to inspect synchronous compositions on a
[formNode] binding; other bindings retain their existing query behavior. See
validator queries for the complete contract.
connected() reports whether a supported binding is present, and source() identifies the active
adapter without changing the component's API.
The Reactive Forms and template-driven adapters observe the public AbstractControl.events stream
and reconcile the current directive control after rendering. They therefore follow a replaced
FormControl and pick up { emitEvent: false } mutations on the next render. They provide value,
disabled, dirty, touched, invalid, pending, errors, required-rule detection, and names declared by formControlName or
ngModel. Required detection recognizes directly registered Validators.required / Validators.requiredTrue and an active
Angular required directive on the host, including changes while the value is valid. Properties these APIs do not expose, including readonly, hidden, and disabled reasons, retain their safe neutral defaults. Standard Angular validator directives on the host additionally expose numeric, length, and pattern
constraints; validator-function parameters are not inspected. See constraint support.
[formField] exposes Angular
Signal Forms state, including constraints, required, readonly, hidden, and disabled reasons.
All errors are exposed as readonly { kind: string; ... }[], regardless of the source-specific
error representation. The remaining signals include value, disabled, disabledReasons,
dirty, hidden, invalid, constraints, name, pattern, pending, readonly, required, and
touched. Disabled reasons are normalized to { message?: string }, without exposing a Form Nodes node or
Angular field tree. An unnamed active reason remains {} rather than being removed, so an empty
array always means that no known reason is active. An unbound component receives neutral values
such as false, [], undefined, and null rather than an injection error.
Server rendering safely starts render-discovered Angular adapters disconnected; they connect after
the component is rendered in the browser. [formNode] can connect synchronously through its host
registry. Code should always treat connected() as the authority and rely on the neutral defaults
while no source is available.
Call markAsTouched() from the custom control's blur interaction to notify whichever forms API is
currently connected. The operation delegates to that API's native touched behavior and is a safe
no-op while disconnected.
useFormNodeState() is deliberately not a second form-control API. Read state from its signals
and use markAsTouched() to report the control's blur interaction. Send user-authored value changes
through the component's model(), Angular FormValueControl, or ControlValueAccessor callbacks.
Programmatic value writes, reset, disabled state, and other form operations remain owned by the API
that created the form. Consequently, the facade does not expose setValue(), reset(),
disable(), or enable().
π ControlValueAccessorβ
Existing CVA controls work without changes:
<app-existing-date-picker [formNode]="myForm.appointment" />
[formNode] calls writeValue(), registers change and touch callbacks, and propagates disabled
state through the normal CVA contract. It also provides a lightweight NgControl view for
componentsβsuch as Angular Material-style controlsβthat inspect their injected control.
If several accessors match, selection follows Angular's precedence: custom, specialized built-in,
then default. Reentrant change callbacks fired from inside writeValue() are ignored so legacy
controls cannot create a feedback loop or mark a programmatic update dirty.
Synchronous validators registered through NG_VALIDATORS join the node's validation state, and
registerOnValidatorChange() triggers reevaluation. NG_ASYNC_VALIDATORS are intentionally not
adapted; use the node's async validation pipeline, which owns pending state,
cancellation, debounce, and stale-result handling.
A CVA component can also declare the standard signal state inputs listed above. Those inputs receive the same node state as a signal-model control.
β Existing controls that inject NgControlβ
A CVA can obtain NgControl from its host injector in ngAfterContentInit() or
ngAfterViewInit() and keep its existing subscriptions. [formNode] supplies the adapter automatically; no extra provider or Angular
FormControl is needed in the application.
Use the documented Angular members through the injected NgControl. Members prefixed with _
are adapter implementation details and are not supported CVA integration points.
import { startWith } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormNodeDirective, field, form, required } from '@ngblocks/form-nodes';
import { Component, DestroyRef, Injector, forwardRef, inject, signal } from '@angular/core';
import { NG_VALUE_ACCESSOR, NgControl, type ControlValueAccessor, type ValidationErrors } from '@angular/forms';
// An existing CVA can keep its Angular Forms integration unchanged.
@Component({
selector: 'app-legacy-text-control',
template: `<input #input [value]="value()" (input)="onInputValueChange(input.value)" (blur)="onTouched()" />`,
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => LegacyTextControl), multi: true }],
})
export class LegacyTextControl implements ControlValueAccessor {
injector = inject(Injector);
destroyRef = inject(DestroyRef);
value = signal('');
errors = signal<ValidationErrors | null>(null);
ngAfterContentInit() {
// Capture Injector during construction; inject() cannot be called directly in this hook.
const ngControl = this.injector.get(NgControl);
ngControl.statusChanges!
.pipe(takeUntilDestroyed(this.destroyRef), startWith(ngControl.status))
.subscribe(() => {
this.errors.set(ngControl.errors);
});
}
onInputValueChange(value: string) {
this.value.set(value);
this.onChange(value);
}
// Ng Value Accessor implementation
onChange = (_value: string) => {};
onTouched = () => {};
registerOnChange(fn: any) { this.onChange = fn }
registerOnTouched(fn: any) { this.onTouched = fn }
writeValue(value: string | null) { this.value.set(value ?? ''); }
}
@Component({
imports: [FormNodeDirective, LegacyTextControl],
template: `<app-legacy-text-control [formNode]="form.username" />`,
})
export class ProfileEditor {
form = form({
username: field('', [required]),
});
}
Both ngControl and ngControl.control expose current value, errors, validation status,
disabled state, and dirty/touched state. Error keys come from each node error's kind. Validator errors retain the complete Form Nodes
error; errors supplied through control.setErrors() return their original Angular payload.
value includes pending debounced input, even when the
node's committed or equality-filtered public value still differs.
The injected directive's name and path describe the bound node's current structural location:
| Bound node | name | path |
|---|---|---|
| Root or detached node | null | [] |
Group or nested form form.address | 'address' | ['address'] |
Leaf form.address.city | 'city' | ['address', 'city'] |
Array item form.contacts[0] | 0 | ['contacts', '0'] |
Nested forms remain part of the full structural path. Detaching a subtree makes that subtree
its own root; its descendants keep their relative paths within it. Reads track structural
changes and binding replacement, including array reordering, independently of public value equality.
Each path read returns a fresh array, so modifying it cannot modify node metadata.
These properties are read-only views: use node operations to change structure.
This intentionally differs from Angular's directive-container paths: DOM nesting, HTML name
attributes, and a custom control's name input do not determine this identity. Read these members
from ngControl; Angular's AbstractControl type has neither member. The combined adapter uses
one runtime object for ngControl and ngControl.control, so both have the same runtime values.
A local helper can capture the host injector during construction and resolve
NgControl in a lifecycle hook, following the same deferred lookup as the example above.
β CVA value changes and validation refresh requestsβ
Send control input through the callback supplied to registerOnChange(), and report blur through
registerOnTouched(). [formNode] uses those callbacks to update the control value, apply node
debounce, mark interaction, and validate the committed value. The callbacks follow binding
replacement and become inactive when the binding is destroyed.
viewToModelUpdate() is not supported. In Angular's Forms directives, that method updates their
view-model cache and emits ngModelChange; Angular writes the control value separately.
[formNode] has no ngModelChange output. A CVA that directly calls that directive method must
use its registered change callback for value input instead.
control.updateValueAndValidity() remains a no-op, following Angular Signal Forms. Values and
validation are current when read and update through their signal dependencies. Calling it does
not force validator execution, clear errors, flush pending input, mark interaction, or emit
additional events. It does not restart or cancel asynchronous validation. Its onlySelf and
emitEvent arguments have no effect: they cannot isolate reactive ancestors or silence an
independent value change or asynchronous completion.
Use the node API to replace configured validators. If an NG_VALIDATORS CVA changes a rule that
depends on ordinary properties, invoke the callback received through registerOnValidatorChange().
Calling updateValueAndValidity() is not a substitute for that notification. Reactive rule
dependencies update normally without either call.
β Validator functions and node validationβ
Both ngControl.validator / asyncValidator and the corresponding control properties return
null. This means the adapter exposes no transferable Angular validator functions, not that
the bound node has no validation. These properties are read-only compatibility views.
Angular's functions accept an AbstractControl and execute its validation. Form Nodes validators
instead read a reactive node context, and asynchronous validators also depend on the node's
scheduling and cancellation. Returning cached node errors from a function would not validate the
supplied Angular control; exporting the underlying callbacks would bypass that ownership.
Consequently, invoking or copying validators through these properties is unsupported. Do not use
their nullness to decide whether a field is optional or valid.
Use errors, getError(), pending, and statusChanges to observe validation. For the required
indicator, control.hasValidator(Validators.required) reads node required metadata, including
reactive requiredIf. Other Angular validator identities have no defined mapping. Configure or
replace rules through the node API. A CVA that depends on executing an injected validator against
a fabricated AbstractControl must adapt to these state queries.
CVA rules supplied through NG_VALIDATORS still contribute binding-owned errors and respond to
registerOnValidatorChange(). They are not exported as a second executable function. Existing
node asynchronous validation continues normally when these properties are inspected, including
pending results, reactive dependency changes, cancellation, and rebinding.
β Resetting from an existing CVAβ
ngControl.reset() and ngControl.control.reset() reset the currently bound node and its
subtree. With no value, or with undefined, reset preserves the latest committed values,
discards buffered input, and clears dirty/touched state. An explicit value uses the node's
reset(value) behavior: provide complete form values; arrays reconcile their items using the
configured identity rules. Reset clears binding-owned parsing errors throughout the reset
subtree and writes the reset values back to controls, even when public value equality retains
an older value. Sibling state is preserved and ancestors recompute normally.
Configured validators remain active. Reset cancels pending control debounce. It does not unconditionally restart or cancel asynchronous validation: unchanged dependencies keep their current work, while changed dependencies cancel obsolete work through the node's validation pipeline. Asynchronous results can still arrive after reset.
To suppress this adapter's reset notifications, use
ngControl.control.reset(undefined, { emitEvent: false }), or supply a value in the first argument.
This silences the synchronous reset result on its valueChanges, statusChanges, and events;
other bindings and ancestor adapters still observe their node changes. Later writes and
asynchronous validation results remain observable. Without suppression, the adapter emits a
synchronous FormResetEvent with itself as source; value, status, and interaction changes
retain their usual scheduled, coalesced notifications. A reset with unchanged state still emits
the reset event. Calling the node API directly does not synthesize this adapter reset event.
This is a Signal Forms reset contract, with intentional differences from Reactive Forms:
- No-argument reset preserves current committed values, rather than returning to null or an initial value.
- Values are raw node values.
{ value, disabled }is ordinary data, not an AngularFormControlStatewrapper; reset preserves disabled configuration. onlySelf: trueandoverwriteDefaultValue: trueare ignored with oneconsole.warnper reset call in development mode. Production mode suppresses the warning. Reset continues and respectsemitEvent; ancestors still update, and no reset default is stored. False or omitted options produce no warning.undefinedmeans no replacement value on this adapter. Use the node API when explicitly assigningundefinedis required.
β Inspecting errors and observing stateβ
Both surfaces also support getError(code, path?) and hasError(code, path?). For example,
after the date control below reports an invalid date:
ngControl.hasError('invalidDateFormat'); // true
ngControl.control!.getError('invalidDateFormat'); // { message: 'Enter a date as YYYY-MM-DD.', actual: '2026-02-30' }
Without a path, these methods inspect only the bound node's own errors. When a custom control
binds a form, group, or array, a relative path can select a descendant, for example
control.getError('required', 'contacts.0.email') or
control.hasError('required', ['contacts', 0, 'email']). Queries follow current children and array
positions after structural changes. Segment arrays also support child names containing dots.
getError() returns the same payload as the corresponding entry in control.errors: the complete
Form Nodes error for validators, or the original payload passed to setErrors(). It returns null
for an unresolved path or a node without errors, and undefined for a missing key in an existing
error map. Like Angular, hasError() checks the payload's truthiness, so a payload of false,
null, or undefined returns false even though that error key can make the node invalid.
The adapter supports these observable subscriptions:
ngControl.valueChangesandngControl.control.valueChangesreport control-value changes.ngControl.statusChangesandngControl.control.statusChangesreport validation status, including error-detail or pending-state changes when the status string stays the same.ngControl.control.eventsemits AngularValueChangeEvent,StatusChangeEvent,TouchedChangeEvent, andPristineChangeEventobjects. Theirsourceis the adapter control.
Getters are current immediately. Observables run during Angular effect synchronization, so several
writes before synchronization can produce one notification with the latest state. The first
synchronization publishes the current state; subscriptions added afterward do not replay it.
Read the getters for initial state, or use startWith(control.status) as the example does. This timing differs from the synchronous
notifications of Reactive Forms.
Replacing [formNode] with another node preserves the adapter and its subscriptions and publishes
the replacement's state. Destroying the binding completes all three streams.
The adapter supports state observation and control-originated errors through control.setErrors().
Keep value writes in the CVA change callback and programmatic operations on the Form Nodes node.
control.setValue() and general Reactive Forms tree traversal such as control.get() are not provided.
If an existing component copies these errors into its own Angular FormControl, check the order
of its operations: setErrors(externalErrors) followed by enable() runs the internal validators
again and replaces those manual errors, even if the internal control was already enabled.
disable() clears its errors too. The injected adapter still exposes the original node errors.
An internal validator that returns the external errors can preserve them during validation;
alternatively, enable the internal control before copying errors while it is enabled.
emitEvent: false suppresses notifications but does not prevent this revalidation.
Reading component signals such as disabledInput() inside a subscription does not subscribe to
those signals. If those inputs can change independently, the component needs its own mechanism
to synchronize them; statusChanges reports the bound node's state.
β Reporting parsing errors with setErrorsβ
A custom control can report an error that originates in its own UI, such as an unparseable date,
through its injected NgControl.control.setErrors():
import { FormNodeDirective, field, form, required } from '@ngblocks/form-nodes';
import { Component, Injector, forwardRef, inject, signal } from '@angular/core';
import { NG_VALUE_ACCESSOR, NgControl, type ControlValueAccessor } from '@angular/forms';
@Component({
selector: 'app-date-input',
template: `
<input
#input
placeholder="YYYY-MM-DD"
[value]="text()"
[disabled]="disabled()"
(input)="changeDate(input.value)"
(blur)="onTouched()"
/>
`,
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => DateInput), multi: true }],
})
export class DateInput implements ControlValueAccessor {
injector = inject(Injector);
ngControl: NgControl | undefined;
text = signal('');
disabled = signal(false);
onChange: (value: Date | null) => void = () => {};
onTouched: () => void = () => {};
ngAfterContentInit() {
this.ngControl = this.injector.get(NgControl);
}
writeValue(value: Date | null) {
this.text.set(value?.toISOString().slice(0, 10) ?? '');
this.ngControl?.control?.setErrors(null);
}
registerOnChange(callback: (value: Date | null) => void) {
this.onChange = callback;
}
registerOnTouched(callback: () => void) {
this.onTouched = callback;
}
setDisabledState(disabled: boolean) {
this.disabled.set(disabled);
}
changeDate(text: string) {
this.text.set(text);
const date = new Date(`${text}T00:00:00.000Z`);
const valid = !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === text;
this.onChange(valid ? date : null);
this.ngControl!.control!.setErrors(text && !valid ? {
invalidDateFormat: { message: 'Enter a date as YYYY-MM-DD.', actual: text },
} : null);
}
}
@Component({
imports: [FormNodeDirective, DateInput],
template: `
<app-date-input [formNode]="form.appointment" />
@for (error of form.appointment.errors(); track error) {
<p>{{ error.message }}</p>
}
<button [disabled]="!form.valid()">Continue</button>
`,
})
export class AppointmentEditor {
form = form({
appointment: field<Date | null>(null, [required]),
});
}
In this example, 2026-02-30 produces invalidDateFormat. The error makes the appointment field
and its parent form invalid. The component preserves the typed text and sends null through its
normal CVA callback; required remains an independent validator. Correcting the date calls
setErrors(null) to remove the parsing error. writeValue() also clears it when the application
supplies a new date.
Each binding owns one imperative error source. setErrors(errors) replaces that source, and
setErrors(null) or setErrors({}) clears it. Pass the errors produced by this component; Form Nodes
already merges them with configured validators and other bindings. Clearing this source preserves
all other errors. Changes immediately update node and ancestor validity, including submission checks.
Angular payloads keep their shape in ngControl.errors: for example,
{ invalidDateFormat: { message: 'Invalid date' } }. On the node, the corresponding error has
kind: 'invalidDateFormat', the original payload in context, and targetNode and formNode
identifying its owners. A string payload.message is also exposed as the node error's message.
Validator-originated errors continue using the complete Form Nodes error object in ngControl.errors.
These are control-owned errors, so they persist across value changes and validation runs until the component replaces or clears them. A node reset, binding replacement, or binding destruction also clears them. Disabled, readonly, and hidden nodes suppress them using normal Form Nodes state rules; they become visible again when the node becomes interactive unless the component cleared them. This lifetime deliberately differs from Reactive Forms, which replaces manual errors on its next validation run.
setErrors(errors, { emitEvent: false }) suppresses the resulting statusChanges and
StatusChangeEvent notification on this adapter. Getters, node signals, and ancestor state still
update, and other bindings remain reactive. Independent state changes still produce notifications.
Notifications otherwise follow the effect timing described above.
π Wrapper componentsβ
A component can accept a formNode input and delegate it to an inner control:
@Component({
selector: 'app-text-field',
imports: [FormNodeDirective],
template: `<input [formNode]="formNode()" />`,
})
export class TextField {
readonly formNode = input.required<FieldNode<string>>();
}
<app-text-field [formNode]="profile.name" />
The wrapper is detected as pass-through, so only the inner control creates a binding. A directive or host directive that consumes or re-exports formNode must register provideFormNodePassThrough() because Angular does not expose equivalent public runtime input reflection for directives.
π Compatibility boundariesβ
- Automatic signal-control discovery applies to Angular components. A control implemented as a
directive or host directive should use a component wrapper or
ControlValueAccessor. - Native elements bind scalar fields. Use a value-model or CVA component when one control edits a complete object or array.
- State inputs declared by a component take precedence over same-named native host properties.
- Custom-element hosts do not receive synthetic native properties such as
disabled,required,readonly,name,min, ormaxunless the component declares the corresponding input. - Initial state is rendered during server rendering, and browser behavior reconnects during hydration.
See Control binding for native element behavior and state propagation. The advanced binding details cover selection precedence, ambiguous accessors, binding ownership, and server rendering semantics.
π Hooks that assign NgControl.valueAccessorβ
A component may use a utility such as useCustomValueAccessor() to inject NgControl and assign an
accessor directly. [formNode] recognizes that accessor at initialization; the component does
not need a model(), a ControlValueAccessor interface declaration, or an NG_VALUE_ACCESSOR
provider as well.
import { NgControl } from '@angular/forms';
import { Component, inject, signal } from '@angular/core';
import { field, form, FormNodeDirective } from '@ngblocks/form-nodes';
// Minimal illustration of a hook that assigns its accessor directly.
function useCustomValueAccessor<T>(options: { writeValue(value: T | null): unknown; setDisabledState(disabled: boolean): unknown }) {
const ngControl = inject(NgControl, { optional: true });
let onChange: (value: T) => void = () => {};
let onTouched: () => void = () => {};
if (ngControl) {
ngControl.valueAccessor = {
writeValue: options.writeValue,
setDisabledState: options.setDisabledState,
registerOnChange(callback) { onChange = callback; },
registerOnTouched(callback) { onTouched = callback; },
};
}
return {
emitChange(value: T) { onChange(value); },
markAsTouched() { onTouched(); },
};
}
@Component({
selector: 'app-hook-input',
template: `
<input #text [value]="value()" [disabled]="disabled()"
(input)="edit(text.value)" (blur)="ngControl.markAsTouched()">
`,
})
export class HookInput {
value = signal('');
disabled = signal(false);
ngControl = useCustomValueAccessor<string>({
writeValue: value => this.value.set(value ?? ''),
setDisabledState: disabled => this.disabled.set(disabled),
});
edit(value: string) {
this.value.set(value);
this.ngControl.emitChange(value);
}
}
@Component({
imports: [FormNodeDirective, HookInput],
template: '<app-hook-input [formNode]="form.name" />',
})
export class ProfileComponent {
form = form({ name: field('Mark') });
}
This example's local useCustomValueAccessor() illustrates the registration pattern; it is not exported
by Form Nodes. Register the accessor synchronously during component construction, before
[formNode] initializes. A directly assigned accessor takes precedence over one discovered
through NG_VALUE_ACCESSOR. Replacing the accessor after initialization is not supported.
Form Nodes calls writeValue() for node-to-control updates and setDisabledState() for disabled
state. User edits must update the component's own view before calling emitChange(); the same
change is not echoed back through writeValue(). Always invoke the touched callback on blur,
even if the control is already touched: later interactions can still need to flush pending
input when the node uses debounce: 'blur'. A hook that suppresses repeated touched callbacks
must remove that guard to support repeated blur-debounced edits.
β State-observing hooksβ
Hooks that wrap control.updateValueAndValidity() to invalidate untracked value/error reads
can observe node changes. The adapter also provides the internal reactive status, touched, and
pristine signals used by the supplied useFormControlState() pattern. Updates propagate through
Angular's reactive synchronization, including programmatic writes, async validation results,
disabled state, reset, and node rebinding. Reading data this way does not rerun validators.
These internal signals are compatibility details, not an additional public node API.
The injected control remains a Form Nodes adapter, not a complete Angular FormControl.
In particular, a hook's optional validator/asyncValidator callbacks that depend on
addValidators()/addAsyncValidators() are not supported. Configure those rules on the node,
or use the documented NG_VALIDATORS CVA integration. Use useFormNodeState() for new
components that need a supported state facade without patching Angular control methods.