Skip to main content

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": true to your tsconfig.json to 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.