Lab 17: Signal Forms
π Resourcesβ
π Starter Codeβ
step-15-album-wholesale-v21-post-signal-computed
Continue from your Lab 16 result. If you need a fresh copy, use step-15-album-wholesale-v21-post-signal-computed as your base and complete Labs 15 and 16 first.
In this lab, you'll adopt Angular's new signal-based forms API. You'll create forms using the form() function backed by a writable signal, bind inputs with the [field] directive, implement validators declaratively, and save by reading directly from the signal.
Note: Signal Forms are experimental in Angular 21. Add
"skipLibCheck": trueto yourtsconfig.jsonto avoid a known type definition issue in@angular/forms/signals.
π Instructionsβ
Step 1: Enable skipLibCheckβ
In tsconfig.json, add "skipLibCheck": true to compilerOptions:
{
"compilerOptions": {
"skipLibCheck": true
}
}
Step 2: Create a Signal Formβ
form() requires a writable signal as its model. The signal is the source of truth β the [field] directive updates it directly as the user types.
import { Component, inject, signal } from '@angular/core';
import { form, required, min, validate, Field } from '@angular/forms/signals';
import { Album } from '../model/album.model';
function priceEndsWithNine(value: number) {
const lastDigit = value.toString().slice(-1);
return lastDigit !== '9'
? { kind: 'priceEndingWith9' as const, message: 'Price must end with 9' }
: null;
}
@Component({
selector: 'app-album-add',
templateUrl: './album-add.component.html',
standalone: true,
imports: [Field],
})
export class AlbumAddComponent {
// The signal is the model β form() wraps it with validation
private albumModel = signal<Album>({ id: 0, name: '', artist: '', description: '', price: 0, tags: [] });
albumForm = form(this.albumModel, (path) => {
required(path.name);
required(path.artist);
min(path.price, 0);
validate(path.price, (ctx) => priceEndsWithNine(ctx.value()));
});
saveAlbum() {
// Read current value directly from the signal β no .getRawValue() needed
const album = this.albumModel();
inject(AlbumService).save(album).subscribe(...);
}
}
Step 3: Use the [field] Directiveβ
Bind each input to its corresponding field using [field]:
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput [field]="albumForm.name">
@for (error of albumForm.name().errors(); track error.kind) {
@if (error.kind === 'required') {
<mat-error>Name is required</mat-error>
}
}
</mat-form-field>
<mat-form-field>
<mat-label>Artist</mat-label>
<input matInput [field]="albumForm.artist">
</mat-form-field>
<mat-form-field>
<mat-label>Price</mat-label>
<input matInput type="number" [field]="albumForm.price">
@for (error of albumForm.price().errors(); track error.kind) {
@if (error.kind === 'min') {
<mat-error>Price must be 0 or greater</mat-error>
}
@if (error.kind === 'priceEndingWith9') {
<mat-error>Price must end with 9</mat-error>
}
}
</mat-form-field>
<button mat-raised-button (click)="saveAlbum()">Save</button>
Step 4: Custom Validatorβ
A custom validator is a function that receives the current value and returns an error object or null:
function priceEndsWithNine(value: number) {
const lastDigit = value.toString().slice(-1);
return lastDigit !== '9'
? { kind: 'priceEndingWith9' as const, message: 'Price must end with 9' }
: null;
}
// Apply with validate():
validate(path.price, (ctx) => priceEndsWithNine(ctx.value()));
The kind property is used in the template @if (error.kind === 'priceEndingWith9') to show the right error message.