Common mistakes
Most surprises come from choosing the wrong node boundary or treating programmatic model updates as if they were user interaction. This page collects the mistakes that are easiest to make when first using Form Nodes. If something already fails or produces an unexpected result, start with Troubleshooting.
π Using array() for every array valueβ
An array-shaped value does not automatically need an array node.
// Unnecessarily creates one node for every selected role.
const myForm = form({
selectedRoles: array(field('')),
});
Use a normal field when one control owns the complete array, such as a multi-select:
const myForm = form({
selectedRoles: field<string[]>([]),
});
<select multiple [formNode]="myForm.selectedRoles">
<option value="admin">Administrator</option>
<option value="editor">Editor</option>
</select>
Use array() only when individual items need their own nodes, bindings, errors, paths, state, or
structural operations. See Array field or array().
π³ Using a group for an atomic or nullable objectβ
A group represents a permanent child structure. It cannot itself become null.
// Appropriate only when city and country need independent nodes.
const myForm = form({
shippingAddress: {
city: field(''),
country: field(''),
},
});
When one control edits the object as a unit, or the object itself may be absent, use a field:
type Address = {
city: string;
country: string;
};
const myForm = form({
shippingAddress: field<Address>(),
});
myForm.shippingAddress.set(null);
βοΈ Forgetting to declare nullable fieldsβ
An initial string produces string. Declare nullability when later writes may use null:
const myForm = form({
displayName: field.nullable(''),
});
myForm.displayName.set(null); // Valid.
Use inferred non-nullable fields or field.strict() when null is not part of the model:
const myForm = form({
countryCode: field.strict('CH'),
});
form() and array() are structural containers and remain non-null. See
field() nullability.
π Reading value.control() as the normal valueβ
value.control() is the immediate representation owned by a directly bound control. It may contain
a value that is still waiting for debounce.
const myForm = form({
search: field('', { debounce: 300 }),
});
myForm.search.value.control.set('signals');
myForm.search.value.control(); // 'signals'
myForm.search(); // '' until committed
Call the node itself for normal application logic. Validators and ancestors also observe the committed node value. See Value flow and debounce.
π Expecting set() or patch() to mark a node dirtyβ
Programmatic writes represent application state changes, not user interaction:
myForm.displayName.set('Ada');
myForm.patch({ displayName: 'Grace' });
myForm.displayName.dirty(); // false unless it was already dirty
A control-originated update marks its directly bound node dirty. If application code is deliberately simulating user interaction, mark that intent explicitly:
myForm.displayName.set('Ada');
myForm.displayName.markAsDirty();
β©οΈ Expecting reset() to restore the declaration valueβ
Calling reset() keeps the current committed value and clears interaction state:
myForm.displayName.set('Ada');
myForm.displayName.markAsTouched();
myForm.displayName.reset();
myForm.displayName(); // 'Ada'
myForm.displayName.touched(); // false
Pass the value to restore when resetting:
myForm.displayName.reset('');
Aggregate reset applies the same rule recursively. See Values and state.
π¨ Using errors() for a complete form summaryβ
errors() contains only errors owned directly by the node:
myForm.errors(); // form-level errors only
myForm.allErrors(); // form and descendant errors
A form can be invalid because a child is invalid while myForm.errors() remains empty. Use
allErrors() for summaries and errors() for rules attached to that exact node. See
Errors and validation status.
π¨ Assuming pending() means invalidβ
Pending work without a completed error has an unknown result:
myForm.username.pending(); // true
myForm.username.valid(); // false
myForm.username.invalid(); // false
myForm.username.validationStatus(); // 'unknown'
While validation is pending, both valid() and invalid() can be false and
validationStatus() is 'unknown'. Treat pending() as its own state instead of forcing a binary
interpretation or deriving invalid as !valid.
ποΈ Expecting enable() to override every disabled causeβ
Effective disabled state can come from local mutable state, reactive configuration, or an ancestor:
const myForm = form({
email: field(''),
}, {
disabled: () => accountLocked(),
});
myForm.email.enable();
// Still disabled while `accountLocked()` is true.
enable() removes only the field's imperative disable() cause. Inspect disabledReasons() when
the remaining source is unclear. Readonly and hidden state follow the same layered model.
π Expecting hidden() to remove the control from the DOMβ
Hidden is form state, not a rendering instruction. Remove hidden UI explicitly:
@if (myForm.internalNote.visible()) {
<input [formNode]="myForm.internalNote" />
}
Development builds warn when a hidden node remains bound to a rendered control. See Interaction and availability.
π Reaching for .$api in ordinary codeβ
Direct members are the normal, readable API:
myForm.displayName.set('Ada');
myForm.contacts.push({ label: 'Work', email: 'ada@example.com' });
myForm.valid();
Use .$api for generic infrastructure or a form child name collision. See
API access for collisions and generic code
for examples.
π Recreating array items when identity mattersβ
Without trackBy, complete array updates reuse nodes by position. That can associate touched,
dirty, or pending state with the wrong domain entity after server data is reordered.
const myForm = form({
people: array({
id: field(''),
name: field(''),
}, {
initialValue: initialPeople,
trackBy: 'id',
}),
});
Use a stable domain key when complete values can arrive in a different order. In Angular templates,
track the node instance rather than $index:
@for (person of myForm.people; track person) {
<input [formNode]="person.name" />
}
See Dynamic arrays.
β³ Returning asyncValidator() from a synchronous validatorβ
Conditional synchronous composition cannot establish an async validator's lifecycle:
// Do not return an async validator from this callback.
field('', [() => enabled() ? asyncValidator(checkValue) : null]);
Configure it directly and use its reactive when option:
const myForm = form({
username: field('', [
asyncValidator(checkUsername, {
when: () => usernameChecksEnabled(),
}),
]),
});
See asyncValidator().
π¨ Treating service failures as validation failures automaticallyβ
A rejected async operation contributes no validation error by default. Map infrastructure failure only when the product should represent it as a validation problem:
asyncValidator(checkUsername, {
onError: () => ({
kind: 'availabilityUnavailable',
message: 'Username availability could not be checked.',
}),
});
This keeps network failure distinct from a valid domain response such as βusername already taken.β
π Expecting native controls to bind aggregate nodesβ
Native input, select, and textarea elements edit field representations. A form or array can
bind directly only to a custom signal control or CVA that represents its complete value.
<!-- Bind a native element to a leaf field. -->
<input [formNode]="myForm.address.city" />
<!-- A custom aggregate editor may bind the complete nested form. -->
<app-address-editor [formNode]="myForm.address" />
β³ Using NG_ASYNC_VALIDATORS for node async validationβ
Synchronous NG_VALIDATORS from a CVA participate in node validation. NG_ASYNC_VALIDATORS are not
adapted because async work needs node-owned cancellation, debounce, dependency tracking, and stale
result protection. Declare it through asyncValidator() instead.
If the problem is already happening and its cause is unclear, continue with Troubleshooting. For a compact map of the complete public surface, see the API overview.