Values and state
Nodes are callable signals. Prefer calling the node itself to read its committed value:
import { field, form } from '@ngblocks/form-nodes';
const profileForm = form({
name: field('Marco'),
age: field<number>(null),
address: {
city: field('Madrid'),
},
secret: field(''),
});
const profileValue = profileForm(); // { name: 'Marco', age: null, address: { ... }, secret: '' }
const nameValue = profileForm.name(); // 'Marco'
đ Alternative value accessâ
The callable node.$api() also reads the exposed value, just like
node() and node.value(). Their properties provide collision-safe state and operations.
See callable APIs.
Examples throughout this documentation call nodes directly. The same committed value is also
available through value() directly or under .$api:
profileForm.value(); // { name: 'Marco', age: null, address: { ... }, secret: '' }
profileForm.$api.value(); // { name: 'Marco', age: null, address: { ... }, secret: '' }
profileForm.name.value(); // 'Marco'
profileForm.name.$api.value(); // 'Marco'
These alternatives are mainly useful in generic infrastructure or when explicitly naming the
signal is important. They do not represent different snapshots: for any node, myNode(),
myNode.value(), and myNode.$api.value() return the same committed value. Prefer myNode() in
application examples and ordinary consumer code.
The Tree navigation and API access guide documents .$api for the uncommon case
where a child name collides with a node member and for generic infrastructure.
đ Custom equality for a consumerâ
When a particular consumer needs its own definition of equality, derive a signal with Angular's
computed() and supply an equal function. This works with form(), group(), array(), and
field(): read the selected node inside the computation and compare the resulting values.
The following preview treats name capitalization as irrelevant while still observing email changes:
import { computed } from '@angular/core';
import { field, form } from '@ngblocks/form-nodes';
const profile = form({
name: field.strict<string>('Marco'),
email: field.strict<string>('marco@example.com'),
});
// This preview treats names that differ only in letter case as interchangeable.
const previewValue = computed(() => profile(), {
equal: (previous, next) => {
return previous.name.toLowerCase() === next.name.toLowerCase()
&& previous.email === next.email;
},
});
let previewRuns = 0;
const previewLabel = computed(() => {
previewRuns++;
const value = previewValue();
return `${value.name} <${value.email}>`;
});
const initialPreview = previewValue();
previewLabel(); // 'Marco <marco@example.com>'
profile.name.set('MARCO');
profile().name; // 'MARCO'
previewValue().name; // 'Marco': the computed retains its previous value
previewLabel(); // 'Marco <marco@example.com>'
if (profile().name !== 'MARCO' || profile.name() !== 'MARCO') {
throw new Error('Consumer equality must not replace the committed node value.');
}
if (previewValue() !== initialPreview || previewRuns !== 1) {
throw new Error('An equivalent preview must retain its value and skip dependent recomputation.');
}
profile.email.set('marco@work.example');
previewLabel(); // 'MARCO <marco@work.example>'
if (previewValue() !== profile() || Number(previewRuns) !== 2) {
throw new Error('A non-equivalent preview must publish the current value and update its consumer.');
}
Returning true retains the computed signal's previous value and reference. It does not keep
the latest value while merely silencing notifications. Consumers depending only on that derived
signal can skip recomputation; the source computation and equality check still run when needed
to compare a changed source. Other reactive dependencies can still cause a consumer to run.
In this example, profile() contains 'MARCO' immediately, while previewValue() retains
'Marco' until a non-equivalent change occurs. Read the node directly whenever the latest committed
value matters. The derived signal does not alter the node's validation, submission, control values,
interaction state, or debounce cancellation; those continue to use the node's own model and state.
Keep the comparator pure and only treat values as equal when they are interchangeable for this consumer. Its arguments are inferred from the computation, including any field nullability. Signals read inside the equality function are not tracked as dependencies; read reactive inputs in the computation itself when they must trigger updates.
This differs from configuring equality on a node, which affects its public value for all consumers.
field(..., { equal }), form(), group(), and array() apply
equality to their exposed values. Internal storage and controls still accept the latest writes.
A derived computed() remains useful when the comparison belongs to only one consumer.
đ Aggregate value equalityâ
form(), group(), and array() accept equal: 'shallow', 'deep', or a typed
(previous, next) => boolean function. The default is Object.is. Equality controls the exposed
aggregate snapshot while each child continues to accept its own committed values and apply its
own public equality, if configured.
import { field, form, group, type FormNodeValue } from '@ngblocks/form-nodes';
let submitted: unknown;
const profile = form({
account: group({
name: field.strict<string>('Marco'),
}, {
equal: (previous, next) => previous.name.toLowerCase() === next.name.toLowerCase(),
validators: ({ value }) => value().name.length > 0 ? null : { kind: 'nameRequired' },
}),
}, {
onSubmit: value => { submitted = value; },
});
const initial = profile();
profile.account.name.set('MARCO');
profile.account.name(); // 'MARCO': the child accepts the new value
profile.account(); // { name: 'Marco' }: the group retains an equivalent public value
profile(); // { account: { name: 'Marco' } }: public parents compose public child values
if (profile.account.name() !== 'MARCO' || profile() !== initial || !profile.valid()) {
throw new Error('Aggregate equality must preserve the exposed value independently of child storage.');
}
await profile.submit();
if (submitted !== initial) {
throw new Error('Submission must receive the same exposed value that the form returns.');
}
profile.reset();
profile.account.name(); // 'MARCO': reset preserves the current child value
if (profile.account.name() !== 'MARCO' || profile.touched() || profile.dirty()) {
throw new Error('Reset must preserve child values and clear interaction state.');
}
let updateInput: FormNodeValue<typeof profile.account> | undefined;
profile.account.update((value) => {
updateInput = value;
return { name: `${value.name}!` };
});
profile.account.name(); // 'Marco!': update receives the exposed group value
if (updateInput !== initial.account || profile.account.name() !== 'Marco!') {
throw new Error('Update callbacks must receive the exposed value.');
}
| Operation or read | Value used |
|---|---|
Node call and equivalent value() signal | Exposed aggregate value, retaining the previous value when equal. |
Validator ctx.value() and value passed to submit() | Exposed value. |
Input to an update() callback | Exposed value. |
| Public parent composition, including arrays | Exposed values of its children. |
| Child writes, reconciliation, and control synchronization | Current committed child values. |
| Aggregate control debounce invalidation | Current committed values, independently of public equality. |
'shallow' compares direct properties with Object.is. 'deep' uses the same recursive comparison
as field equality. Custom comparator arguments preserve
the complete inferred object or array shape and child nullability. The option is captured at construction,
is not inherited, and is preserved in configured factories and array template clones.
The exposed aggregate is a lazy computed(). The first evaluation publishes the current snapshot
without comparing it; later evaluations compare against the last exposed value. Intermediate
writes may be combined before a read. Comparator reads are untracked. A throwing comparator can
make an exposed read fail after children have already accepted their values; a later dependency
change allows the computed to recover. Fields follow the same exposed-value strategy: equality
does not reject writes at storage time.
Value-only validators can retain their result or pending asynchronous work after an equal change.
Other dependencies still matter: a validator that reads ctx.node().name() directly observes that
child's public value, and child errors and interaction state continue propagating independently.
The comparator must therefore treat values as interchangeable for the aggregate's validation and
submission rules, as well as for its other consumers.
reset() clears interaction and pending control work while preserving current child values.
reset(value) assigns the supplied child values; the exposed aggregate may still retain an equal
previous snapshot. Bound controls follow current committed values. Consequently value.control()
may differ from the exposed aggregate even without a pending debounce; it remains a control-facing
signal, not an alternative general-purpose value accessor.
Array equality filters the public value independently of structure. items(), indexed access,
length(), paths, and reconciliation follow the current nodes even if the exposed value retains
an older array. A comparator that ignores ordering can retain the old public order while item nodes
move; one that ignores length can retain a different public item count. Use items() for rendering
dynamic rows. See array equality for an executable example.
đ Set, update, and patchâ
set() replaces a complete value. update() computes a complete value from the current exposed value:
profileForm.set({
name: 'Ada',
age: 36,
address: { city: 'London' },
secret: '',
});
profileForm.update(value => ({ ...value, age: (value.age ?? 0) + 1 }));
patch() updates only supplied form branches:
profileForm.patch({ name: 'Grace' });
Use set() to replace a field value. Supplied arrays in patch() reconcile exactly like set(),
requiring complete item values and adjusting length and order. Use an individual row's patch() for partial row edits;
see Dynamic arrays.
Programmatic writes preserve dirty and touched state.
âŠī¸ Resetâ
reset() clears dirty and touched state while preserving current values. Pass a complete value to replace values and clear interaction state together:
profileForm.reset();
profileForm.reset({
name: '',
age: null,
address: { city: '' },
secret: '',
});
Resetting a nested node affects only that subtree. Validators remain configured and immediately evaluate the reset value.
âąī¸ Control values and debounceâ
value.control() is not another general-purpose value accessor. It is the immediate value buffered
from a bound UI control, while the node call reads the committed model observed by validators and
ancestors:
Read the node itself for application state. Reach for value.control() only when control
infrastructure specifically needs the uncommitted UI representation.
const myForm = form({
search: field('', { debounce: 300 }),
});
myForm.search.value.control.set('angular');
myForm.search.value.control(); // 'angular'
myForm.search(); // '' until the delay completes (preferred committed-value read)
myForm.search.debouncing(); // true
Without a pending control debounce, value.control() and the committed value normally match.
Pending values from descendant controls are not composed into a form or array's
value.control(); aggregate nodes continue exposing their last committed representation until the
descendant value commits.
Use debounce: 'blur' to commit on focus loss, or provide a function that receives an AbortSignal and optionally returns a promise. A new control value cancels the previous debounce. flush() commits immediately.
Forms and arrays can define an inherited debounce for descendant fields and expose aggregate debouncing() and flush() operations. Programmatic set(), update(), patch(), and reset(value) are never debounced.
See Value flow and debounce for the complete transition table, custom debounce cancellation, aggregate buffers, and reset interaction.
đ Interaction stateâ
Every node exposes paired state signals and actions:
| State | Complement | Actions |
|---|---|---|
touched() | untouched() | markAsTouched(), markAsUntouched() |
dirty() | pristine() | markAsDirty(), markAsPristine() |
disabled() | enabled() | disable(), enable() |
readonly() | writable() | markAsReadonly(), markAsWritable() |
hidden() | visible() | hide(), show() |
A control-originated value change marks its directly bound node dirty. Blur or a control touch event marks it touched. Aggregate touched and dirty state reflects descendants.
markAsTouched() applies to an aggregate subtree by default. Pass { skipDescendants: true } to touch only that node.
đī¸ Disabled, readonly, and hiddenâ
These states suppress a node's validation errors and exclude its invalid or pending state from ancestor validity. They do not prevent programmatic reads or writes.
- Disabled state propagates to descendants and records causes in
disabledReasons(). - Readonly state propagates to descendants and prevents UI edits.
- Hidden state propagates to descendants but does not remove DOM elements.
Templates should use @if to omit hidden controls:
@if (profileForm.secret.visible()) {
<input [formNode]="profileForm.secret" />
}
Passing a string to disable() or the disabled option records a user-facing reason:
profileForm.disable('Account is locked');
profileForm.disabledReasons();
See Interaction and availability for exact propagation, stored state, and non-interactive validation behavior.
đŗ Tree navigationâ
Every node exposes reactive parent(), form(), root(), path(), and keyInParent() signals:
profileForm.address.city.path(); // ['address', 'city']
profileForm.address.city.parent(); // profileForm.address
profileForm.address.city.form(); // profileForm
profileForm.address.city.root(); // profileForm
form() identifies the nearest explicit submission workflow, while root() returns the topmost
structural node. They differ when an explicit form is nested inside another tree.
Forms expose a stable readonly children map. Arrays expose an items() signal and index access.
See Tree navigation and API access for paths, parents, child maps, and API-name collisions.
Mutable valuesâ
When a field stores a Date, Moment instance, array, or object, replace that value when changing its contents. Editing the existing
instance in place does not notify signals. Calling set() with that same reference does not force
notification either: internal committed and control signals still use identity equality, independently
of the public equal option. A direct read can see the mutated object while a previously evaluated
computed value or rendered control still shows its old result.
Use new Date(...), Moment's .clone(), object spread, or a new array before making the change:
import moment from 'moment';
import { computed } from '@angular/core';
import { field, form } from '@ngblocks/form-nodes';
const booking = form({
date: field(new Date(2025, 0, 10)),
momentDate: field(moment([2025, 0, 10])),
guest: field({ name: 'Ada' }),
});
const day = computed(() => booking.date()?.getDate());
const momentDay = computed(() => booking.momentDate()?.date());
const name = computed(() => booking.guest()?.name);
if (day() !== 10 || momentDay() !== 10 || name() !== 'Ada') {
throw new Error('Derived values must reflect the initial form.');
}
// Create a new instance before changing mutable values.
const nextDate = new Date(booking.date()!.getTime());
nextDate.setDate(11);
booking.date.set(nextDate);
booking.momentDate.set(booking.momentDate()!.clone().add(1, 'day'));
booking.guest.set({ ...booking.guest()!, name: 'Grace' });
booking.date()?.getDate(); // 11
booking.momentDate()?.date(); // 11
booking.guest()?.name; // 'Grace'
if (day() !== 11 || momentDay() !== 11 || name() !== 'Grace') {
throw new Error('Replacing values must refresh dependent signals.');
}
booking.resetToInitial();
if (day() !== 10 || momentDay() !== 10 || name() !== 'Ada') {
throw new Error('Updates must leave declared initial values intact.');
}
Cloning before mutation also protects initial values from changes to opaque objects. Standard Dates
are snapshotted for resetToInitial(), while Moment and other custom classes retain their references;
see reset snapshot semantics.