Skip to main content

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.

πŸ§ͺ Experimental state input synchronization

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 APIRecognized shapeBinding support
Signal Forms value controlvalue = model<T>()Fields, forms, and arrays
Signal Forms checkbox controlchecked = model<boolean>()Boolean fields
Separate input/output controlvalue/valueChange or checked/checkedChangeExperimental; requires bindInputOutputPairs: true
Reactive Forms / Forms APIControlValueAccessor through NG_VALUE_ACCESSORFields and compatible aggregate values
Native form elementinput, select, or textareaScalar 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:

  1. ControlValueAccessor
  2. An automatically discovered model or input/output pair (pairs require bindInputOutputPairs: true)
  3. 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.

Do not require the control model input

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 nodenamepath
Root or detached nodenull[]
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 Angular FormControlState wrapper; reset preserves disabled configuration.
  • onlySelf: true and overwriteDefaultValue: true are ignored with one console.warn per reset call in development mode. Production mode suppresses the warning. Reset continues and respects emitEvent; ancestors still update, and no reset default is stored. False or omitted options produce no warning.
  • undefined means no replacement value on this adapter. Use the node API when explicitly assigning undefined is 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.valueChanges and ngControl.control.valueChanges report control-value changes.
  • ngControl.statusChanges and ngControl.control.statusChanges report validation status, including error-detail or pending-state changes when the status string stays the same.
  • ngControl.control.events emits Angular ValueChangeEvent, StatusChangeEvent, TouchedChangeEvent, and PristineChangeEvent objects. Their source is 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, or max unless 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.