Dynamic arrays
For a complete program whose assertions verify keyed reconciliation and structural operations, see the executable array example.
An array() owns an ordered collection of cloned node templates:
const myForm = form({
people: array({
name: field(''),
age: field(18),
}, {
initialLength: 3,
}),
});
const people = myForm.people;
initialLength: 3 creates three independent form items from the template defaults. The initial value is:
myForm.people();
// Expected output:
// [
// { name: '', age: 18 },
// { name: '', age: 18 },
// { name: '', age: 18 },
// ]
Use initialValue for actual data and initialLength for a non-negative safe integer count of items created from template defaults:
array(personTemplate);
array(personTemplate, {
initialValue: initialPeople,
trackBy: 'id',
});
array(personTemplate, {
initialLength: 3,
});
The positional argument accepts either a count (array(template, 3)) or item values
(array(template, initialPeople)). Numeric initialValue, such as { initialValue: 3 },
remains supported for compatibility; prefer initialLength for new count-based declarations.
Use one initial source: a positional value/count, initialValue, or initialLength. Conflicting
sources are rejected by TypeScript and throw at runtime. initialLength accepts zero, but rejects
negative, fractional, non-finite, and unsafe integer counts. Omit all sources to start empty.
The length only applies during initialization. It does not impose a minimum or fixed length.
Every new item receives its own configureEach callback. resetToInitial() restores the captured
initial collection; ordinary reset() clears interaction state without restoring the initial count.
import { array, field, form } from '@ngblocks/form-nodes';
const myForm = form({
timeseries: array({
timeseriesCode: field<string>(null),
value: field(''),
axis: field('left'),
}, {
initialLength: 3,
configureEach(api) {
api.children.timeseriesCode.onValueChange(() => {
api.patch({ value: '', axis: 'left' });
});
},
}),
});
myForm.timeseries.length(); // 3
if (myForm.timeseries.length() !== 3 || myForm.timeseries.at(0) === myForm.timeseries.at(1)) {
throw new Error('Initial length must create three independent rows.');
}
myForm.timeseries.removeAt(0);
myForm.timeseries.length(); // 2
myForm.resetToInitial();
myForm.timeseries.length(); // 3
if (myForm.timeseries.length() !== 3) {
throw new Error('Reset to initial must restore the captured initial collection.');
}
// Positional counts and numeric initialValue remain supported.
const positional = array(field(''), 2);
const compatible = array(field(''), { initialValue: 2 });
if (positional.length() !== 2 || compatible.length() !== 2) {
throw new Error('Existing numeric initialization forms must remain supported.');
}
💡 Templates and factories
A template may be a field, form, nested array, shorthand object, or explicit factory:
const myForm = form({
tags: array(field(''), {
initialValue: ['angular', 'signals'],
}),
people: array(() => ({
name: field(''),
age: field(0),
})),
});
Object templates can also use field-value shorthand. Keep ordinary examples explicit and see
field() shorthands in object templates
for the concise syntax, inference, and ambiguity rules.
Declarative templates are compiled into a clone recipe. Every item receives fresh signals, descendants, validators, state, debounce ownership, and async watchers. Runtime values, touched/dirty flags, errors, pending work, parents, and paths are never shared.
The template node itself is not inserted. If application code retains it, it remains an independent live node. Use a factory when template construction itself must not start independent asynchronous work.
Compiling a template does not keep its original nodes or their parent tree alive through the clone recipe. Values, validator callbacks, and explicit injectors retain their existing identity; references held by your own configuration still apply.
Use configureEach to connect siblings or install
validators for every new item without wrapping an object template in group(). The callback
receives the item API after initial data is applied and runs only once per created item.
A factory must return a fresh tree. Returning the same live node more than once throws rather than allowing items to share state.
Validate siblings within a row
A field validator can read another field in the same array item through
ctx.parent<(typeof this.deliveryForm.packages)[number]>(). For example, require a
pickupLocation when that row's deliveryMethod is 'pickup'. The parent generic accepts
nullable indexed item types without NonNullable; the returned parent can still be null.
See the complete sibling-validation examples in array()
for a component declaration, the equivalent named form type, and a configure alternative with
inferred child types. Each row reads its own sibling, including rows created later.
📚 Reading items
Read values by calling the array and nodes through indexes, at(), items(), iteration, or familiar helpers:
people(); // [{ name: '', age: 18 }, { name: '', age: 18 }, { name: '', age: 18 }]
people[0]?.name(); // ''
people.at(0)?.name(); // ''
people.items();
people.map(person => person.name()); // ['', '', '']
for (const person of people) {
console.log(person.name());
}
Angular templates can iterate the node directly. Track the node instance to retain rendered controls across moves:
@for (person of people; track person) {
<input [formNode]="person.name" />
}
Array traversal helpers snapshot items() when the operation begins. Structural changes made inside a callback do not alter that active traversal.
items() is a signal whose array reference changes when structure changes. Its nodes are live and readonly as a collection. Calling the array also produces a new value-array reference after a structural change, so computed() and effect() consumers react to push() and the other structural operations. Previously read item and value snapshots remain unchanged.
📚 Add and remove items
const created = people.push({ id: '2', name: 'Grace' });
people.insert(0, { id: '0', name: 'Lin' });
const removed = people.removeAt(1);
people.clear();
Omit the value from push() or insert() to use the template defaults.
New items start pristine and untouched. Structural mutations are programmatic and preserve the array's current dirty state. Removed nodes detach from parent state and validation; a retained reference remains usable as a standalone tree.
Detachment is immediate and observable. A removed node has parent() === null, a root path of [],
and no longer contributes value, errors, pending work, touched state, or dirty state to its former
array. If the removed item is itself a form or array, its descendants remain attached to that
removed root and continue working normally.
📚 Reorder items
Structural operations preserve node identity, interaction state, validation state, and pending work:
people.moveUp(2);
people.moveDown(0);
people.move(3, 1);
people.swap(0, 2);
Paths and indexes update after each operation.
Boundary moves and same-index operations are no-ops. An index that does not identify an existing item throws RangeError for movement and swap operations.
Because moves preserve nodes rather than values alone, in-flight validation and current rendered bindings remain owned by the moved item. Angular templates should continue tracking the item node, not its current index.
💡 Complete reconciliation
set() and update() reconcile a complete value. Without trackBy, current nodes are reused by index:
people.set([
{ id: '2', name: 'Grace Hopper' },
{ id: '1', name: 'Ada Lovelace' },
]);
Use a stable property or callback when values can be reordered or replaced from a server:
Choose trackBy from immutable domain identity, not the current index or another editable value.
Duplicate keys are rejected before the array mutates.
const myForm = form({
people: array(personTemplate, {
initialValue: initialPeople,
trackBy: 'id',
}),
keyedPeople: array(personTemplate, {
initialValue: initialPeople,
trackBy: person => person.id,
}),
});
Matching keys reuse and move existing nodes. Keys must be unique among current and incoming items; duplicates throw before mutation.
Passing null or undefined to set(), returning it from update(), or supplying it to reset(value) clears the collection. The observable array value itself remains [], never nullish.
📝 Patching collections and individual rows
patch() reconciles a complete array, exactly like set(). This includes arrays supplied to
form.patch() or group.patch(): omitted object branches remain unchanged, but supplied arrays
set the collection's length and order and require complete item values. Matching nodes are reused
by index or trackBy; missing nodes detach and new nodes are created.
import { array, field, form } from '@ngblocks/form-nodes';
const profile = form({
username: field('Ada'),
details: {
note: field('Keep this note'),
cities: array({
city: field(''),
country: field(''),
}, {
initialValue: [{ city: 'Madrid', country: 'Spain' }],
}),
},
});
profile.patch({ details: { cities: [
{ city: 'Rabat', country: 'Morocco' },
{ city: 'Valencia', country: 'Spain' },
] } });
profile.username(); // 'Ada'
profile.details.note(); // 'Keep this note'
profile.details.cities.length(); // 2
if (profile.username() !== 'Ada' || profile.details.note() !== 'Keep this note'
|| profile.details.cities.length() !== 2 || profile.details.cities[0]?.country() !== 'Morocco') {
throw new Error('A parent patch must preserve omitted branches and reconcile complete array values.');
}
profile.details.cities.patch([{ city: 'Paris', country: 'France' }]);
profile.details.cities(); // [{ city: 'Paris', country: 'France' }]
if (profile.details.cities.length() !== 1 || profile.details.cities[0]?.country() !== 'France') {
throw new Error('An array patch must remove trailing rows and assign complete item values.');
}
profile.details.cities.at(0)?.patch({ city: 'Lyon' });
profile.details.cities(); // [{ city: 'Lyon', country: 'France' }]
if (profile.details.cities[0]?.city() !== 'Lyon' || profile.details.cities[0]?.country() !== 'France') {
throw new Error('A row patch must preserve omitted row properties.');
}
profile.patch({ details: { cities: [] } });
profile.details.cities(); // []
if (profile.details.cities.length() !== 0) throw new Error('An empty array patch must clear the collection.');
// Runtime fallback for data that bypasses the complete-item TypeScript contract.
const people = form({
users: array({
username: field(''),
age: field<number | null>(null),
}, {
initialValue: [{ username: 'previous', age: 28 }],
}),
});
const externalData = JSON.parse('[{"username":"tobi"},{"username":"andrew"}]');
people.patch({ users: externalData });
people.users(); // [{ username: 'tobi', age: null }, { username: 'andrew', age: null }]
if (people.users.length() !== 2 || people.users().some(user => user.age !== null)) {
throw new Error('Omitted properties must use declaration defaults for both reused and new rows.');
}
For a partial edit to one existing object row, call that row's patch(). Empty arrays, null,
and undefined clear the collection. Sparse arrays no longer express skipped positional updates.
See the array patch reference.
⚡ State aggregation
Array validity, errors, dirty, touched, disabled, readonly, hidden, pending, debouncing, focus, and reset behavior aggregate or propagate like forms. Removed items detach from that aggregation immediately.
See Advanced behavior and edge cases for retained removed nodes, stale async reconciliation work, and identity-related edge cases.