Skip to main content

Form nodes

Use isFormNode(value) to check whether an unknown value is a field, form, group, or array node. The helper narrows the value to the shared AnyNode type.

Form Nodes represents every part of a form as a node:

  • field() creates a leaf value.
  • form() combines named child nodes into an object value.
  • array() manages an ordered collection of repeated node definitions.

🔌 Use nodes with Angular signal utilities​

Every field, form, group, and array is an Angular Signal<T> of its exposed committed value. Pass a node directly to a utility that accepts Signal<T>; isSignal(node) also returns true. Fields retain their inferred value type: field('Marco') is a Signal<string>, while field.nullable('Marco') is a Signal<string | null>.

import { field, form } from '@ngblocks/form-nodes';
import { computed, isSignal, type Signal } from '@angular/core';

const uppercase = (source: Signal<string | null>) => computed(() => source()?.toUpperCase() ?? '');
const profile = form({ name: field('Marco') });
const displayName = uppercase(profile.name);

isSignal(profile.name); // true
displayName(); // 'MARCO'

if (!isSignal(profile.name) || !isSignal(profile) || displayName() !== 'MARCO') {
throw new Error('Nodes must be usable as Angular signals.');
}

profile.name.set('Lia');
displayName(); // 'LIA'

if (displayName() !== 'LIA') {
throw new Error('Signal consumers must observe updated node values.');
}

This also works with an effect-based utility such as delaySignal(source: Signal<T>, wait?: number). The utility still needs the injection context or injector required by Angular effect(). Creating and reading the node itself does not require an injection context.

Consumers observe committed values and configured equality. Pending control input becomes visible when committed, for example by flush(). Use node.value.control when a utility should observe pending input instead. Nodes also support writable utilities as described below.

Writable signal utilities​

Fields, arrays, and forms/groups without colliding child names can be passed directly to utilities accepting Angular's WritableSignal<T>. Reads, set(), and update() use the node's existing value and writing behavior: validation and parent propagation still run, programmatic writes cancel pending input, and they do not mark the form dirty or touched. No adapter or synchronized copy is needed. .$api supports the same contract without child-name collisions.

writable-signal-interop.example.ts
import { computed, type WritableSignal } from '@angular/core';
import { array, field, form } from '@ngblocks/form-nodes';

function increment(value: WritableSignal<number>) {
value.update(current => current + 1);
}

const profile = form({
age: field.strict(18),
users: array({ username: field('') }),
});

increment(profile.age);
profile.age(); // 19

const age = profile.age.asReadonly();
const doubled = computed(() => age() * 2);
if (doubled() !== 38 || age !== profile.age.$api.asReadonly()) {
throw new Error('Readonly views must share identity and track the exposed value.');
}

profile.age.set(20);
if (doubled() !== 40 || profile.dirty()) {
throw new Error('External writes must propagate without marking the form dirty.');
}

function append<T>(items: WritableSignal<T[]>, value: T) {
items.update(current => [...current, value]);
}

append(profile.users, { username: 'Ada' });
if (profile.users.at(0)?.username() !== 'Ada') {
throw new Error('Writable array utilities must update the live form tree.');
}

// Use the API facade when a child name shadows an operation.
const labels = form({ set: field('draft') });
const writable: WritableSignal<{ set: string }> = labels.$api;
writable.set({ set: 'published' });
if (labels.set() !== 'published') {
throw new Error('The API facade must preserve child-name collisions.');
}

The utility's value type must match the node. field(18) infers number, so it matches WritableSignal<number>; use field.nullable(18) for WritableSignal<number | null>. For forms and groups with children named set, update, or asReadonly, pass node.$api. Broad AnyNode, FormNode, and GroupNode annotations also use .$api because their child names are unknown. Concrete inferred types retain direct access when there is no collision. Aggregate setters accept more than their read value type (for example, array.set(null) clears an array). A generic utility that infers its type from writes can therefore infer a wider type. When needed, pass the read type explicitly, such as utility<ReturnType<typeof profile>>(profile), or annotate the argument as WritableSignal<ReturnType<typeof profile>>.

Readonly value views​

node.asReadonly() returns a stable, live Signal<T> of the exposed value. The node and its .$api.asReadonly() return the same signal. It has no set(), update(), child nodes, or form operations. Calling it tracks dependencies and respects configured equality and pending debounce. The method can be extracted without binding a receiver and works outside an injection context.

This method does not set the form's readonly state: use markAsReadonly() for that. As with Angular signals, a readonly view does not freeze or clone object values. Existing node semantics, including array reconciliation and equality, continue to apply; writable compatibility does not turn aggregate values into an unrelated signal store.

⚡ Think of a field as a signal with form features​

Conceptually, field() starts from the same value-access pattern as a normal writable Angular signal(): call it to read its current value, and use set() or update() to change that value.

const angularName = signal('Marco');

angularName(); // 'Marco'
angularName.set('David');

const formName = field('Marco');

formName(); // 'Marco'
formName.set('David');
formName.update(name => name.toUpperCase());

The difference is that a field is also a form node. In addition to signal-style value access, it provides validation, errors, touched and dirty state, disabled/readonly/hidden state, reset, debounce, focus, tree navigation, and Angular control binding. A normal signal() that stores the same value does not provide those form behaviors.

This mental model also applies to form(), group(), and array(): each primitive is callable to read its aggregate committed value and exposes set() and update(), while adding the structural and state behavior appropriate to that node kind.

Calling the node itself is the preferred way to read its committed value.

const myForm = form({
name: field('Marco'),
});

myForm.name(); // 'Marco'
myForm.name.set('David');
myForm.name.touched();
myForm.name.markAsTouched();

value.control() has different semantics and represents an immediate value buffered from a bound UI control before debounce completes. The Values and state page documents the explicit alternative value paths for generic infrastructure.

Aggregate state is derived from descendants. A form becomes invalid when one of its descendants is invalid, while errors() remains scoped to errors owned directly by the current node. Use allErrors() to collect errors from the complete subtree.