Jarenlang kozen Angular-ontwikkelaars tussen twee manieren om formulieren te doen: template-driven forms (eenvoudig maar beperkt) of Reactive Forms (krachtig maar veel code). Reactive Forms werden de standaard voor complexe apps, maar vroegen altijd behoorlijk wat boilerplate. Met Angular 21 is er een nieuw alternatief: Signal Forms.
Ik heb de laatste tijd met Signal Forms gewerkt en een kleine demo-app gemaakt om de kennis te delen met anderen. En ik moet zeggen: ik ben onder de indruk.
Deze API combineert het beste van twee werelden: de kracht en reactiviteit van Reactive Forms met de eenvoud en helderheid van Angular Signals.
Een korte geschiedenis van Angular-formulieren
Angular heeft altijd twee manieren gehad om formulieren te beheren:
Template-driven forms zijn het eenvoudigst. Je gebruikt directives zoals ngModel in je templates en Angular regelt het meeste achter de schermen. Ze zijn handig voor simpele formulieren, maar worden al snel onhandig bij meer complexiteit. Testen is ook lastiger omdat veel logica in de template zit.
Reactive Forms gaven meer controle. Je definieert de formulierstructuur in TypeScript met FormGroup, FormControl en FormArray. Deze aanpak is krachtig en goed testbaar, maar gaat gepaard met veel herhalende code. Bij elk reactive form schrijf je weer dezelfde patronen: controls aanmaken, validators instellen, subscriben op value changes, state handmatig bijhouden…
Beide aanpakken hebben hun dienst bewezen, maar ze bestonden al voordat Signals het reactiviteitsmodel van Angular veranderden.
Waarom Signals het verschil maken
Angular Signals (sinds Angular 16, en in nieuwere versies de aanbevolen manier) hebben veranderd hoe we over reactieve state in Angular nadenken. In plaats van overal op RxJS-observables te leunen of change detection handmatig aan te sturen, bieden signals een intuïtievere en efficiëntere manier om met reactieve data om te gaan.
Wat signals bijzonder maakt:
- Fijnmazige reactiviteit: Alleen de onderdelen van je template die van een signal afhangen, worden opnieuw gerenderd bij wijziging.
- Eenvoudige syntax: Geen async pipe of handmatig subscribe/unsubscribe.
- Betere prestaties: Werkt naadloos met OnPush change detection.
- Intuïtieve API: Een signal uitlezen met door deze aan te roepen, en bijwerken met
.set() of .update()voelt natuurlijk aan.
Toen het Angular-team Signal Forms in Angular 21 aankondigde, was dat dus logisch: waarom deze aanpak niet ook voor formulieren gebruiken?
Aan de slag met Signal Forms
Zorg dat je Angular 21 of hoger gebruikt:
{
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0"
}
}
De Signal Forms API zit in @angular/forms/signals, los van de bestaande forms-API. Dit importeer je:
import { form, Field, required, validate, validateHttp, debounce } from '@angular/forms/signals';
import { signal } from '@angular/core';
Daarmee kun je direct signal-gebaseerde formulieren bouwen.
Kernconcepten
Ik licht de kernconcepten toe aan de hand van een eenvoudig personenformulier dat de belangrijkste onderdelen van Signal Forms laat zien.
Signal Models: de data van je formulier
De eerste stap is het definiëren van je formuliermodel. Bij Signal Forms is dat gewoon een signal:
interface Person {
name: string;
age: number;
}
// Stap 1: Maak een formuliermodel met signal()
personModel = signal<Person>({
name: '',
age: 0,
});
Je formulierdata staat in een signal en is daarmee vanaf het begin al reactief. Geen aparte FormControl-instanties of handmatige value accessors meer nodig!
Formulieren maken met de functie form()
Hier begint het sterke punt van Signal Forms. Je geeft je model-signal door aan form(), en je krijgt een FieldTree terug met alle reactieve formulier-state die je nodig hebt:
// Stap 2: Geef het formuliermodel door aan form() om een FieldTree te maken
personForm = form(this.personModel, (schemaPath) => {
// Validatieregels toevoegen
required(schemaPath.name, { message: 'Name is required' });
required(schemaPath.age, { message: 'Age is required' });
validate(schemaPath.age, ({ value }) => {
if (value() < 0) {
return {
kind: 'age-error',
message: 'Age must be non-negative',
};
}
if (value() < 18 || value() > 65) {
return null; // Valid
}
return {
kind: 'age-error',
message: 'Age must be lower than 18 or higher than 65',
};
});
});
Wat er gebeurt:
form()krijgt je model-signal en een configuratiefunctie.- Die functie krijgt een schemaPath die de structuur van je model volgt.
- Je voegt validators toe met functies als
required() en validate(). - Custom validators krijgen een signal voor de veldwaarde en geven null (geldig) of een error-object terug.
TypeScript kent dankzij je Person-interface de velden van schemaPath. Een veld dat niet bestaat valideer je niet; dat geeft een compilefout.
Precies het soort developer experience dat fijn werkt.
De Field-directive
Het koppelen van je formulier aan de template gaat eenvoudig met de [field]-directive:
<input id="name-input" type="text" [field]="personForm.name" />
Eén directive, en Angular regelt two-way binding, validatie en state. Geen formControlName en FormGroup in de template meer; die ceremonie is bij Signal Forms niet meer nodig.
Zoals je ziet is de integratie in je templates strak en sluit aan bij de nieuwste Angular-features.
Reactiviteit is nu simpel
Signal Forms maken reageren op input erg eenvoudig. Neem dit voorbeeld waarin we ARIA-attributen toevoegen aan een input:
<input
id="name-input"
type="text"
[field]="personForm.name"
[attr.aria-invalid]="
personForm.name().touched() ? (personForm.name().invalid() ? 'true' : 'false') : null
"
[attr.aria-valid]="
personForm.name().touched() && personForm.name().valid() ? 'true' : null
"
[attr.aria-describedby]="
personForm.name().touched() && personForm.name().invalid() ? 'name-errors' : null
"
/>
Het veld exposeert signals voor alle state die je nodig hebt: touched(), invalid(), valid(). Daarmee zet je eenvoudig de juiste ARIA-attributen voor screenreaders in dit voorbeeld; bij statewijziging updaten ze automatisch mee doordat het allemaal signals zijn.
Foutweergave met de nieuwe control flow
De nieuwe control flow van Angular (@if, @for) past goed bij Signal Forms:
@if (personForm.name().touched() && personForm.name().invalid()) {
<ul id="name-errors" class="error-list" role="alert" aria-live="polite">
@for (error of personForm.name().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
Leesbaar als gewone taal: als het naamveld touched én invalid is, toon een lijst; voor elke error toon het bericht. De errors()-signal geeft een array van error-objecten met de berichten uit je validators. Geen hasError() of handmatig door error-objecten graven meer.
Alle formulierfouten tonen
Soms wil je alle fouten op één plek tonen, bijvoorbeeld bovenaan het formulier. Dat kan door over alle velden en hun errors te itereren, of met een helper zoals getAllFormErrors() die je in je component definieert. Handig bij langere formulieren zodat gebruikers in één oogopslag zien wat er nog gecorrigeerd moet worden.
Formulierstate in realtime tonen
Voor ontwikkeling en debuggen kun je de actuele formulierstate eenvoudig tonen: elk veld heeft signals voor value(), valid()/invalid(), touched(), dirty(), pending() en errors(). Daarmee zie je direct in welke staat je formulier verkeert.
Asynchrone validatie
Signal Forms ondersteunen asynchrone validatie, bijvoorbeeld tegen een backend-API (zoals gebruikersnaam beschikbaarheid). Daarvoor is validateHttp beschikbaar: je definieert request, onSuccess en onError. Tijdens de validatie is pending() true; daarmee kun je loaders of “Controleren…” tonen en ARIA-attributen zoals aria-busy correct zetten. Met debounce() voorkom je dat bij elke toetsaanslag een request wordt gedaan.
```
userForm = form(this.userModel, (schemaPath) => {
// Async validation with HTTP request
validateHttp(schemaPath.username, {
request: ({value}) => {
const usernameValue = value();
// Skip HTTP validation if username is empty (required validator handles this)
if (!usernameValue || usernameValue.trim() === '') {
return undefined;
}
return `/api/check-username?username=${encodeURIComponent(usernameValue)}`;
},
onSuccess: (response: any) => {
if (response.taken) {
return [
{
kind: 'usernameTaken',
message: 'Username is already taken',
}
];
}
return []; // Username is available - return empty array for valid
},
onError: (error: unknown) => [
{
kind: 'networkError',
message: 'Could not verify username availability',
}
],
});
});
```
Architectuur van de dataflow
- Initialisatie:
personModel (signal) → form() → personForm(FieldTree met signals). - Gebruikersinvoer: Gebruiker typt →
[field]-directive→ veldsignal en model-signal worden bijgewerkt → UI volgt. - Validatie: Velden wijzigen → validators draaien → valid/invalid-signals updaten → template past zich aan.
- Verzenden: In onSubmit lees je eenvoudig
this.personModel(); je hebt direct je getypte, complete data, zonderform.valueof partiële waarden.
Vergelijking met traditionele formulieren
Bij Reactive Forms heb je FormGroup, FormControl, validators en template-binding met formControlName en *ngIf/@if voor errors.
Bij Signal Forms heb je een model-signal, form() met validators, en in de template [field] en @if/@for voor errors.
De verschillen:
- Minder boilerplate: geen verbose
FormGroup/FormControlcreaties. - Betere type safety: schema path is volledig getypt vanuit je model.
- Schonere templates:
[field]in plaats van[formGroup] + formControlName. - Duidelijkere errors: objecten met een message-property in plaats van string-keys.
- Directe modeltoegang: gewoon de signal uitlezen.
Voordelen en aandachtspunten
Voordelen:
- Type safety: TypeScript kent je formulierstructuur; fouten worden bij compile time gevonden.
- Minder boilerplate: Geen
FormControl/FormGroup-hiërarchie; alleen model en validators. - Betere prestaties: Fijnmazige reactiviteit en
OnPushzonder extra inspanning. - Consistente API: Alles via signals.
- Moderne Angular-patronen: Standalone components, nieuwe control flow.
- Leesbare, declaratieve code.
Aandachtspunten:
- Nieuwe API om te leren als je gewend bent aan Reactive Forms.
- Angular 21+ is vereist; niet voor elk project makkelijk haalbaar.
- Minder voorbeelden en libraries dan bij Reactive Forms.
- Bestaande reactive forms moet je herschrijven; geen automatische migratie.
- Voor zeer complexe formulieren (dynamische velden, form arrays) zijn patronen nog in ontwikkeling door de community.
Praktische implementatietips
- Modellen structureren: Houd formuliermodellen apart van domeinmodellen als de vorm verschilt.
- Validators centraliseren: Zet herbruikbare validators in een apart bestand (bijv.
validators.ts). - Fouten consistent tonen: Bijvoorbeeld een herbruikbare component voor veldfouten die
field().touched(), field().invalid() en field().errors()gebruikt. - Formulierverzending: In onSubmit altijd
event.preventDefault()en controleren oppersonForm().invalid(); daarna data uitpersonModel()halen en naar je API sturen.
Conclusie
Signal Forms zijn een duidelijke stap vooruit voor formulieren in Angular. Ze sluiten aan bij signals, standalone components en de nieuwe control flow en bieden een intuïtievere en krachtigere developer experience dan de eerdere opties. Template-driven forms waren te “magisch”, Reactive Forms te uitgebreid. Signal Forms zitten ertussenin: expliciet genoeg om goed testbaar en onderhoudbaar te zijn, en compact genoeg om niet in boilerplate te verdrinken.
Voor een nieuw Angular 21-project zou ik Signal Forms als standaardkeuze nemen. In bestaande apps kun je per formulier of feature bekijken of een migratie de moeite waard is.
Heb je vragen of wil je eens sparren over Angular? Neem gerust contact op.