Signal Forms ist das neue Formularsystem von Angular, aufgebaut auf Signals. Es erschien experimentell in v21 und wurde in v22 zu einer stabilen öffentlichen API. Die Grundidee: Du startest mit einem Modell (einem Signal mit deinen Daten), und form() baut darauf einen Baum von Feldern mit Zustand (valid, touched, errors…) und Regeln auf, die in einem Schema definiert sind.
| Reactive Forms | Signal Forms | |
|---|---|---|
| Single Source of Truth | FormGroup (interne Kopie) | dein Modell (signal) |
| Struktur | von Hand gebaut (FormControl, FormGroup) | aus dem Typ des Modells abgeleitet |
| Validierung | Validators pro Control | Schema: required(p.email), validate(...) |
| Reaktivität | valueChanges (Observable) | Signals (value(), valid()) |
| Binding | formControlName | [formField] |
| Typisierung | gut (Typed Forms, v14+) | vollständig, aus dem Modell |
Das erste Formular
import { Component, signal } from '@angular/core';
import { FormField, FormRoot, email, form, minLength, required } from '@angular/forms/signals';
interface Login {
email: string;
password: string;
}
@Component({
selector: 'app-login',
imports: [FormField, FormRoot],
template: `
<form [formRoot]="loginForm">
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().touched() && loginForm.email().invalid()) {
@for (e of loginForm.email().errors(); track e.kind) { <small>{{ e.message }}</small> }
}
<input type="password" [formField]="loginForm.password" />
<button type="submit" [disabled]="loginForm().submitting()">Anmelden</button>
</form>
`,
})
export class LoginForm {
// 1. DAS MODELL: ein gewöhnliches Signal — die Single Source of Truth
readonly model = signal<Login>({ email: '', password: '' });
// 2. DAS FORMULAR: ein Baum von Feldern über dem Modell + das SCHEMA der Regeln
readonly loginForm = form(
this.model,
(path) => {
required(path.email, { message: 'E-Mail ist erforderlich' });
email(path.email, { message: 'Ungültige E-Mail' });
required(path.password);
minLength(path.password, 8, { message: 'Mindestens 8 Zeichen' });
},
{ submission: { action: async (f) => this.auth.login(f().value()) } },
);
}Das Modell IST das Formular
// Das Modell und das Formular sind DASSELBE — es gibt keine interne Kopie
this.model.set({ email: 'ana@atlas.dev', password: '' }); // → das Input aktualisiert sich
this.loginForm.email().value.set('x@y.ro'); // → model() aktualisiert sich
// Das bedeutet:
// - du lädst Daten vom Server mit model.set(...)
// - du kannst alles mit computed(() => this.model().email) ableiten
// - patchValue / setValue / valueChanges brauchst du nicht mehrDer Zustand eines Feldes
// Jeder Knoten des Baums ist eine Funktion: Du rufst sie auf und bekommst den Zustand (Signals)
const email = this.loginForm.email(); // FieldState<string>
email.value(); // WritableSignal<string> — direkt an das Modell gebunden
email.value.set('a@b.ro');
email.valid(); email.invalid(); email.pending();
email.touched(); email.dirty();
email.errors(); // ValidationError[] — { kind, message }
email.getError('email');
email.disabled(); email.hidden(); email.readonly(); email.required();
email.markAsTouched();
email.focus(); // v21.1: Fokus auf das gebundene Control
// Das ganze Formular ist ebenfalls ein Feld
this.loginForm().valid();
this.loginForm().submitting();
this.loginForm().errorSummary(); // alle Fehler im Baum
this.loginForm().reset();Wann man die Fehler zeigt
Ein Feld kann ungültig sein, lange bevor der Nutzer damit fertig ist: ana@ ist noch keine E-Mail. Der Feldzustand gibt dir alles, um den Moment zu wählen: dirty() (der Wert hat sich geändert), touched() (der Nutzer hat das Feld verlassen) und valid().
dirty() && invalid(): Die Meldung erscheint beim ersten Tastendruck.
| password | ||
|---|---|---|
dirty() | false | false |
touched() | false | false |
valid() | false | false |
| Fehler sichtbar | false | false |
- Tastendrücke
- 0
- Tastendrücke mit sichtbarem Fehler
- 0
Fehler beim Tippen zu zeigen (dirty) heißt, den Nutzer für ein Wort zu tadeln, das er noch nicht zu Ende geschrieben hat. Auf touched zu warten ist der übliche Standard: Das erste Urteil kommt beim Verlassen des Felds, danach aktualisiert sich die Meldung live und verschwindet, sobald der Wert stimmt. Bis zum Absenden zu warten passt zu kurzen Formularen; ein Absendeversuch markiert alle Felder als touched. Was du auch wählst: Leg es an einer Stelle fest — in einer Komponente wie <app-field-errors> —, nicht in jedem Template.
Validators
import { email, max, maxLength, min, minLength, pattern, required, validate } from '@angular/forms/signals';
form(this.model, (p) => {
required(p.name, { message: 'Pflichtfeld' });
minLength(p.name, 2);
maxLength(p.bio, 280);
email(p.email);
min(p.age, 18, { message: 'Mindestens 18 Jahre alt' });
max(p.age, 120);
pattern(p.zip, /^\d{6}$/, { message: 'Postleitzahl mit 6 Ziffern' });
required(p.terms); // bei einem Boolean: false = ungültig (wie bei einer nativen Checkbox)
// bedingt
required(p.company, { when: ({ valueOf }) => valueOf(p.type) === 'business' });
// benutzerdefiniert, zwischen Feldern
validate(p.confirm, ({ value, valueOf }) =>
value() !== valueOf(p.password) ? { kind: 'mismatch', message: 'Passwörter stimmen nicht überein' } : undefined,
);
});Das Formular absenden
// Variante 1: submission in den form()-Optionen + <form [formRoot]>
readonly f = form(this.model, schemaFn, {
submission: {
action: async (field) => {
try {
await this.api.register(field().value());
return undefined; // Erfolg
} catch {
// Serverfehler, die an ein bestimmtes Feld gebunden sind
return { kind: 'taken', message: 'E-Mail wird bereits verwendet', fieldTree: field.email };
}
},
onInvalid: (field) => field().errorSummary()[0]?.fieldTree().focus(), // Fokus auf den ersten Fehler
},
});
// Variante 2: ein manuelles submit()
async save() {
await submit(this.f, async (field) => this.api.save(field().value()));
}Was du mit [formField] binden kannst
<!-- [formField] funktioniert mit allen nativen Controls: -->
<input [formField]="f.name" /> <!-- string -->
<input type="number" [formField]="f.age" /> <!-- number | null -->
<input type="checkbox" [formField]="f.terms" /> <!-- boolean -->
<input type="date" [formField]="f.birthday" /> <!-- string / Date -->
<select [formField]="f.country">...</select>
<textarea [formField]="f.bio"></textarea>
<input type="radio" value="a" [formField]="f.plan" />
<!-- und mit eigenen Controls: FormValueControl / FormCheckboxControl / ControlValueAccessor -->
<app-rating-control [formField]="f.rating" />Bedingte Felder, dynamische Listen und asynchrone Validierung behandelt Signal Forms — fortgeschritten; eigene Controls Eigene Formular-Controls.
Wann ja, wann nein
- Jedes neue Formular in einer v22-Anwendung.
- Formulare mit viel bedingter Logik (ausgeblendete Felder, deaktivierte Felder, Validierungen, die von anderen Feldern abhängen).
- Wenn die Daten ein einfaches Signal sein sollen, das sich leicht ableiten und speichern lässt.
- Eine große Anwendung mit Hunderten funktionierender Reactive Forms — migriere schrittweise, nicht alles auf einmal.
- Komponentenbibliotheken, die nur
ControlValueAccessor+ Reactive Forms unterstützen (prüfe die CVA-Interop). - Das Team braucht überall
valueChangesals Observable (obwohltoObservabledas löst).
Häufige Stolperfallen
- Du vergisst, das Feld aufzurufen:
f.email.valid()gibt es nicht; richtig istf.email().valid()(erst der Zustand, dann das Signal). - Fehler werden sofort angezeigt: Zeig sie erst nach
touched()(oder nach einem Submit). Das Absenden markiert automatisch alles als touched. - Das Modell mutieren:
model().email = 'x'benachrichtigt nicht. Nutzemodel.update()oderf.email().value.set(). min/maxmit Strings: Ab v22 akzeptieren sie nur noch Zahlen (Breaking Change).