Configuring nodes and sibling rules
Use the configure option when a rule needs several already-created children. Its callback receives
the node's callable, collision-safe $api, with the children inferred from
your declaration. No parent assertion or reference to the variable being initialized is necessary.
One callback for each node instance
| Primitive | Callback argument | Typical use |
|---|---|---|
field() | The field API, including value and setValidators | Install a reusable rule on the created field. |
group() | The group API, including typed children | Connect sibling validators. |
form() | The form API, including typed children and submission operations | Configure rules involving several branches. |
array() | The array API, including typed items() | Configure the collection itself. |
array(..., { configure }) configures the array. Use configureEach to configure each
new item directly, including ordinary object templates. A group() or form() template may
also carry its own configure callback. A factory remains useful when
you prefer capturing locally declared sibling nodes.
import { array, field, form, group, type ArrayItemNode, type FieldNode, type GroupNode } from '@ngblocks/form-nodes';
const deliveryForm = form({
packages: array(group({
deliveryMethod: field<'home' | 'pickup'>('home'),
pickupLocation: field(''),
}, {
configure: ({ children }) => {
children.pickupLocation.setValidators(() => {
return children.deliveryMethod() === 'pickup' && !children.pickupLocation()
? { kind: 'pickupLocation', message: 'Choose a pickup location.' }
: null;
});
},
}), {
initialValue: 2,
}),
});
type PackageNode = ArrayItemNode<typeof deliveryForm.packages>;
const first: PackageNode = deliveryForm.packages.at(0)!;
const second = deliveryForm.packages.at(1)!;
first.deliveryMethod.set('pickup');
first.pickupLocation.hasError('pickupLocation'); // true
second.pickupLocation.hasError('pickupLocation'); // false
if (!first.pickupLocation.hasError('pickupLocation') || second.pickupLocation.hasError('pickupLocation')) {
throw new Error('Sibling validation must stay within its own row.');
}
first.pickupLocation.set('Central station');
if (first.pickupLocation.invalid()) throw new Error('Choosing a pickup location must resolve the error.');
const added = deliveryForm.packages.push({ deliveryMethod: 'pickup', pickupLocation: '' });
if (!added.pickupLocation.hasError('pickupLocation')) throw new Error('New rows must also be configured.');
// A reusable field may instead declare the parent structure it requires.
type PackageParent = GroupNode<{ deliveryMethod: FieldNode<'home' | 'pickup' | null> }>;
const pickupLocation = field('', (ctx) => {
const row = ctx.parent<PackageParent>();
return row?.deliveryMethod() === 'pickup' && !ctx.value()
? { kind: 'pickupLocation', message: 'Choose a pickup location.' }
: null;
});
if (pickupLocation.invalid()) throw new Error('A detached field must tolerate a null parent.');
const standalonePackage = group({ deliveryMethod: field<'home' | 'pickup'>('pickup'), pickupLocation });
if (!standalonePackage.pickupLocation.hasError('pickupLocation')) {
throw new Error('The declared parent must be observed after attachment.');
}
standalonePackage.deliveryMethod.set('home');
if (standalonePackage.pickupLocation.invalid()) throw new Error('Changing the sibling must clear the error.');
Timing and lifecycle
- The callback runs synchronously once per new instance, before its factory returns. The node's own API, declared children, and initial array items are ready. Immediate children are attached to this node, but the node itself may not yet have an ancestor.
- A template is an independent node and runs its own callback. Each fresh clone runs the callback
again with its own API. This includes new rows created by
push(), insertion, or value reconciliation. - Existing nodes do not rerun configuration on edits, reset, movement, or keyed reuse. A reset or set that creates new nodes configures those new instances normally.
- Configuration runs untracked. Reading a signal in the callback does not subscribe the outer declaration to it. Reads inside the validators you install remain reactive in the usual way.
- Configuration works outside an Angular injection context. It does not create an injection context or wait for bindings. Do not assume ancestors, DOM controls, or injected providers are available.
- Do not reference the variable being initialized from the callback: it has not been assigned yet. Use the callback argument. Avoid synchronous validity rules that depend on that outer variable.
- Return values are ignored. Use a synchronous callback; promises are not awaited and returned functions are not registered as cleanup handlers. Thrown exceptions propagate from construction.
setValidators()replaces the node's validators. If it should retain declaration rules, include those rules in the replacement list. This follows the ordinarysetValidators()contract.
Prefer configuration for installing rules rather than changing initial values. A callback's set()
changes current state; it does not redefine a field's declared reset default. Supplied array row
values are applied after the template instance is constructed, so they can overwrite construction-time
value changes. Install a validator that reads live values instead of capturing a value snapshot.
Configure each array item
configureEach receives each new item's typed, callable $api. For object rows,
api.children exposes the inferred sibling nodes and api.patch() updates that row.
import { array, field, form } from '@ngblocks/form-nodes';
const myForm = form({
timeseries: array({
timeseriesCode: field<string>(null),
value: field(''),
axis: field('left'),
}, {
initialValue: [
{ timeseriesCode: 'temperature', value: 'average', axis: 'right' },
{ timeseriesCode: 'pressure', value: 'maximum', axis: 'right' },
],
configureEach(api) {
api.children.timeseriesCode.onValueChange(() => {
api.patch({ value: '', axis: 'left' });
});
},
}),
});
if (myForm.timeseries.at(0)?.value() !== 'average') {
throw new Error('Initial data must not trigger the dependent-field reset.');
}
myForm.timeseries.at(0)?.timeseriesCode.set('humidity');
myForm.timeseries.at(0)?.value(); // ''
myForm.timeseries.at(0)?.axis(); // 'left'
myForm.timeseries.at(1)?.value(); // 'maximum'
if (myForm.timeseries.at(0)?.value() !== '' || myForm.timeseries.at(0)?.axis() !== 'left') {
throw new Error('Changing the code must clear the dependent values in the same row.');
}
if (myForm.timeseries.at(1)?.value() !== 'maximum') {
throw new Error('Changing one row must preserve its sibling rows.');
}
const added = myForm.timeseries.push({ timeseriesCode: 'wind', value: 'minimum', axis: 'right' });
added.timeseriesCode.set('rain');
if (added.value() !== '' || added.axis() !== 'left') {
throw new Error('Rows added later must receive the same configuration.');
}
The callback runs once per actual item, for both templates and factories. It runs after the
item's own configure and supplied initial data, before attachment to the array and capture
of the item's resetToInitial() baseline. Its value changes therefore become part of that
baseline. Children are ready; ancestors and bindings may not be available yet. For initial
items, configureEach runs before the containing array's configure.
It also runs for new items created by push(), insert(), or value reconciliation. Edits,
reordering, and resets of reused items do not repeat configuration. A reset that creates new
items configures those new items. Neither the original template nor templateValue() drafts
run this callback. Cloning an array retains the option for its own newly created items.
Configuration is synchronous, untracked, and safe outside an injection context. Initial data and configuration writes do not emit value-change notifications. Returned values are ignored; promises are not awaited and returned functions are not cleanup handlers. Errors propagate. Subscriptions registered here use the normal subscription ownership rules, including continued observation of retained detached nodes.
onValueChange observes later programmatic writes as well as committed control edits. A
patch() or value reset that changes timeseriesCode will also clear the dependent fields,
even if that operation supplied values for them. Control debounce delays this reaction until
the code commits. The dependent patch() preserves their dirty and touched state; use each
field's reset(value) if you also want to clear those interaction flags.
Declaring a parent contract
ctx.parent<TParent>() is available in synchronous and asynchronous validator contexts. It returns
a read-only validation view of NonNullable<TParent>, or null. It retains reactive parent
tracking and refers to the immediate structural parent.
It does not skip an array to find a group. A field cannot be used as the parent type.
Array index types may include undefined; pass them directly without writing NonNullable:
import { Component } from '@angular/core';
import { array, field, form, validator } from '@ngblocks/form-nodes';
@Component({ selector: 'app-delivery-editor', template: '' })
export class DeliveryEditor {
form = form({
packages: array({
deliveryMethod: field<'home' | 'pickup'>('home'),
pickupLocation: field('', (ctx) => {
const row = ctx.parent<(typeof this.form.packages)[number]>();
return row?.deliveryMethod() === 'pickup' && !ctx.value()
? { kind: 'pickupLocation', message: 'Choose a pickup location.' }
: null;
}),
}, {
initialValue: 2,
}),
});
}
type DeliveryForm = DeliveryEditor['form'];
export const pickupLocationRequired = validator<string | null>((ctx) => {
const row = ctx.parent<DeliveryForm['packages'][number]>();
return row?.deliveryMethod() === 'pickup' && !ctx.value()
? { kind: 'pickupLocation', message: 'Choose a pickup location.' }
: null;
});
ctx.parent<DeliveryForm['packages'][number]>() and
ctx.parent<(typeof this.form.packages)[number]>() both remove nullish members from the generic.
ctx.parent<typeof this.form.packages[0]>() also works, though [number] more clearly describes
any row type. The return still includes null for a missing parent and never includes undefined.
The generic only describes the immediate parent; it does not select a row or look up index zero.
The generic is a consumer-supplied type assertion. It does not check the parent's kind or child names
at runtime, or verify where the field will eventually be attached. A missing parent still returns
null; a mismatched attached parent is not converted to null. Use this for reusable fields with a
known placement contract. Prefer configure when the surrounding declaration can infer the siblings.
Without a generic, ctx.parent() retains its existing inferred or general navigation type. This
convenience belongs to validator contexts; ordinary node parent() signals keep their existing API.
Extracting an array item type
ArrayItemNode<TArray> extracts the type of an existing row,
including its child nodes and parent navigation. Unlike a numeric lookup or at(), the type itself
excludes undefined; actual lookups can still fail and must be checked.
type PackageNode = ArrayItemNode<DeliveryEditor['form']['packages']>;
Extract this type from an already inferred declaration. Referencing that same declaration's type inside its initializer can introduce a circular inference dependency; the helper does not remove it.
The node returned by ctx.parent<TParent>() always uses the recursive read-only validation view,
even with an explicit generic. Read child values for conditions and return errors. Validation
outputs, metadata queries, and mutations remain unavailable through that view; configure itself
continues to receive the normal API for installing validators and initializing the node.
React after value changes
onValueChange observes committed value changes from both control edits and programmatic writes.
Use the binding outputs to observe only edits from
a specific control: (formNodeValueChange) reports committed input after debounce, and
(formNodeControlValueChange) reports the control value immediately, before debounce.
Programmatic writes do not emit either output.
Use onValueChange(value, node) in the options of field(), form(), group(), or array()
to react to a change in the node's committed public value. Both arguments retain the inferred
value and node types. The callback runs synchronously before the operation returns; reading
other signals inside it does not subscribe the callback to those signals. No injector is needed.
import assert from 'node:assert/strict';
import { field, form } from '@ngblocks/form-nodes';
const names: string[] = [];
const snapshots: { name: string; city: string }[] = [];
const profile = form({
name: field('Ada', {
onValueChange: value => names.push(value),
}),
city: field('London'),
}, {
onValueChange(value, node) {
snapshots.push(value);
assert.equal(node.name(), value.name);
assert.equal(node.city(), value.city);
},
});
assert.deepEqual(names, []);
assert.deepEqual(snapshots, []);
profile.patch({ name: 'Grace', city: 'New York' });
profile(); // { name: 'Grace', city: 'New York' }
assert.deepEqual(names, ['Grace']);
assert.deepEqual(snapshots, [{ name: 'Grace', city: 'New York' }]);
profile.name.set('Grace');
assert.equal(snapshots.length, 1);
profile.resetToInitial();
profile(); // { name: 'Ada', city: 'London' }
assert.deepEqual(names, ['Grace', 'Ada']);
assert.equal(snapshots.length, 2);
const queries: string[] = [];
const search = form({
query: field('', {
debounce: 'blur',
onValueChange: value => queries.push(value),
}),
});
search.query.value.control.set('Angular');
search.query(); // ''
assert.deepEqual(queries, []);
search.query.markAsTouched();
search.query(); // 'Angular'
assert.deepEqual(queries, ['Angular']);
The callback observes control input and programmatic changes through set(), update(), aggregate
patch(), and resets that change the value. value.committed.set() also notifies. Control input
waits for its debounce or an explicit flush; a canceled pending input never notifies. As with all
public value consumers, the node's equal option can retain a previous value and suppress a
notification. Installing this callback observes values at operation boundaries, so comparisons
are evaluated then instead of waiting for an unrelated consumer to read the value.
Declaration, configure, and initial values assigned to new array rows do not notify.
Array template callbacks are copied to new rows and run on their later edits. Array insertions,
removals, reorders, and reconciliation notify the array and its ancestors when their exposed value
changes. Removed children no longer notify former ancestors. Options are captured on construction;
callbacks are not inherited by descendants.
A single aggregate set(), patch(), reset, or flush updates its children before notifying.
Changed descendants run before their ancestors; ancestor callbacks see the complete resulting
value once, including changes made by descendant callbacks. Separate operations notify separately.
Calls made inside a callback queue further notifications until that callback returns. Callbacks
that repeatedly modify their own dependencies must settle: more than 100 notifications for one
node in a single operation throws an error and clears the pending queue.
onValueChange does not change dirty/touched state and does not wait for asynchronous validation.
An invalid, disabled, readonly, or hidden node can still report a programmatic value change.
Validation completion and changes to interaction or availability alone do not notify. Return
values are ignored; asynchronous callback work is not awaited or canceled by the library.
If a synchronous callback throws, committed writes remain applied and other pending callbacks
still run. The operation then throws that error; multiple failures use AggregateError. For timer- or promise-delayed commits,
errors surface during that scheduled delivery, not from the earlier input call. A comparator
failure can be recovered by a later write; an unreadable public value does not notify. A failed
operation that already changed part of a tree reports the resulting changes as well. Notifications
are released at the operation boundary, with no live watcher or global strong registration keeping
an unreachable node alive.
The callback receives the node itself. Use node.$api when a child name hides an API member, as
explained in API access. To observe only control-originated input, use
the binding outputs instead.
Subscribe to an existing node
Call node.onValueChange(callback, { injector?, debounce? }) after creating a field, form, group, or array.
The callback receives the inferred value and original node. Each call creates an independent
subscription and returns an idempotent cancellation function. The method also exists on $api
when an object child is named onValueChange.
import assert from 'node:assert/strict';
import { Injector } from '@angular/core';
import { field, form } from '@ngblocks/form-nodes';
const profile = form({ name: field('Ada'), city: field('London') });
const names: string[] = [];
const snapshots: ReturnType<typeof profile>[] = [];
const stopName = profile.name.onValueChange(value => names.push(value));
const stopProfile = profile.onValueChange((value, node) => {
snapshots.push(value);
assert.equal(node, profile);
});
assert.deepEqual(names, []);
profile.patch({ name: 'Grace', city: 'New York' });
assert.deepEqual(names, ['Grace']);
assert.deepEqual(snapshots, [{ name: 'Grace', city: 'New York' }]);
stopName();
stopName();
profile.name.set('Lin');
assert.deepEqual(names, ['Grace']);
assert.equal(snapshots.length, 2);
stopProfile();
const owner = Injector.create({ providers: [] });
const ownedValues: string[] = [];
profile.name.onValueChange(value => ownedValues.push(value), { injector: owner });
profile.name.set('Ada');
owner.destroy();
profile.name.set('Grace');
assert.deepEqual(ownedValues, ['Ada']);
By default, instance subscriptions follow the same equality, control debounce, batching, validation, and error rules
as the construction callback above. They emit no initial value, coexist with the construction
callback, and are not copied to array template clones. Registration during configure works;
construction-time writes remain silent. For each notification, the construction callback runs
first, then instance listeners in registration order. Canceling a listener before its turn skips
it. New listeners do not receive an update already in progress, but can receive subsequent
reentrant changes. Every listener in a delivery receives the same value snapshot, even if an
earlier listener makes another write.
Debounce a subscription
Pass { debounce: 300 } to notify that listener only after 300 milliseconds without another
committed value change. Each subscription has its own timer and receives the latest value and
original node. This is useful for search requests, autosave, or notifying a parent component after
typing pauses.
import { Component, input, output } from '@angular/core';
import { field, form, FormNodeDirective } from '@ngblocks/form-nodes';
@Component({
selector: 'app-search-editor',
imports: [FormNodeDirective],
template: `<input [formNode]="form.query" />`,
})
export class SearchEditor {
initialQuery = input('');
search = output<string>();
form = form({ query: field('') });
ngOnInit() {
this.form.patch({ query: this.initialQuery() });
// Listen only after initialization; emit once typing has paused for 300 ms.
// The form captures the component injector, so destruction cancels pending delivery.
this.form.onValueChange(value => this.search.emit(value.query), { debounce: 300 });
}
}
The form value, validation, dirty/touched state, and other listeners update normally. This delay
does not make debouncing() or validation pending() true. Both programmatic writes and committed
control edits restart the timer. Equality-suppressed writes do not restart it. Registration emits
nothing, including when the form was patched earlier in ngOnInit().
- Omit
debounceor pass0for the existing synchronous behavior. - Use finite, non-negative milliseconds. Negative values,
NaN, and infinities throwRangeErrorwhen registering, before the listener is installed. - Calling the returned cancellation function or destroying either subscription owner cancels the
pending notification as well as future ones.
injectoranddebouncecan be used together. - A reset that changes the committed value replaces the pending notification with the reset value. Resetting only interaction state leaves the pending notification intact.
- Returning to a previously delivered value still emits if intervening committed changes occurred.
- A delayed callback that throws reports its error from the timer, after the original write has returned. Catch errors in the callback when they need application-specific handling. Work already started by a callback, such as a request, is not canceled by later changes or unsubscription.
The node's own debounce option delays control commits. This subscription option delays
callback delivery after a commit. If both are set to 300, a control edit can take approximately
600 ms to reach the listener. Programmatic writes bypass control debounce but still wait for the
subscription delay. flush() flushes control edits; it does not bypass subscription debounce.
Initialize from component inputs before listening
When an editor receives its initial values through Angular input() signals, read them in
ngOnInit(), after Angular has assigned the inputs. Apply those values with patch() first,
then register onValueChange() if the parent should receive only subsequent changes.
import { Component, input, output } from '@angular/core';
import { field, form, FormNodeDirective } from '@ngblocks/form-nodes';
@Component({
selector: 'app-profile-editor',
imports: [FormNodeDirective],
template: `
<label>Username <input [formNode]="form.username" /></label>
<label>Email <input type="email" [formNode]="form.email" /></label>
`,
})
export class ProfileEditor {
username = input.required<string>();
email = input.required<string>();
form = form({
username: field(''),
email: field(''),
});
profileChange = output<{ username: string; email: string }>();
ngOnInit() {
this.form.patch({
username: this.username(),
email: this.email(),
});
this.form.onValueChange(value => {
this.profileChange.emit(value);
});
}
}
For example, a parent can supply username="manolo" and email="manolo@lama.com".
The first patch() fills both controls without emitting profileChange. It completes
synchronously before the subscription exists. Registering onValueChange() emits no current
value and does not replay that earlier patch, including on later change-detection cycles.
After registration, committed user edits and programmatic set() or patch() calls that change
the value emit profileChange. Control edits respect debounce; programmatic writes commit
immediately. The initial patch runs normal validation and preserves pristine/untouched state.
Any listeners registered earlier, including an onValueChange construction option, still observe
that patch; this ordering only skips it for the newly registered listener.
This copies the inputs once. Later parent input changes do not automatically patch the form.
If data arrives asynchronously, apply it and register the listener after that initial load;
ngOnInit() does not wait for a request to finish. Register the subscription once to avoid
duplicate notifications.
Although ngOnInit() is outside Angular's injection context, this form was created in a component
field initializer and captured its injector. The subscription therefore cleans up automatically
when the component is destroyed. See subscription ownership for forms
created elsewhere and explicit injector options.
Automatic subscription cleanup
Choose the consumer owner in this order: the explicit options.injector, the context where
onValueChange() is called, or the node's current injector. The explicit option only owns this
subscription and does not change the node's injector. The subscription also ends if the node's
current owner is destroyed, even when the consumer has a longer lifetime.
import { field, form } from '@ngblocks/form-nodes';
import { Component, Injector, inject, signal } from '@angular/core';
@Component({ template: '' })
export class ProfilePage {
injector = inject(Injector);
latestName = signal<string | null>('Ada');
form = form({ name: field('Ada') });
constructor() {
// This subscription ends automatically when the component is destroyed.
this.form.name.onValueChange(value => this.latestName.set(value));
}
observeLater() {
// An explicit owner also works when registration happens outside injection context.
this.form.onValueChange(value => this.latestName.set(value.name), {
injector: this.injector,
});
}
}
A node keeps its explicit or captured injector ahead of direct binding and ancestor injectors.
When it temporarily adopts a [formNode] binding injector or inherits an ancestor injector,
subscriptions follow ownership changes: rebinding or detachment removes the old association.
A detached node can still notify. Destruction of the former owner does not cancel a subscription
that has already moved away, unless that injector was also chosen explicitly or captured as the
consumer owner. A canceled subscription does not restart on later attachment.
Without an injector, subscriptions still work, including subscription debounce. Use the returned cancellation function when the node outlives its consumer. Unreachable nodes and callbacks can be garbage-collected; keeping an injector or cancellation function alive does not retain the node tree. Canceling early also unregisters lifecycle hooks. Unsubscribing does not reset the node or change its validation rules.
The callback provided in factory options keeps its existing node-lifetime behavior and is copied into template clones. Use instance subscriptions for consumers that need automatic injector cleanup. Callback return values do not register cleanup; asynchronous work started by a callback remains owned by the application.