Node value views
Every field, group, form, and array exposes value, a NodeValueSignal<TValue, TSet>.
TValue is its read type; TSet is the complete input accepted by node.set().
This distinction preserves optional input defaults and array normalization. Array setters also
accept null or undefined to clear the array. Group/form setters accept complete objects,
not patches; use patch() for partial object updates. Supplied arrays in a patch still require
complete collections; use an individual row's patch() for partial row edits.
| Read | Configured custom equality | Pending input |
|---|---|---|
node() / node.value() | Applied, including exposed child values | Excluded |
node.value.committed() | Bypassed, including descendants | Excluded |
node.value.control() | Bypassed | Includes this node's own draft |
value()
Signature: value: NodeValueSignal<TValue, TSet>; calling it returns TValue.
Reads the exposed committed value, exactly like calling the node. Prefer profile.name() for
ordinary application code. Configured equal may retain an earlier equivalent value; parent
exposed values compose exposed child values. Validator contexts, submission values, and update()
callbacks continue to use exposed values. Adding these views does not change those contracts.
Example: profile.name.value(); // 'Ada' in the complete example below.
value.committed()
Signature: value.committed: Signal<TValue> & { set(value: TSet): void }.
Reads the latest committed data before configured equal checks. An aggregate includes raw
committed child data, so a child equality rule cannot conceal a spelling or object change here.
It still waits for debounce. Use the control view to inspect pending input.
This is a reactive signal, not a notification for every assignment: Angular's ordinary identity checks still apply. It does not make in-place object mutations reactive or produce defensive copies.
Example: after committing 'ADA', profile.name.value.committed(); // 'ADA', even while the
case-insensitive exposed read remains 'Ada'.
value.committed.set()
Signature: value.committed.set(value: TSet): void.
Equivalent to node.set(value). Commits immediately, cancels pending control input in the affected
subtree, synchronizes control rendering, and preserves dirty/touched state. Normal committed-value
validation and parent propagation apply. Configured equal still governs exposed values; this
setter does not disable it. It does not restore initial values: use resetToInitial() for that.
Example: profile.name.value.committed.set('Grace') replaces a pending draft immediately.
value.control()
Signature: value.control: Signal<TValue> & { set(value: TSet): void }.
Reads the latest control representation, including this node's pending numeric, blur, or asynchronous debounce. Without pending input it follows committed data before custom equality. It is useful for control integration and draft previews.
An aggregate's control value is not a recursive snapshot of pending child input. It contains
its own buffered complete value, or committed child data when it has no active buffer. Read each
child's value.control() when a draft preview needs pending child input. A committed model change
can invalidate a pending aggregate buffer; the model remains authoritative.
Example: immediately after the control write, profile.name.value.control(); // 'ADA'.
value.control.set()
Signature: value.control.set(value: TSet): void.
Receives control input and marks the selected node dirty, even if the value is unchanged. It does
not mark touched. Applies configured or inherited debounce before committing; without debounce,
commit is synchronous. flush() commits pending input early, and blur/touch completes blur debounce.
Committed-data validation and parent values follow the eventual commit. Existing aggregate
propagation rules apply; a complete aggregate control write is not a separate user edit on each child.
A new control write replaces pending work. A committed setter or reset cancels pending input.
reset() discards the draft and clears subtree interaction state; resetToInitial() also restores
captured initial values.
Example: profile.name.value.control.set('ADA') buffers input and marks the field dirty.
Neither nested setter emits formNodeControlValueChange or formNodeValueChange by itself.
Those outputs originate in the bound adapter's input channel. Keep [formNode] for ordinary binding;
there is no need to call a setter from its output handler to synchronize the node again.
Executable example
This example exercises all five entries, custom equality, debounce, cancellation, and restoration.
import { field, form } from '@ngblocks/form-nodes';
const profile = form({
name: field('Ada', {
equal: (previous, next) => previous?.toLowerCase() === next?.toLowerCase(),
debounce: 'blur',
}),
});
profile.name.value(); // 'Ada': equivalent to profile.name()
profile.name.value.control.set('ADA');
profile.name.value.control(); // 'ADA': input is available immediately
profile.name.value.committed(); // 'Ada': debounce has not finished
if (profile.name.value.control() !== 'ADA' || profile.name.value.committed() !== 'Ada') {
throw new Error('Pending input must remain separate from committed data.');
}
profile.name.flush();
profile.name.value(); // 'Ada': equal retains the exposed spelling
profile.name.value.committed(); // 'ADA': the latest committed spelling
profile.value.committed(); // { name: 'ADA' }: bypasses child equality too
if (profile.name.value() !== 'Ada' || profile.value.committed().name !== 'ADA') {
throw new Error('Committed reads must include writes hidden by custom equality.');
}
profile.name.value.control.set('discarded draft');
profile.name.value.committed.set('Grace');
profile.name.value.control(); // 'Grace': the pending draft was cancelled
profile.name.value.committed(); // 'Grace'
if (profile.name.debouncing() || profile.name.value.control() !== 'Grace' || !profile.name.dirty()) {
throw new Error('Committed writes must cancel pending input and preserve interaction state.');
}
profile.resetToInitial();
profile.name.value.committed(); // 'Ada'
if (profile.name.value.committed() !== 'Ada' || profile.dirty()) {
throw new Error('Restoring initial values must reset all value views and interaction state.');
}
Generic infrastructure and migration
node.value and node.$api.value expose the same facade. Child properties can
shadow value or api; use node.$api.value for an AnyNode whose child names are unknown.
All three views are Angular signals. Nested views expose set() only, not update() or
asReadonly() from WritableSignal. Setters may be extracted without binding a receiver.
Public controlValue() and setControlValue() have been removed. Follow the
migration table to update calls and signal references.
Explicit FieldNode annotations and IntelliSense
const myFieldNodeTyped: FieldNode = field('') retains both value.committed() and
value.control(), including their set() methods. Omitting the generic uses any; use
FieldNode<string> for string reads and writes, or retain the inferred factory type.
The property is spelled committed, with two t characters.
Function members such as call, apply, bind, name, and length are hidden from IntelliSense
on value, value.committed, and value.control. Completion after node.value. shows
committed and control; completion on either nested view shows set. All three remain callable
Angular signals. This is a public typing restriction; it does not modify JavaScript's function prototype.
import { field, type FieldNode } from '@ngblocks/form-nodes';
const myFieldNodeTyped: FieldNode = field('');
myFieldNodeTyped.value.committed(); // '' (typed as any)
myFieldNodeTyped.value.control(); // '' (typed as any)
myFieldNodeTyped.value.committed.set('Ada');
if (myFieldNodeTyped.value.committed() !== 'Ada' || myFieldNodeTyped.value.control() !== 'Ada') {
throw new Error('A bare FieldNode annotation must retain both nested value views.');
}
const name: FieldNode<string> = field.strict('Ada');
name.value.committed(); // 'Ada' (typed as string)
name.value.control(); // 'Ada' (typed as string)
name.value.control.set('Grace');
if (name.value.committed() !== 'Grace' || name.value.control() !== 'Grace') {
throw new Error('Typed value views must retain their read and write behavior.');
}