Control binding
The type-checked Angular example
covers the standalone directive import, [formNode], FormNodeDirective, and viewChild.required().
Import FormNodeDirective and bind a node with [formNode]:
import { Component } from '@angular/core';
import { FormNodeDirective, field } from '@ngblocks/form-nodes';
@Component({
imports: [FormNodeDirective],
template: `<input [formNode]="name" />`,
})
export class Editor {
name = field('');
}
It supports native input, select, and textarea elements, Angular ControlValueAccessor
components, value = model<T>() controls, and checked = model<boolean>() checkbox controls. Native controls bind leaf field() nodes; aggregate forms and
arrays require a custom control that represents their complete value. Separate input/output pairs
are also available through experimental bindInputOutputPairs. See
Advanced custom controls for the complete compatibility
matrix and integration boundaries.
File inputs
Bind a file input directly to a field. The multiple attribute determines the value shape:
| Native control | Field declaration | Empty user selection |
|---|---|---|
<input type="file"> | field<File>(null) | null |
<input type="file" multiple> | field<File[]>([]) | [] |
A multiple selection is one field<File[]>, not an array() node. Its value is a snapshot of
input.files as a normal array containing the original File objects.
import { Component, computed } from '@angular/core';
import { field, form, required, FormNodeDirective } from '@ngblocks/form-nodes';
@Component({
selector: 'app-document-editor',
imports: [FormNodeDirective],
template: `
<form [formNode]="form">
<label>
Cover image
<input type="file" accept="image/*" [formNode]="form.cover" />
</label>
<p>{{ coverName() }}</p>
<label>
Attachments
<input type="file" multiple [formNode]="form.attachments" />
</label>
@for (file of form.attachments() ?? []; track file) {
<p>{{ file.name }} ({{ file.size }} bytes)</p>
}
<button type="button" (click)="form.cover.set(null)">Clear cover</button>
<button type="button" (click)="form.resetToInitial()">Start over</button>
</form>
`,
})
export class DocumentEditor {
form = form({
cover: field<File>(null, [required]),
attachments: field<File[]>([]),
});
coverName = computed(() => this.form.cover()?.name ?? 'No file selected');
}
Reading form.cover()?.name in a template or computed() tracks the field: selecting or
clearing a file updates the displayed name. Files also expose size (bytes), type, and
lastModified. File metadata itself is immutable; replace the field value to select a different
file. Replace arrays with set() or update() instead of mutating them in place.
These bindings use the usual dirty, touched, validation, debounce, and committed-output rules.
With debounce, ordinary node reads show the committed file until the pending selection commits.
Cancelling the picker leaves the selection unchanged. The browser's input and change events
for the same selection produce one value update.
Use set(null) to clear a single input and set([]) to clear a multiple input. Nullish values
also clear either control. Existing File objects can be assigned programmatically; populated
selections synchronize through the browser's DataTransfer and input.files APIs. A string
path cannot select a local file. Keep multiple consistent with the model shape: a non-null
single value must be a File, and a multiple value must be a File[].
reset() preserves the current committed value and clears interaction state; resetToInitial()
restores the original file value, including the native selection. Files are not serialized into
server-rendered HTML. Populating a selection requires browser DataTransfer support; clearing
it does not.
Selection does not upload anything. Build a FormData payload and send it through your own
HTTP client when appropriate; JSON does not serialize file contents. The accept attribute is a
picker hint, not a validator. Validate size/type in your application and validate uploaded data
on the server.
import assert from 'node:assert/strict';
import { computed } from '@angular/core';
import { field, form } from '@ngblocks/form-nodes';
const upload = form({
attachment: field<File>(null),
});
const filename = computed(() => upload.attachment()?.name ?? 'No file selected');
assert.equal(filename(), 'No file selected');
const report = new File(['Quarterly report'], 'report.txt', { type: 'text/plain' });
upload.attachment.set(report);
upload.attachment()?.name; // 'report.txt'
upload.attachment()?.size; // 16
assert.equal(filename(), 'report.txt');
assert.equal(upload.attachment()?.size, 16);
assert.equal(upload.attachment(), report);
// Send this payload with your application's HTTP client when ready to upload.
const payload = new FormData();
const attachment = upload.attachment();
if (attachment) payload.append('attachment', attachment);
assert.equal((payload.get('attachment') as File).name, 'report.txt');
upload.resetToInitial();
upload.attachment(); // null
assert.equal(upload.attachment(), null);
assert.equal(filename(), 'No file selected');
Independent fields
Declare a field() when a control does not belong to a larger form. Bind the field itself with
[formNode] and read its value by calling it. Control edits update the field automatically;
programmatic set() calls update the control without emitting control-change outputs.
import { Component } from '@angular/core';
import { field, FormNodeDirective } from '@ngblocks/form-nodes';
@Component({
imports: [FormNodeDirective],
template: `
<label>Search <input [formNode]="search" /></label>
<p>Current search: {{ search() }}</p>
<button type="button" (click)="search.set('')">Clear search</button>
`,
})
export class SearchPage {
search = field('');
}
The same binding works with native controls, CVAs, and supported signal controls. The field owns
its validators, debounce, and interaction state. useFormNodeState() observes that state and can
contribute errors. reset() clears interaction state while retaining the current value;
resetToInitial() restores the declared initial value.
An independent field remains a separate root even when its control appears inside a bound
<form>. Declare it as a child of form() when it should participate in that form's value,
validation, submission, and reset. Use one shared field for all controls in a radio group.
If you need to supply a raw value without declaring a node, see the
[formNodeValue] input. Control edits update its
independent field without assigning the application source.
🔌 Native controls
The directive synchronizes value, disabled, readonly, required, name, and applicable constraint state. DOM input updates use value.control.set(), mark the field dirty, and follow its debounce. Blur marks it touched.
<input [formNode]="profile.name" />
<input type="number" [formNode]="profile.age" />
<select [formNode]="profile.country">
<option value="ch">Switzerland</option>
<option value="es">Spain</option>
</select>
Native support includes text and numeric inputs, range, checkbox, radio, date-like inputs, single and multiple selects, and textareas. IME composition is buffered until compositionend. Dynamically changing between compatible textual input types preserves synchronization.
Bindings receive a stable generated name based on the application, structural root, and reactive path. Controls bound to the same field share a name, preserving radio groups; moving an array item updates that path-derived name. An explicitly authored native name is replaced.
Select values are reapplied when options change, including asynchronously rendered options. Radio bindings reevaluate their authored option value after Angular renders.
◆ Radio buttons
Bind every radio in a group to the same field and give each option a distinct string value.
The field's initial value selects the matching option. [formNode] generates the shared name,
so you do not need to set name or checked yourself.
import { Component } from '@angular/core';
import { field, form, FormNodeDirective } from '@ngblocks/form-nodes';
@Component({
selector: 'app-delivery-options',
imports: [FormNodeDirective],
template: `
<fieldset>
<legend>Delivery method</legend>
<label>
<input type="radio" value="standard" [formNode]="form.delivery" />
Standard delivery
</label>
<label>
<input type="radio" value="express" [formNode]="form.delivery" />
Express delivery
</label>
</fieldset>
<p>Selected delivery: {{ form.delivery() }}</p>
`,
})
export class DeliveryOptions {
form = form({
delivery: field('standard'),
});
}
Standard delivery starts selected. Selecting Express delivery updates form.delivery()
to 'express' and updates the displayed selection. The labels make each option clickable,
and the fieldset and legend identify the group.
Nullable text and numeric fields
A text input bound to field<string>(null) accepts strings immediately, including after
reset(null). Typing 007 keeps the string '007'; whitespace is preserved, and clearing the
input writes ''. The declared null remains in the model until the user edits it.
import { Component } from '@angular/core';
import { field, form, required, FormNodeDirective } from '@ngblocks/form-nodes';
@Component({
selector: 'app-contact-editor',
template: `
<label>
Name
<input [formNode]="form.name" />
</label>
<button type="button" (click)="form.reset({ name: null })">Reset name</button>
`,
imports: [FormNodeDirective],
})
export class ContactEditor {
form = form({
name: field<string>(null, [required]),
});
}
For a numeric field that starts at null, use <input type="number">. Generic types disappear at
runtime, so field<number>(null) alone cannot tell a text input that it should parse numbers.
Text inputs already bound to a numeric value keep their numeric parsing through clearing and
reset(null) while that binding remains connected. An observed string value switches back to text;
rebinding to another node starts inference again. Invalid numeric text retains the last committed
value and exposes a parse error.
✅ Native constraints
required, aria-invalid, min, max, minLength, maxLength, and combined pattern metadata are synchronized when applicable:
- Numeric and date
min/maxare written to number, range, date, and month inputs. minLengthandmaxLengthapply to inputs and textareas, not selects.- Multiple pattern validators become one native pattern requiring every expression.
- Without active pattern validators, the native
patternattribute is removed. An empty HTML pattern would incorrectly reject every nonempty value. Removing or disabling the final pattern validator also clears the native restriction. - Node validation remains authoritative; browser constraints improve native UI interoperability.
- Time, week, and datetime-local currently do not receive
min/max, matching Angular 22 Signal Forms behavior.
Invalid native numeric or date input produces a parse error while retaining the last valid model value and the user's raw text. A later valid input, programmatic update, reset, rebind, or binding destruction clears the binding-owned parse error.
🔌 Querying the binding
Export the directive and query it with Angular's signal-based viewChild():
import { Component, viewChild } from '@angular/core';
import { FormNodeDirective, field } from '@ngblocks/form-nodes';
@Component({
imports: [FormNodeDirective],
template: `<input #nameBinding="formNode" [formNode]="name" />`,
})
export class Editor {
name = field('');
readonly nameBinding = viewChild.required<FormNodeDirective<typeof this.name>>('nameBinding');
focusName() {
this.nameBinding().focus();
}
}
The public binding exposes node(), errors(), element, injector, focus(), flush(), and reset().
👆 Focus
Every node also exposes focus(options?). A field focuses its first binding in DOM order; a form or array searches its current subtree. Calling it without a rendered binding is a no-op.
profile.name.focus();
profile.focus();
⚡ Status classes
If your application uses a shared NgModule, it can import and re-export FormNodeDirective. Configure
bindings in either the application providers or SharedModule.providers, according to who owns
the convention. See Using FormNodeDirective through SharedModule
for complete examples of both approaches and their injector scopes.
Configure reactive classes once in the standalone application providers:
import type { ApplicationConfig } from '@angular/core';
import { provideFormNodesConfig } from '@ngblocks/form-nodes';
export const appConfig: ApplicationConfig = {
providers: [
provideFormNodesConfig({
classes: {
'is-invalid': binding => binding.node().$api.invalid(),
'is-touched': binding => binding.node().$api.touched(),
'is-pending': binding => binding.node().$api.pending(),
},
}),
],
};
The configuration applies to [formNode] bindings below that injector. Routes and components can
provide a more local configuration. An NgModule provider's scope depends on how that module is
loaded: an eagerly imported root module does not create an isolated configuration scope.
The nearest provider wins, and each predicate tracks only the signals it reads. Angular's provideSignalFormsConfig() independently configures
Angular [formField] controls; both providers can coexist.
Use the optional preset when application styles or a UI library expect Angular Forms status
classes. [formNode] does not require the preset:
import type { ApplicationConfig } from '@angular/core';
import { ANGULAR_FORMS_STATUS_CLASSES, provideFormNodesConfig } from '@ngblocks/form-nodes';
export const appConfig: ApplicationConfig = {
providers: [
provideFormNodesConfig({ classes: ANGULAR_FORMS_STATUS_CLASSES }),
],
};
It adds ng-valid/ng-invalid, ng-pending, ng-pristine/ng-dirty, and
ng-untouched/ng-touched. The classes update reactively with the bound node and do not alter its
state. No status classes are installed by default. See
ANGULAR_FORMS_STATUS_CLASSES for the
complete mapping and extension example.
🔌 Hidden controls
hidden() is form state and does not alter DOM visibility. Remove hidden controls in the template with @if. Development builds warn when a hidden node remains rendered.
💡 Server rendering and hydration
Initial native and custom-control state renders on the server. Browser-only observation is deferred until the browser, and hydration reuses the rendered controls while reconnecting events and reactive state.
See Custom controls for component integration.
For multiple bindings, control-owned error filtering, accessor precedence, and SSR edge cases, see
Advanced behavior and edge cases.
The FormNodeDirective binding reference lists its instance API,
configuration providers, control contracts, pass-through registration, and native form directive.
Receiving control edits
Prefer (formNodeValueChange) when reacting to an updated node value, or
(formNodeControlValueChange) for the immediate draft before debounce. Both work across native
controls, CVAs, signal controls, and enabled input/output pairs, so consumers do not need to select
a native input or change event for each control type. See the
value output reference for a complete component
example and the control-originated event contract.
A custom equal comparator can retain the previously exposed node() value without preventing
the control from displaying new input. Rendering uses value.control(), while
value.committed() exposes the latest committed data before the public equality check.
Consequently, formNodeValueChange can emit the retained public value for an edit that compares
equal; formNodeControlValueChange carries the latest control value. Debounce still determines
when input is committed. See value outputs.
Boolean presence and acceptance
Use required for a yes/no question initialized to null, so false is a valid answer.
Use requiredTrue for a checkbox that must be checked. notNil only rejects null and undefined.
import { Component } from '@angular/core';
import { field, form, required, requiredTrue, FormNodeErrors, FormNodeDirective } from '../../src/public-api';
@Component({
selector: 'app-checkout',
imports: [FormNodeDirective, FormNodeErrors],
template: `
<form [formNode]="form">
<fieldset>
<legend>Do you need an invoice?</legend>
<button type="button" [attr.aria-pressed]="form.wantsInvoice() === true"
(click)="answer(true)">Yes</button>
<button type="button" [attr.aria-pressed]="form.wantsInvoice() === false"
(click)="answer(false)">No</button>
<form-node-errors [node]="form.wantsInvoice" />
</fieldset>
<label>
<input type="checkbox" [formNode]="form.acceptedTerms" />
I accept the terms
</label>
<form-node-errors [node]="form.acceptedTerms" />
<button type="submit">Continue</button>
</form>
`,
})
export class CheckoutComponent {
form = form({
wantsInvoice: field<boolean>(null, [required]),
acceptedTerms: field(false, [requiredTrue]),
}, {
onSubmit: async value => {
await fetch('/api/checkout', { method: 'POST', body: JSON.stringify(value) });
},
});
answer(value: boolean) {
this.form.wantsInvoice.set(value);
this.form.wantsInvoice.markAsTouched();
}
}
A native checkbox receives HTML required for requiredTrue, including its reactive when
condition. A presence-only required rule leaves the native checkbox constraint false, while
node.required() remains true. This keeps the browser's checkValidity() consistent with a
valid negative answer. notNil does not add HTML required to any control.
When experimental syncInputs includes required, custom controls exposing a public checked
input receive the same acceptance-specific value, including checkbox CVAs such as Angular Material.
Other custom controls receive the node's logical required() state. Existing CVA validators
still apply their own rules.
Inside a custom checkbox, use useFormNodeState().required() for a required indicator, but do
not automatically copy it to an inner native checkbox's [required]: the logical flag also
represents presence-only rules. Let Form Nodes errors drive validation, use the synchronized
required input on a checked control, or expose an explicit acceptance option in your wrapper.