Skip to main content

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);

See Object field or group.

βš™οΈ 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'
Validity has three observable outcomes

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" />

See Advanced custom controls.

⏳ 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.