Formulare sind das primäre Mittel der Datenerfassung in Webanwendungen. Angular bietet zwei fundamental unterschiedliche Ansätze zur Formularimplementierung: Template-driven Forms und Reactive Forms. Diese Dichotomie ist keine bloße Designentscheidung, sondern reflektiert zwei konkurrierende Philosophien über die Rolle von Templates versus TypeScript-Code.
Template-driven Forms orientieren sich an klassischem HTML. Die Logik
lebt überwiegend im Template, Validierung wird durch HTML-Attribute
ausgedrückt, das Two-Way-Binding mit ngModel synchronisiert
View und Model automatisch. Dieser Ansatz ist deklarativ und intuitiv
für Entwickler mit HTML-Hintergrund.
Reactive Forms kehren diese Prioritäten um. Die Formular-Struktur wird imperativ in TypeScript definiert, das Template wird zur View-Layer reduziert. Reactive Forms bieten explizite Kontrolle über Zustand, Validierung und Updates. Sie sind verbose aber mächtig, komplex aber testbar.
Die Wahl zwischen beiden hängt von Komplexität, Team-Präferenzen und Testbarkeits-Anforderungen ab. Template-driven Forms eignen sich für einfache Szenarien – Login-Formulare, Kontaktformulare, simple CRUD-Operationen. Reactive Forms glänzen bei Komplexität – dynamische Felder, cross-field Validierung, komplexe State-Management-Requirements.
Template-driven Forms bauen auf Angular’s Two-Way-Binding. Das
FormsModule erweitert Standard-HTML-Elemente mit
Angular-Direktiven. Die ngForm-Direktive wird automatisch
auf jedes <form>-Element angewendet und tracked
dessen State. Die ngModel-Direktive bindet Input-Elemente
an Komponenten-Properties.
Ein einfaches Beispiel demonstriert das Pattern:
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-user-form',
standalone: true,
imports: [FormsModule],
template: `
<form #userForm="ngForm" (ngSubmit)="onSubmit()">
<label for="name">Name</label>
<input
id="name"
name="name"
[(ngModel)]="user.name"
required
minlength="3"
#nameField="ngModel">
@if (nameField.invalid && nameField.touched) {
<div class="error">
@if (nameField.errors?.['required']) {
<span>Name erforderlich</span>
}
@if (nameField.errors?.['minlength']) {
<span>Mindestens 3 Zeichen</span>
}
</div>
}
<label for="email">E-Mail</label>
<input
id="email"
name="email"
type="email"
[(ngModel)]="user.email"
required
email
#emailField="ngModel">
@if (emailField.invalid && emailField.touched) {
<div class="error">
@if (emailField.errors?.['required']) {
<span>E-Mail erforderlich</span>
}
@if (emailField.errors?.['email']) {
<span>Ungültige E-Mail</span>
}
</div>
}
<button type="submit" [disabled]="userForm.invalid">
Speichern
</button>
</form>
`
})
export class UserFormComponent {
user = {
name: '',
email: ''
};
onSubmit() {
console.log('Submitted:', this.user);
}
}Die Template-Referenz #userForm="ngForm" gibt Zugriff
auf die Form-Direktive. Die ngForm-Direktive aggregiert
alle ngModel-Controls innerhalb des Forms und trackt deren
Combined State. Das Form ist valid, wenn alle Controls valid sind. Das
Form ist touched, wenn ein Control touched wurde.
Die Template-Referenz #nameField="ngModel" gibt Zugriff
auf das individuelle Control. Dies ermöglicht granulare
Validierungs-Feedback. Die Bedingung
nameField.invalid && nameField.touched stellt
sicher, dass Fehler erst nach Benutzerinteraktion angezeigt werden,
nicht beim initialen Render.
Die name-Attribute sind essentiell. Sie registrieren
Controls beim Form. Fehlt das name-Attribut, tracked
ngForm das Control nicht. Das Two-Way-Binding
[(ngModel)] ist syntaktischer Zucker für
[ngModel]="user.name" (ngModelChange)="user.name = $event".
Angular fügt automatisch CSS-Klassen zu Form-Controls basierend auf State:
| Klasse | Bedeutung | Verwendung |
|---|---|---|
ng-valid / ng-invalid |
Validierungs-State | Grüne/rote Border |
ng-pristine / ng-dirty |
Änderungs-State | User hat Wert geändert |
ng-untouched / ng-touched |
Interaktions-State | User hat Control fokussiert |
ng-pending |
Async Validation läuft | Loading-Indicator |
Diese Klassen ermöglichen State-basiertes Styling:
input.ng-invalid.ng-touched {
border-color: #dc3545;
background-color: #fff5f5;
}
input.ng-valid.ng-touched {
border-color: #28a745;
}
input.ng-pending {
background-image: url('loading-spinner.gif');
background-position: right center;
background-repeat: no-repeat;
}Die Kombination .ng-invalid.ng-touched ist Standard –
zeige Fehler nur für berührte Felder. Dies verhindert eine Wall-of-Red
beim initialen Render.
Template-driven Forms können strukturierte Daten repräsentieren durch
ngModelGroup:
@Component({
template: `
<form #customerForm="ngForm" (ngSubmit)="onSubmit()">
<input name="name" [(ngModel)]="customer.name" required>
<div ngModelGroup="address">
<input name="street" [(ngModel)]="customer.address.street" required>
<input name="city" [(ngModel)]="customer.address.city" required>
<input name="zip" [(ngModel)]="customer.address.zip" required pattern="[0-9]{5}">
</div>
<button [disabled]="customerForm.invalid">Submit</button>
</form>
`
})
export class CustomerFormComponent {
customer = {
name: '',
address: {
street: '',
city: '',
zip: ''
}
};
}Die ngModelGroup-Direktive gruppiert Controls logisch.
Das customerForm.value-Objekt reflektiert die Struktur:
{
name: "John Doe",
address: {
street: "Main St",
city: "New York",
zip: "10001"
}
}Die Gruppierung ist nicht nur semantisch – sie ermöglicht auch Group-Level-Validierung durch Custom-Direktiven.
Reactive Forms invertieren die Kontrolle. Die Form-Struktur wird in TypeScript definiert, nicht im Template abgeleitet. Das Template bindet an die programmatisch erstellte Struktur. Diese Separation ermöglicht Unit-Testing der Formular-Logik ohne DOM-Abhängigkeit.
Der Kern-Baustein ist FormControl:
const nameControl = new FormControl('initial value', [
Validators.required,
Validators.minLength(3)
]);Ein FormControl kapselt den Wert, die Validatoren und
den State eines einzelnen Input. Die API ist explizit:
nameControl.setValue('new value');
nameControl.value // 'new value'
nameControl.valid // true/false
nameControl.errors // { required: true } oder null
nameControl.markAsTouched();
nameControl.touched // trueFormGroup sammelt mehrere Controls:
const userForm = new FormGroup({
name: new FormControl('', Validators.required),
email: new FormControl('', [Validators.required, Validators.email])
});Die Struktur ist hierarchisch. Ein FormGroup kann andere
FormGroups enthalten. Die Validierung propagiert – ein
FormGroup ist nur valid, wenn alle Children valid sind.
Der FormBuilder-Service reduziert Boilerplate:
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-user-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
<input formControlName="name">
@if (name.invalid && name.touched) {
<div class="error">
@if (name.errors?.['required']) {
<span>Name erforderlich</span>
}
@if (name.errors?.['minlength']) {
<span>Mindestens {{ name.errors?.['minlength'].requiredLength }} Zeichen</span>
}
</div>
}
<input formControlName="email" type="email">
@if (email.invalid && email.touched) {
<div class="error">
@if (email.errors?.['required']) {
<span>E-Mail erforderlich</span>
}
@if (email.errors?.['email']) {
<span>Ungültige E-Mail</span>
}
</div>
}
<button [disabled]="userForm.invalid">Submit</button>
</form>
`
})
export class UserFormComponent {
private fb = inject(FormBuilder);
userForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]]
});
get name() { return this.userForm.get('name')!; }
get email() { return this.userForm.get('email')!; }
onSubmit() {
if (this.userForm.valid) {
console.log('Submitted:', this.userForm.value);
}
}
}Die Template-Syntax ist sparsamer.
[formGroup]="userForm" bindet das Form-Objekt.
formControlName="name" bindet an das spezifische Control.
Keine name-Attribute nötig – die Binding erfolgt über die
programmatische Struktur.
Die Getter get name() vereinfachen Template-Zugriff.
Ohne sie wäre jede Referenz userForm.get('name')! – verbose
und repetitiv. Die Non-Null-Assertion ! ist sicher, da wir
wissen dass das Control existiert.
Reactive Forms exposen Observables für State-Changes:
export class ReactiveFormComponent {
userForm = this.fb.group({
name: [''],
email: ['']
});
constructor() {
this.userForm.valueChanges.subscribe(value => {
console.log('Form value changed:', value);
});
this.userForm.statusChanges.subscribe(status => {
console.log('Form status changed:', status);
});
this.userForm.get('name')!.valueChanges.subscribe(name => {
console.log('Name changed:', name);
});
}
}Die valueChanges-Observable emittiert bei jeder
Value-Änderung. Die statusChanges-Observable emittiert bei
Validierungs-Changes. Diese Observables ermöglichen reaktive
Side-Effects – etwa automatisches Speichern, Live-Search,
Cross-Field-Validierung.
Ein praktisches Beispiel ist Debouncing für API-Calls:
this.searchForm.get('query')!.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.searchService.search(query))
).subscribe(results => {
this.searchResults = results;
});Das Pattern ist common: warte 300ms nach der letzten Änderung, ignoriere Duplikate, cancele vorherige Requests, update Results. Ohne Observables wäre dies erheblich komplexer.
Reactive Forms bieten explizite APIs für Value-Updates:
// Alle Werte setzen (alle Controls müssen provided werden)
this.userForm.setValue({
name: 'John Doe',
email: 'john@example.com'
});
// Partielle Werte setzen
this.userForm.patchValue({
name: 'John Doe'
});
// Einzelnes Control
this.userForm.get('name')!.setValue('John Doe');
// Reset zu Initial-Values
this.userForm.reset();
// Reset mit neuen Werten
this.userForm.reset({
name: '',
email: ''
});setValue() ist strict – alle Controls müssen Werte
erhalten, sonst Error. patchValue() ist lenient – nur
provided Properties werden gesetzt. Der Unterschied ist wichtig beim
Laden von Server-Daten, die nicht die komplette Form-Struktur
matchen.
Verschachtelte Strukturen werden durch nested FormGroups
modelliert:
customerForm = this.fb.group({
name: ['', Validators.required],
address: this.fb.group({
street: ['', Validators.required],
city: ['', Validators.required],
zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]]
})
});Im Template mit formGroupName:
<form [formGroup]="customerForm">
<input formControlName="name">
<div formGroupName="address">
<input formControlName="street">
<input formControlName="city">
<input formControlName="zip">
</div>
</form>Der Zugriff auf nested Controls nutzt Dot-Notation:
get street() { return this.customerForm.get('address.street')!; }Die Struktur des value-Objekts reflektiert die
Hierarchie:
{
name: "John Doe",
address: {
street: "Main St",
city: "New York",
zip: "10001"
}
}FormArray managed eine dynamische Liste von Controls.
Dies ist essentiell für variabel lange Listen – Order Items, Phone
Numbers, Email Addresses.
Ein Order-Form mit dynamischen Items:
orderForm = this.fb.group({
customerName: ['', Validators.required],
items: this.fb.array([
this.createItem()
])
});
createItem(): FormGroup {
return this.fb.group({
product: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
price: [0, [Validators.required, Validators.min(0.01)]]
});
}
get items() {
return this.orderForm.get('items') as FormArray;
}
addItem() {
this.items.push(this.createItem());
}
removeItem(index: number) {
this.items.removeAt(index);
}
get totalPrice() {
return this.items.controls.reduce((sum, control) => {
const quantity = control.get('quantity')!.value;
const price = control.get('price')!.value;
return sum + (quantity * price);
}, 0);
}Im Template mit formArrayName:
<form [formGroup]="orderForm">
<input formControlName="customerName">
<div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input formControlName="product">
<input formControlName="quantity" type="number">
<input formControlName="price" type="number" step="0.01">
<button type="button" (click)="removeItem(i)">Remove</button>
</div>
}
</div>
<button type="button" (click)="addItem()">Add Item</button>
<div>Total: {{ totalPrice | currency }}</div>
</form>Die @for-Direktive iteriert über
items.controls. Jedes Control ist ein
FormGroup mit product/quantity/price. Das
[formGroupName]="i" bindet an den Index im Array.
FormArray ist nicht nur für homogene Listen. Es kann
auch gemischte Control-Typen enthalten:
const mixedArray = this.fb.array([
this.fb.control('simple value'),
this.fb.group({ name: [''], value: [0] }),
this.fb.array([this.fb.control('')])
]);Dies ist selten nötig aber möglich für highly dynamic Forms.
Angular’s Validierungs-System ist funktional. Ein Validator ist eine
Funktion, die ein AbstractControl nimmt und entweder
null (valid) oder ein ValidationErrors-Objekt
(invalid) zurückgibt.
Die eingebauten Validators sind pure Functions:
Validators.required(control: AbstractControl): ValidationErrors | null
Validators.minLength(length: number): ValidatorFn
Validators.pattern(pattern: string | RegExp): ValidatorFnValidators.required ist eine Funktion.
Validators.minLength ist eine Factory, die eine ValidatorFn
zurückgibt. Dies ermöglicht Parametrisierung.
Custom Validators folgen demselben Pattern:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function phoneValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) return null;
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
const valid = phoneRegex.test(control.value);
return valid ? null : { invalidPhone: true };
};
}Die Verwendung:
phoneForm = this.fb.group({
phone: ['', [Validators.required, phoneValidator()]]
});Die Error-Keys (invalidPhone) sind custom und müssen im
Template gehandled werden:
@if (phone.errors?.['invalidPhone']) {
<span>Ungültige Telefonnummer</span>
}Manche Validierung hängt von mehreren Fields ab. Password-Confirmation ist das klassische Beispiel:
export function passwordMatchValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const password = control.get('password');
const confirm = control.get('confirmPassword');
if (!password || !confirm) return null;
if (password.value !== confirm.value) {
confirm.setErrors({ passwordMismatch: true });
return { passwordMismatch: true };
}
// Clear error if passwords match
if (confirm.hasError('passwordMismatch')) {
confirm.setErrors(null);
}
return null;
};
}Der Validator wird auf dem FormGroup registriert, nicht
auf individuellen Controls:
registrationForm = this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required]
}, { validators: passwordMatchValidator() });Das Error-Objekt ist auf dem Group-Level:
get passwordMismatch() {
return this.registrationForm.errors?.['passwordMismatch'];
}Alternativ kann der Error auf einem Child-Control gesetzt werden, wie im Validator-Beispiel. Die Wahl hängt von UX-Anforderungen ab.
Manche Validierung erfordert Server-Calls. Username-Uniqueness ist
ein typisches Szenario. Asynchrone Validators returnen ein
Observable<ValidationErrors | null>:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, catchError, debounceTime, switchMap, first } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class UsernameValidator {
private http = inject(HttpClient);
uniqueUsername(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
return of(control.value).pipe(
debounceTime(300),
switchMap(username =>
this.http.get<{ available: boolean }>(`/api/check-username?username=${username}`).pipe(
map(response => response.available ? null : { usernameTaken: true }),
catchError(() => of(null))
)
),
first()
);
};
}
}Die Usage:
constructor(private usernameValidator: UsernameValidator) {}
form = this.fb.group({
username: [
'',
[Validators.required, Validators.minLength(3)],
[this.usernameValidator.uniqueUsername()]
]
});Asynchrone Validators sind das dritte Array-Argument. Sie laufen nur, wenn alle synchronen Validators passen. Dies verhindert unnötige API-Calls.
Während der Async-Validation ist der Control-Status
PENDING. Das Template kann dies visualisieren:
<input formControlName="username">
@if (username.pending) {
<span class="spinner">Checking...</span>
}
@if (username.errors?.['usernameTaken'] && username.touched) {
<span class="error">Username already taken</span>
}Das Debouncing im Validator verhindert Requests bei jedem Keystroke.
switchMap cancelt vorherige Requests. first()
completed die Observable nach dem ersten Emit. catchError
verhindert, dass Network-Errors die Form blockieren.
Formulare enden typischerweise mit einem HTTP-POST an den Server:
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormBuilder, Validators } from '@angular/forms';
import { finalize } from 'rxjs/operators';
@Component({
template: `
<form [formGroup]="contactForm" (ngSubmit)="onSubmit()">
<input formControlName="name">
<input formControlName="email">
<textarea formControlName="message"></textarea>
<button [disabled]="contactForm.invalid || submitting">
@if (submitting) {
Sending...
} @else {
Send
}
</button>
@if (submitSuccess) {
<div class="success">Message sent successfully!</div>
}
@if (submitError) {
<div class="error">{{ submitError }}</div>
}
</form>
`
})
export class ContactFormComponent {
private fb = inject(FormBuilder);
private http = inject(HttpClient);
contactForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
message: ['', Validators.required]
});
submitting = false;
submitSuccess = false;
submitError = '';
onSubmit() {
if (this.contactForm.invalid) return;
this.submitting = true;
this.submitSuccess = false;
this.submitError = '';
this.http.post('/api/contact', this.contactForm.value)
.pipe(finalize(() => this.submitting = false))
.subscribe({
next: () => {
this.submitSuccess = true;
this.contactForm.reset();
},
error: (error) => {
this.submitError = error.message || 'Submission failed';
}
});
}
}Die submitting-Flag disabled den Button während des
Requests. finalize() setzt die Flag zurück, egal ob Success
oder Error. Die Success-Callback reset das Form und zeigt Bestätigung.
Die Error-Callback zeigt die Fehlermeldung.
Für optimistische Updates kann das Form sofort gereset werden:
onSubmit() {
const data = this.contactForm.value;
this.contactForm.reset();
this.http.post('/api/contact', data).subscribe({
error: () => {
// Restore form state on error
this.contactForm.patchValue(data);
}
});
}Dies gibt sofortiges Feedback. Bei Failure wird der State restored.
Angular 14+ verbesserte die Type-Safety für Reactive Forms erheblich. Controls sind jetzt generisch typisiert:
interface UserFormModel {
name: string;
email: string;
age: number;
}
const form = this.fb.group<UserFormModel>({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
age: [0, [Validators.required, Validators.min(18)]]
});
// Type-safe access
const name: string = form.controls.name.value;
const age: number = form.controls.age.value;Der Compiler validiert Feld-Namen.
form.controls.invalidField ist ein Compile-Error. Die Werte
sind korrekt typisiert – kein any mehr.
Für nicht-nullable Controls gibt es nonNullable:
const form = this.fb.nonNullable.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]]
});
// Type: string, never null
const name = form.controls.name.value;Dies ist sicherer als nullable Defaults. form.reset()
setzt zu den Initial-Values zurück, nicht null.
Verschachtelte Strukturen sind ebenfalls typisiert:
interface AddressModel {
street: string;
city: string;
zip: string;
}
interface CustomerModel {
name: string;
address: AddressModel;
}
const form = this.fb.group<CustomerModel>({
name: [''],
address: this.fb.group<AddressModel>({
street: [''],
city: [''],
zip: ['']
})
});
// Type-safe nested access
const street: string = form.controls.address.controls.street.value;Die verbesserte Type-Safety reduziert Runtime-Errors erheblich. Refactorings werden sicherer – Rename eines Fields bricht die Compilation, nicht erst zur Runtime.
Komplexe Anwendungen profitieren von wiederverwendbaren Form-Komponenten. Eine Address-Form-Komponente kann in verschiedenen Contexts genutzt werden:
import { Component, Input, OnInit, inject } from '@angular/core';
import { FormGroup, FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-address-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<div [formGroup]="addressGroup">
<input formControlName="street" placeholder="Street">
<input formControlName="city" placeholder="City">
<input formControlName="zip" placeholder="ZIP">
</div>
`
})
export class AddressFormComponent implements OnInit {
@Input() parentForm!: FormGroup;
@Input() controlName = 'address';
private fb = inject(FormBuilder);
addressGroup!: FormGroup;
ngOnInit() {
this.addressGroup = this.fb.group({
street: ['', Validators.required],
city: ['', Validators.required],
zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]]
});
this.parentForm.addControl(this.controlName, this.addressGroup);
}
}Die Parent-Komponente integriert es:
@Component({
template: `
<form [formGroup]="customerForm" (ngSubmit)="onSubmit()">
<input formControlName="name">
<app-address-form
[parentForm]="customerForm"
controlName="billingAddress">
</app-address-form>
<app-address-form
[parentForm]="customerForm"
controlName="shippingAddress">
</app-address-form>
<button>Submit</button>
</form>
`
})
export class CustomerFormComponent {
customerForm = this.fb.group({
name: ['', Validators.required]
});
}Die Address-Components fügen sich selbst zum Parent-Form hinzu. Das Result ist ein integriertes Form mit allen Validations.
Ein fortgeschritteneres Pattern nutzt
ControlValueAccessor für vollständige
Form-Control-Integration. Dies erlaubt Custom-Components, die sich wie
native Form-Controls verhalten – mit ngModel und
formControlName kompatibel.
Angular Forms bieten zwei komplementäre Ansätze für Datenerfassung. Template-driven Forms sind schnell und intuitiv für einfache Szenarien. Reactive Forms bieten explizite Kontrolle, Testbarkeit und Skalierbarkeit für Komplexität. Die Validierungs-API ist extensible durch Custom Validators, sowohl synchron als auch asynchron. FormArray ermöglicht dynamische Form-Strukturen. Die Type-Safety-Verbesserungen in neueren Versionen reduzieren Fehler und verbessern Developer Experience. Die Wahl zwischen Template-driven und Reactive hängt von Requirements ab, aber beide integrieren nahtlos in Angular’s Reactive Architecture.