Handle nested objects and arrays in signal forms, adding and removing fields at runtime without FormArray.

Bishal Biswas
Self Learner
Nested objects
A nested object in the model becomes a nested field tree automatically.
interface Order {
reference: string;
customer: {
name: string;
email: string;
};
delivery: {
line1: string;
city: string;
postcode: string;
};
}
protected readonly model = signal<Order>({
reference: '',
customer: { name: '', email: '' },
delivery: { line1: '', city: '', postcode: '' },
});
protected readonly orderForm = form(this.model, path => {
required(path.reference);
required(path.customer.name);
email(path.customer.email);
required(path.delivery.line1);
required(path.delivery.postcode);
});
<input [formField]="orderForm.reference">
<input [formField]="orderForm.customer.name">
<input [formField]="orderForm.customer.email">
<input [formField]="orderForm.delivery.line1">
The path mirrors the model exactly, and every level is typed. There is no FormGroup to construct and no formGroupName in the template.
Reusing a schema for a subtree
import { schema, apply } from '@angular/forms/signals';
interface Address {
line1: string;
city: string;
postcode: string;
}
export const addressSchema = schema<Address>(path => {
required(path.line1, { message: 'Street address is required' });
required(path.city);
required(path.postcode);
pattern(path.postcode, /^[A-Z0-9 ]{5,8}$/i, { message: 'Check the postcode' });
});
protected readonly orderForm = form(this.model, path => {
required(path.reference);
apply(path.delivery, addressSchema);
apply(path.billing, addressSchema);
});
One set of address rules, applied twice. Writing that with reactive forms means building the same FormGroup twice or writing a factory.
Shortening deep paths in templates
@let delivery = orderForm.delivery;
<input [formField]="delivery.line1">
<input [formField]="delivery.city">
<input [formField]="delivery.postcode">
@let keeps deeply nested forms readable.
Passing a subtree to a child component works too:
@Component({
selector: 'app-address-fields',
imports: [FormField],
template: `
<input [formField]="address().line1">
<input [formField]="address().city">
<input [formField]="address().postcode">
`,
})
export class AddressFields {
readonly address = input.required<FieldTree<Address>>();
}
<app-address-fields [address]="orderForm.delivery" />
<app-address-fields [address]="orderForm.billing" />
The child knows nothing about the parent form. That composition is much cleaner than passing a FormGroup around.
Arrays
An array in the model becomes an iterable field.
interface Invoice {
customer: string;
lines: { description: string; quantity: number; price: number }[];
}
protected readonly model = signal<Invoice>({
customer: '',
lines: [{ description: '', quantity: 1, price: 0 }],
});
import { applyEach } from '@angular/forms/signals';
const lineSchema = schema<{ description: string; quantity: number; price: number }>(path => {
required(path.description);
min(path.quantity, 1);
min(path.price, 0);
});
protected readonly invoiceForm = form(this.model, path => {
required(path.customer);
applyEach(path.lines, lineSchema);
});
applyEach applies a schema to every element, however many there are.
Rendering an array
@for (line of invoiceForm.lines; track $index) {
<div class="line">
<input [formField]="line.description">
<input [formField]="line.quantity" type="number">
<input [formField]="line.price" type="number">
<button type="button" (click)="removeLine($index)">Remove</button>
</div>
}
<button type="button" (click)="addLine()">Add line</button>
Note invoiceForm.lines with no parentheses in the @for. The field tree is iterable directly. Its length is also structural:
<p>{{ invoiceForm.lines.length }} lines</p>
Adding and removing
You change the model, not the form. The form follows.
protected addLine(): void {
this.model.update(invoice => ({
...invoice,
lines: [...invoice.lines, { description: '', quantity: 1, price: 0 }],
}));
}
protected removeLine(index: number): void {
this.model.update(invoice => ({
...invoice,
lines: invoice.lines.filter((_, i) => i !== index),
}));
}
protected moveUp(index: number): void {
if (index === 0) return;
this.model.update(invoice => {
const lines = [...invoice.lines];
[lines[index - 1], lines[index]] = [lines[index], lines[index - 1]];
return { ...invoice, lines };
});
}
No FormArray, no push, no removeAt, no rebuilding controls. Replace the array in the model and the form updates.
This is the biggest practical improvement over reactive forms, where keeping a FormArray and the data in step was a constant source of bugs.
Derived totals
protected readonly total = computed(() =>
this.model().lines.reduce((sum, line) => sum + line.quantity * line.price, 0)
);
<p>Total: {{ total() | currency:'GBP' }}</p>
The model is a signal, so totals are just computed values. There is no valueChanges subscription to manage.
Nested loops
@for (section of surveyForm.sections; track $index; let sectionIndex = $index) {
<fieldset>
<input [formField]="section.title">
@for (question of section.questions; track $index) {
<input [formField]="question.text">
<button (click)="removeQuestion(sectionIndex, $index)">Remove</button>
}
</fieldset>
}
Alias the outer $index, because the inner loop shadows it. Forgetting that is the classic nested loop bug.
protected removeQuestion(sectionIndex: number, questionIndex: number): void {
this.model.update(survey => ({
...survey,
sections: survey.sections.map((section, i) =>
i !== sectionIndex
? section
: { ...section, questions: section.questions.filter((_, q) => q !== questionIndex) }
),
}));
}
Validating the array itself
form(this.model, path => {
applyEach(path.lines, lineSchema);
validate(path.lines, ctx => {
const lines = ctx.value();
if (lines.length === 0) {
return { kind: 'empty', message: 'Add at least one line' };
}
if (lines.length > 50) {
return { kind: 'tooMany', message: 'No more than 50 lines' };
}
return null;
});
});
Element rules and whole array rules coexist.
Multi select arrays
An array of selected values needs checkboxes or a multiple select.
@for (option of availableTags; track option) {
<label>
<input
type="checkbox"
[value]="option"
[formField]="form.tags">
{{ option }}
</label>
}
The value attribute is required here, unlike text inputs. It tells the directive which option this checkbox represents.
Conditional fields
form(this.model, path => {
hidden(path.deliveryInstructions, ctx => ctx.valueOf(path.method) !== 'delivery');
applyWhenValue(path, m => m.method === 'delivery', deliverySchema);
});
@if (!orderForm.deliveryInstructions().hidden()) {
<textarea [formField]="orderForm.deliveryInstructions"></textarea>
}
The field stays in the model, so switching back restores what was typed. Removing it from the model instead would lose it.
Quick recap
- Nested objects become nested field trees with no
FormGroupto build. apply(path.x, someSchema)reuses validation rules for a subtree.- Pass a subtree to a child component as a
FieldTreeinput. applyEachapplies a schema to every array element.- Iterate
form.lineswith no parentheses, and readform.lines.lengthstructurally. - Add and remove by updating the model, never the form.
- Alias the outer
$indexin nested loops, since the inner one shadows it.
import { Component, signal, computed, input } from '@angular/core';
import { form, schema, apply, applyEach, validate, required, min,
FormField, FieldTree } from '@angular/forms/signals';
interface Address { line1: string; city: string; postcode: string; }
interface Line { description: string; quantity: number; price: number; }
interface Invoice { customer: string; delivery: Address; billing: Address; lines: Line[]; }
// Reusable schemas, applied to a subtree or to every array element
const addressSchema = schema<Address>(path => { required(path.line1); required(path.city); });
const lineSchema = schema<Line>(path => { required(path.description); min(path.quantity, 1); });
@Component({
selector: 'app-invoice',
imports: [FormField],
template: `
<!-- @let keeps deeply nested paths readable -->
@let delivery = invoiceForm.delivery;
<input [formField]="delivery.line1">
<input [formField]="delivery.city">
<!-- iterate the field tree with NO parentheses -->
@for (line of invoiceForm.lines; track $index) {
<input [formField]="line.description">
<input [formField]="line.quantity" type="number">
<button (click)="removeLine($index)">Remove</button>
}
<!-- length is structural, no parentheses either -->
<p>{{ invoiceForm.lines.length }} lines, total {{ total() }}</p>
<button (click)="addLine()">Add line</button>
`,
})
export class InvoiceForm {
protected readonly model = signal<Invoice>({
customer: '',
delivery: { line1: '', city: '', postcode: '' },
billing: { line1: '', city: '', postcode: '' },
lines: [{ description: '', quantity: 1, price: 0 }],
});
protected readonly invoiceForm = form(this.model, path => {
required(path.customer);
apply(path.delivery, addressSchema); // one schema, applied twice
apply(path.billing, addressSchema);
applyEach(path.lines, lineSchema); // applied to EVERY element
// whole array rules coexist with element rules
validate(path.lines, ctx => ctx.value().length === 0
? { kind: 'empty', message: 'Add at least one line' } : null);
});
// The model is a signal, so totals are just computed. No valueChanges.
protected readonly total = computed(() =>
this.model().lines.reduce((sum, l) => sum + l.quantity * l.price, 0));
// Change the MODEL, never the form. No FormArray, no push, no removeAt.
protected addLine(): void {
this.model.update(inv => ({ ...inv,
lines: [...inv.lines, { description: '', quantity: 1, price: 0 }] }));
}
protected removeLine(index: number): void {
this.model.update(inv => ({ ...inv,
lines: inv.lines.filter((_, i) => i !== index) }));
}
}
// Pass a SUBTREE to a child component. The child knows nothing about the parent.
@Component({
selector: 'app-address-fields',
imports: [FormField],
template: `<input [formField]="address().line1"><input [formField]="address().city">`,
})
export class AddressFields {
readonly address = input.required<FieldTree<Address>>();
}
// <app-address-fields [address]="invoiceForm.delivery" />
// In NESTED loops, alias the outer index. The inner $index shadows it.
// @for (s of form.sections; track $index; let sectionIndex = $index) {
// @for (q of s.questions; track $index) { ... }
// }
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready