Einführung
Angular 21 hat erstmals die zukunftsweisenden Signal Forms eingeführt, die die bekannten Reactive Forms und Template Driven Forms ablösen sollen. Das Angular-Team stellt dafür eine Reihe an Möglichkeiten zur Verfügung, um die Migration zu erleichtern.
Dieser Artikel stellt die verschiedenen Grundkonzepte vor und erklärt die wesentlichen Schritte, um bestehende Formulare zu migrieren. Zusätzlich wird vertestet, wie KI uns dabei optimal unterstützen kann.
Warum Signal Forms?
Aktuell ist die Verwendung von Formularen in Verbindung mit Signals noch etwas umständlich. Mit den neuen Formularen greifen die Konzepte nahtlos ineinander und ermöglichen eine rein deklarative Struktur, die kürzer und verständlicher ist als zuvor. So wird nun statt Observables und Subscriptions auf einen deklarativen State gesetzt, der Komponenten übersichtlicher und somit wartbarer macht. Zudem fallen potentielle Memory Leaks weg und durch die Umstellung auf Signals greift die neue optimierte Change Detection für verbesserte Performance.
Zudem bieten die neuen Formulare verbesserte Typsicherheit, wodurch Fehler früher auffallen können.
Der Ausgangspunkt
Als Startpunkt für die Migration wird ein minimales Bestellformular verwendet, das auf Reactive Forms basiert und Eingabefelder für eine E-Mail und die Adresse beinhaltet.
1orderForm: FormGroup<OrderForm> = this.fb.group({
2 email: ['', [Validators.required, Validators.email]],
3 address: this.fb.group({
4 street: ['', Validators.required],
5 zip: ['', Validators.required]
6 })
7});
8
9isSubmitting = false;
10
11async onSubmit(): Promise<void> {
12 this.orderForm.markAllAsTouched();
13 if (this.orderForm.valid && !this.isSubmitting) {
14 this.isSubmitting = true;
15 await this.orderService.order(this.orderForm.value);
16 this.orderForm.reset();
17 this.isSubmitting = false;
18 }
19}
Das zugehörige HTML verwendet die gängigen Direktiven:
1<form [formGroup]="orderForm" (ngSubmit)="onSubmit()">
2 <input [formControl]="orderForm.controls.email" />
3 @if (orderForm.controls.email.invalid && orderForm.controls.email.touched) {
4 <span class="error">Invalid email</span>
5 }
6 ...
7 <button type="submit" [disabled]="orderForm.invalid || isSubmitting">Submit</button>
8</form>
Das ist das funktionierende Grundbeispiel. Wie im Code zu sehen ist, muss manuell festgehalten und geprüft werden, ob ein Submit-Vorgang läuft. Die formControl liefert in dieser Version nur Fehler-Codes und keine Nachricht, wodurch Mehraufwand für die Verwaltung und Anzeige der Nachrichten entsteht.
SignalFormControl
Wenn diese FormGroup jetzt erweitert werden soll, um beispielsweise einen Telefon-Input, bietet Angular die Möglichkeit, die neuen FormControls mithilfe des SignalFormControl auf Signal-Basis aufzubauen. So kann die neue API verwendet werden, ohne die bestehende Basis anpassen zu müssen und ermöglicht so eine Bottom-Up-Migration.
1import { SignalFormControl } from '@angular/forms/signals/compat';
2import { required } from '@angular/forms/signals';
3
4readonly phoneNumber = new SignalFormControl('', field => {
5 required(field, { message: 'Phone number is required' });
6});
7
8orderForm = this.fb.group({
9 email: ['', [Validators.required, Validators.email]],
10 phoneNumber: this.phoneNumber,
11 address: this.fb.group({ ... })
12});
Der zweite Parameter für den SignalFormControl-Konstruktor ist hier von besonderem Interesse. Es handelt sich dabei um eine Schemafunktion, die die Validierungsregeln für das Feld definiert. Das field an der Stelle ist eine Referenz zu dem Feld selbst, welches in den Validierungsregeln verwendet werden kann.
In dem Beispiel wird die Telefonnummer mit dem required zum Pflichtfeld gemacht und die entsprechende Fehlermeldung wird in Form der message-Property direkt mit übergeben. Dadurch, dass Schemafunktionen wiederverwendbar sind, ist es so einfacher, konsistente Fehlermeldungen in größeren Anwendungen anzuzeigen.
Bei einzelnen Feldern wirkt das erstmal wie ein recht kleiner Unterschied, in der Verwendung mit ganzen Formularen machen sich die Vorteile aber stärker bemerkbar.
Das Template für die bestehenden Felder ändert sich nicht, für das neue Feld wird nun formField statt formControl verwendet.
1<input type="tel" [formField]="phoneNumber.fieldTree" />
2@let phoneField = phoneNumber.fieldTree();
3@if (phoneField.touched() && phoneField.errors(); as errors) {
4 @for (error of errors; track error) {
5 <span class="error">{{ error.message }}</span>
6 }
7}
compatForm als Brücke
compatForm ist der nächste sinnvolle Schritt in der Migration und oft direkt ein guter Startpunkt. Mithilfe von compatForm wird das ganze Formular migriert, bietet aber die Möglichkeit, alte FormControls einzubinden. Dies ist sinnvoll, wenn Custom Controls verwendet wurden oder komplexe RxJS-Abläufe stattfinden. Ansonsten kann direkt auf reine Signal Forms migriert werden.
Bevor wir das Formular migrieren, fügen wir noch eine Custom Component namens ShippingSelectorComponent hinzu. Das Interface ControlValueAccessor erlaubt es eigene Komponenten an FormControls zu knüpfen und ermöglicht den Datenaustausch mit anderen Komponenten. Diese Komponente erlaubt es die Versandart auszuwählen und hilft bei der Veranschaulichung der weiteren Schritte. Mit dieser Komponente wird exemplarisch die Verwendung von compatForm gezeigt.
1@Component({
2 selector: 'app-shipping-selector',
3 providers: [{
4 provide: NG_VALUE_ACCESSOR,
5 useExisting: forwardRef(() => ShippingSelectorComponent),
6 multi: true
7 }]
8})
9export class ShippingSelectorComponent implements ControlValueAccessor {
10 private value: ShippingOption | null = null;
11 private disabled = false;
12 private onChangeFn: (value: ShippingOption | null) => void = () => {};
13 private onTouchedFn: () => void = () => {};
14
15 writeValue(value: ShippingOption | null): void { this.value = value; }
16 registerOnChange(fn: (value: ShippingOption | null) => void): void { this.onChangeFn = fn; }
17 registerOnTouched(fn: () => void): void { this.onTouchedFn = fn; }
18 setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }
19
20 onChange(newValue: ShippingOption): void {
21 this.value = newValue;
22 this.onChangeFn(newValue);
23 this.onTouchedFn();
24 }
25}
Der aktuelle Ansatz für eine solche Komponente bringt sehr viel Boilerplate mit sich und erfordert viel Zusatzaufwand, wie das manuelle Handhaben von Änderungen. In den späteren Abschnitten ist zu sehen, wie viel kürzer der moderne Ansatz ist.
Mithilfe von compatForm kann das komplette Formular migriert und die neue ShippingSelectorComponent eingebunden werden, ohne etwas an der Custom Control anzupassen. So können Signal Forms verwendet werden, ohne bestehende Komponenten anpassen zu müssen.
1shippingControl = new FormControl('standard', {
2 nonNullable: true,
3 validators: Validators.required
4});
5
6initialState = {
7 email: '',
8 phoneNumber: '',
9 shipping: this.shippingControl,
10 address: { street: '', zip: '' }
11}
12
13orderModel = signal(this.initialState);
14
15orderValidationSchema = (schemaPath) => {
16 required(schemaPath.email, { message: 'Email is required' });
17 email(schemaPath.email, { message: 'Invalid email format' });
18 required(schemaPath.phoneNumber, { message: 'Phone number is required' });
19 required(schemaPath.address.street, { message: 'Street is required' });
20 required(schemaPath.address.zip, { message: 'Zip is required' });
21};
22
23orderSubmission = async (form) => {
24 await this.orderService.order(this.orderModel());
25 this.shippingControl.reset('standard')
26 form().reset(this.initialState);
27 return undefined;
28};
29
30orderForm = compatForm(this.orderModel, this.orderValidationSchema, {
31 submission: { action: this.orderSubmission }
32});
Auch hier gibt es eine Schemafunktion. Diese wurde in die Variable orderValidationSchema ausgelagert und könnte so für mehrere Formulare wiederverwendet werden. In diesem Fall hat die Funktion eine Referenz auf das ganze Formular, so wird die gesamte Validierung gebündelt definiert. Dadurch, dass alles auf Signals aufgebaut ist, kann so auch konditionelle Validierung eingebaut werden, ohne diese seperat manuell zu verwalten.
Auch im Template verändert sich dadurch Einiges. Statt formGroup wird nun formRoot verwendet, welches gleichzeitig das (ngSubmit) ablöst und die Submission-Logik aus der Formular-Deklaration anwendet. Einzelne Felder verwenden, wie bei SignalFormControl, nun formField.
1<form [formRoot]="orderForm">
2 <input [formField]="orderForm.email" />
3 ...
Die ShippingSelectorComponent bildet hier die Ausnahme und verwendet weiterhin die alte Syntax:
1<app-shipping-selector [formControl]="orderForm.shipping().control()" />
Dadurch, dass die Signal Form jetzt direkt die Submission handhabt, kann der Status dazu automatisiert festgehalten werden und lässt sich problemlos mit dem submitting Signal abrufen.
1@if (orderForm().submitting()) {
2 <span class="submitting">Submitting...</span>
3}
4<button type="submit" [disabled]="!orderForm().valid() || orderForm().submitting()">
5 Submit
6</button>
Reine SignalForms
Für den Umstieg auf reine SignalForms fehlt nur noch ein Schritt: die ShippingSelectorComponent muss migriert werden. Dafür gibt es nun das neue FormValueControl-Interface, welches sämtliches Boilerplate eliminiert.
Der NG_VALUE_ACCESSOR fällt weg und Angular selbst verwaltet nun die onTouched- und onChanged-Methoden. Zudem liefert das Interface Signals, die mit den Standardvalidatoren, wie minLength, verknüpft sind und so die Interoperabilität mit diesen unterstützt.
Für neue Custom Controls müssen nicht alle Felder implementiert werden, sondern nur die, die wirklich gebraucht werden. So werden neue Komponenten wahnsinnig kurz:
1export class ShippingSelectorComponent implements FormValueControl<ShippingOption> {
2 value = model<ShippingOption>('standard');
3 disabled = input<boolean>(false);
4}
Mit dem value-Model werden Änderungen am Formular vorgenommen und Angular kommuniziert diese direkt im Hintergrund.
Außerdem liefert Angular eine Reihe an optionalen State Properties, die automatisch verwaltet werden. So kann z. B. mit dem disabled-Attribut ausgelesen werden, ob das Element deaktiviert ist oder nicht, ohne jeglichen Zusatzaufwand.
In der Parent-Komponente wird compatForm durch form abgelöst werden und das Model angepasst. Als letztes wird im Template auch hier auf formField umgestellt.
1import { form } from '@angular/forms/signals';
2
3orderModel = signal({
4 email: '',
5 phoneNumber: '',
6 shipping: 'standard' as ShippingOption,
7 address: { street: '', zip: '' }
8});
9
10orderForm = form(this.orderModel, (schemaPath) => ... );
FormArrays
FormArrays erfordern in der Migration mehr Aufmerksamkeit als die restlichen Formfelder, da sich nicht nur die Syntax verändert hat.
Mit Reactive Forms wurden FormArrays wie folgt verwendet:
1get items(): FormArray<FormGroup<ItemForm>> {
2 return this.orderForm.controls.items;
3}
4
5addItem(): void {
6 this.items.push(this.fb.group({ name: ['', Validators.required], quantity: [1] }));
7}
8
9removeItem(index: number): void {
10 this.items.removeAt(index);
11}
Signal Forms verwenden Signals für den Zustand und müssen dementsprechend aktualisiert werden.
1addItem(): void {
2 this.orderForm.items().value.update(items => [...items, { name: '', quantity: 1 }]);
3}
4
5removeItem(index: number): void {
6 this.orderForm.items().value.update(items => items.filter((_, i) => i !== index));
7}
Der größte Unterschied liegt allerdings in der Validierung, diese wird mithilfe von applyEach in der Form definiert und auf jedes Feld angewendet.
1orderForm = form(this.orderModel, (schemaPath) => {
2 applyEach(schemaPath.items, (item) => {
3 required(item.name, { message: 'Item name is required' });
4 required(item.quantity, { message: 'Quantity is required' });
5 });
6});
Veränderungen in Tests
In Tests verändert sich überraschend wenig. Eine solide Testbasis fokussiert sich auf Nutzerinteraktionen und Ergebnisse, statt auf Details in der Implementierung.
1const button = spectator.query('button[type="submit"]') as HTMLButtonElement;
2expect(button.disabled).toBe(true);
Die Tests mit dem meisten Anpassungsaufwand sind die, die den Zustand der Formulare verwenden. So muss statt zum Beispiel control.value oder control.valid nun entsprechend field().value() und field().valid() verwendet werden. Zusätzlich muss auch im Template der Tests auf die neuen Direktiven umgestellt werden.
Stolperfallen
Signal Forms bringen neben der reinen Syntax viele weitere Anpassungen mit sich, die bei der Entwicklung überraschend sein können.
Deklarativer Zustand
Neben der Validierung ist auch der restliche Zustand des Formulars deklarativ und wird gebündelt definiert. Wo früher disable und enable imperativ aufgerufen wurde, wird der Status nun automatisch durch das Formular verwaltet. Neben disabled kann so auch hidden und readonly definiert werden.
1orderForm = form(this.orderModel, (schemaPath) => {
2 disabled(schemaPath.address, ({valueOf}) =>
3 valueOf(schemaPath.shipping) === 'pickup'
4 );
5});
valueChanges und statusChanges
Ein übliches Muster mit Reactive Forms ist es auf die Observables in einem FormControl zu subscriben und so auf Änderungen zu reagieren.
Mit der neuen Variante muss an diesen Stellen entsprechend auf effect oder computed umgestellt werden. Insgesamt sorgen Signal Forms dafür, dass der Großteil der Logik so deklarativ definiert ist.
1// Reactive Forms
2this.total$ = this.form.valueChanges.pipe(map(value => calculateTotal(value)));
3
4// Signal Forms
5total = computed(() => calculateTotal(this.orderModel()));
CSS-Klassen
Die altbekannten CSS-Klassen ng-valid, ng-touched, etc. sind nun nicht mehr standardmäßig gesetzt. Angular bietet aber die Möglichkeit, diese projektweit zu aktivieren und eigene Klassen hinzuzufügen.
1import {provideSignalFormsConfig} from '@angular/forms/signals';
2import {NG_STATUS_CLASSES} from '@angular/forms/signals/compat';
3
4bootstrapApplication(App, {
5 providers: [
6 provideSignalFormsConfig({
7 classes: {
8 ...NG_STATUS_CLASSES,
9 'ng-readonly': ({state}) => state().readonly(),
10 }
11 }),
12 ],
13});
Reset
form().reset() verhält sich etwas anders, als formGroup.reset(). Die neue Variante aktualisiert, wie vorher, den touched- und dirty-Zustand, aktualisiert aber keine Werte.
Um die Felder zu leeren, müssen die Werte explizit übergeben werden.
1form().reset({
2 email: '',
3 phoneNumber: '',
4 shipping: 'standard',
5 address: { street: '', zip: '' }
6});
KI-gestützte Migration
Migrationen auf neue APIs bleiben oft als technische Schuld länger liegen und erhöhen so auf lange Sicht den Aufwand. Repetetive Aufgaben, wie die Migration von Formfeldern, eignen sich für KI-Unterstützung.
Konversations-KI
Angular bietet sehr ausführliche Dokumentation zur Migration und API der Signal Forms. Es gibt Beispiele zu den einzelnen Migrationsschritten und die Zusammenhänge und Gedanken der Schnittstellen werden erklärt. Mit genug Zeit, kann so ein solides Verständnis aufgebaut werden.
Chatbots haben allerdings das Problem, dass viele Daten veraltet sind und Antworten somit nicht auf dem aktuellsten Stand basieren. Beispielsweise hat die KI ein computed-Signal verwendet, um den Anzeigewert in einem Formular zu berechnen, anstatt die neue transformedValue-Methode zu verwenden, welches diesen Aufwand übernimmt.
Teilweise sind die Antworten auch schlichtweg falsch, was besonders bei neuen Schnittstellen vorkommt. Die KI gibt eine scheinbar richtige Antwort, die auf alten Schnittstellen basiert, aber in der Realität nicht funktioniert.
Wenn allerdings sichergestellt wird, dass die KI Zugriff auf aktuelle Daten hat, kann diese neue Konzepte gut erläutern und eventuell einzelne Formulare migrieren. Hierbei ist es aber wichtig einen Überblick über die Möglichkeiten der neuen API zu haben und die Antworten zu verifizieren.
Agentische KI
Um die Migration durch Agentische KI zu testen, habe ich essentielle Beispiele aus einem Projekt gesammelt und diese zusätzlich ergänzt, um möglichst viele Anwendungsfälle von Reactive Forms abzudecken. So habe ich 22 Beispiele aufgestellt und an eine Test-API angebunden, um möglichst praxisnah zu testen.
Unter anderem beinhaltet sind folgende Aspekte:
| Thema | Details / Features |
|---|---|
| Validierung | Serverseitig, Feldübergreifend, Schema mit Zod |
| Dynamische Formulare | FormArray, FormRecord, konditionale Felder |
| Wizards & Formulare | Multi-Step Wizards, Verschachtelte Formulare |
| Dropdowns | Abhängig, API-Anbindung, Autocomplete |
| Custom Controls | 5-Sterne-Bewertung, Dateiupload |
| Logik & HTTP | RxJS-Komposition, HTTP Interceptor, Autosave & Recovery, Form Submission |
Sämtliche Beispiele sind mit Tests abgedeckt, die darauf zielen Nutzerinteraktion und die Präsentation der Ergebnisse zu validieren, also nicht den internen Zustand der Komponenten zu testen, sondern das, was bei den Verwendern ankommt. So wird sichergestellt, dass sich alle Elemente so verhalten, wie vor der Migration.
Ich habe die Migration zwei Mal mit agentischer KI durchführen lassen, beide Male wurde von Angular 20 auf 22 geupdated und sämtliche Einsätze von Reactive Forms durch Signal Forms ersetzt.
Beide Durchläufe wurden mit Claude Opus 4.8 durchgeführt. Im ersten wurde die Codebasis ohne zusätzliche Tools migriert. Auf den ersten Blick wirkte das Ergebnis vielversprechend — alle Tests laufen durch und die Anwendung baut ohne Fehler. Rein visuell wirken die Beispiele auch unverändert. Nach genauerer Betrachtung sind dennoch einige Unterschiede und gravierende Probleme aufgefallen.
Obwohl Claude anfangs auf die offizielle Dokumentation zugegriffen hat, scheinen die meisten Probleme durch unvollständige API-Kenntnis zu kommen. Unter anderem fehlt das FormField-Interface in den Custom Controls, Validation wurde stellenweise mit computed Signals umgesetzt und statt transformedValue wurden effects verwendet, um neue Werte zu setzen.
Darüberhinaus hat Claude, scheinbar willkürlich Anpassungen vorgenommen, wenn die API komplexer wurde. So ist zum Beispiel ein Feld für das Alter durch ein Biographie-Feld ersetzt worden, Fehlermeldungen wurden umformuliert oder entfernt und mehrere Typen enthalten statt einer UUID jetzt einen Namen. Zusätzlich gibt es einige Stellen, die nicht richtig typisiert wurden und auf any zurückgreifen.
Durch die Migration sind aber auch schwerwiegendere Fehler entstanden. Bei der automatischen Speicherung kam es durch eine Race Condition zu Datenverlusten. Durch eine Verkettung von effects ist in einem Beispiel eine Endlosschleife entstanden, die den Browser-Tab abstürzen ließ. Zusätzlich funktioniert in keinem Beispiel die submission. Bei Nachbesserungsversuchen tat sich Claude mit dem submission-Thema am schwersten und hat einige umständliche Lösungen gebaut, bis manuell auf die neue API verwiesen wurde.
Insgesamt scheint der Großteil der Probleme durch mangelnde Kenntnisse der API und Best-Practices zu kommen.
Praktischerweise bietet das Angular-Team eine quasi maßgeschneiderte Lösung dafür an: den Angular CLI MCP Server. Dieser stellt zwei essentielle Tools für die Arbeit mit KI zur Verfügung. Zum einen direkten Zugriff auf die Dokumentation durch search_documentation und Zugriff auf Best Pratices mit get_best_practices.
Um dies zu verproben wurde ein zweiter Durchlauf mit Claude und Zugriff auf diese Tools verwendet.
Der zweite Durchlauf hat nicht nur 60% weniger Tokens verwendet, sondern auch fast alle oben benannten Probleme behoben. Durch bessere Kenntnis der Schnittstellen, konnte Claude komplexe und fehleranfällige Strukturen aus effects vermeiden und hat stattdessen die speziellen Funktionen dafür verwendet.
Interessanterweise neigt die KI dazu validateTree an zu vielen Stellen einzusetzen. Beispielsweise für feldübergreifende Validierung, obwohl die offizielle Dokumentation genau das nicht empfiehlt. Teilweise sogar für einzelne Felder. Funktional hat dies keinen Einfluss, ist aber unsauber.
Auch im zweiten Durchlauf ist Claude daran gescheitert die Submission zu migrieren und es konnten keine Formulare abgesendet werden. Mithilfe der Dokumentation konnte Claude dies aber direkt korrigieren und hat sogar ein einigen Stellen die neuen submitting-Signals verwendet. Mit Nachbesserungen in den Tests, konnte dieses Problem allerdings behoben werden.
Abgesehen davon sind alle Beispiele funktional identisch zu der Lösung mit Reactive Forms. Die agentische KI konnte auch komplexe Beispiele und spezielle Anwendungsfälle migrieren und hat größtenteils die richtigen Funnktionen in der API gefunden.
Fazit
Signal Forms haben überzeugende Vorteile — der deklarative Ansatz sorgt für weniger Code, der verständlicher ist. Insbesondere bei Custom Controls macht sich bemerkbar, wie einfach die neue Umsetzung ist.
Agentische KI kann mithilfe des MCP von Angular den Großteil der Migration übernehmen. Eine umfassende und verhaltensorientierte Testbasis schafft dabei Vertrauen in die Lösung. Nichtsdestotrotz sollte die Ergebnisse der KI verifiziert werden. Eine eigenständige Auseinandersetzung mit der neuen API ist erforderlich, um saubere und nachhaltige Lösungen sicherzustellen.
Als ersten Schritt sollten bestehende Formulare mithilfe von compatForm migrieren werden, um sich so schrittweise mit der neuen API vertraut zu machen.
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Blog-Autor*in
Jasper Houben
IT Consultant
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.