Consider the last app you opened. More than likely you signed in, looked something up, or went through a checkout flow. Each of those screens sits on top of a form — which is why you'll run into one sooner in an Angular codebase than you'd expect.
The catch is that forms in Angular can be genuinely disorienting at first. One project's code shows [(ngModel)]. The next one is full of formControlName. A third throws some [formField] at you. And everywhere those cryptic invalid, touched or valueChanges keep surfacing, wrapped in configuration that looks anything but simple. Familiar feeling?
Take a breath — we'll untangle it piece by piece. This article covers which kinds of forms Angular offers, how they differ, how to wire them to HTML, and what all those states actually mean. We won't dig into every corner; the goal is that after reading, you can open a component containing a form and have at least a rough idea of what you're looking at.
Forms in Angular
On the surface, a form is just a <form> holding a few <input> elements. What more could it need?
As it turns out, quite a lot. We need to know what the user entered. We need to verify that the e-mail actually resembles an e-mail, and that the password meets length and complexity rules. We want to surface an error — but not the instant the page loads, only once the user has genuinely interacted with the field. Doing all of that by hand, subscribing to input or blur events on every single field, turns into a slog fast.
That's precisely the job Angular's forms take off our hands. The framework keeps track, on our behalf, of each field's value, whether it's valid, and whether the user has already interacted with it. What to do with that information is left to us.
What's interesting is that Angular doesn't offer a single way to build forms, but three:
| Approach | Where we define the form | Binding in HTML | Import |
|---|---|---|---|
| Template-driven forms | in the HTML template | [(ngModel)] | FormsModule from @angular/forms |
| Reactive forms | in the component class | [formGroup], formControlName | ReactiveFormsModule from @angular/forms |
| Signal forms | in the component class, based on a signal | [formField] | FormField from @angular/forms/signals |
Template-driven and reactive forms have been around for years, so you'll encounter them readily in existing codebases. Signal forms are the youngest member of the family — they arrived as an experimental feature in Angular 21, and by Angular 22 they had reached stable API status.
The good news: all three solve the same problem. Once you understand one approach, you'll recognize plenty of familiar concepts in the others. So let's begin with the simplest one.
Template-driven forms
Let's start with the approach many people find the most intuitive. Everything about fields and validation lives in HTML, while the component class only holds the data. Angular quietly handles the rest under the hood.
A simple login example will show it. To begin, we need FormsModule:
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-login',
imports: [FormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
email = '';
password = '';
onSubmit(): void {
console.log(this.email, this.password);
}
}
Now it's time to bind the fields to our properties. That's what [(ngModel)] is for:
<form (ngSubmit)="onSubmit()">
<input type="email" name="email" [(ngModel)]="email" required />
<input type="password" name="password" [(ngModel)]="password" required minlength="8" />
<button type="submit">Log in</button>
</form>
The [(ngModel)] notation is what's called two-way binding. The key point is that it works in both directions: when the user types into the field, the value flows into email in the component; when we change email in code, the field in the form updates too.
While we're here, a few small details are worth flagging, since they're easy to stumble over early on:
- the name attribute is mandatory — Angular identifies the field within the form by it.
- validation is added with regular HTML attributes — required, minlength, email or pattern look like standard HTML, but Angular treats them as its own validators.
- (ngSubmit) rather than (submit) — this way, submitting the form won't reload the page.
So when should you reach for template-driven forms? These days, the answer is: mostly when you encounter them in existing code. They're the oldest of the three approaches, dating back to when Angular used modules. They rely on FormsModule and the ngModel directive, offer no strong typing, and don't cooperate well with signals — the direction the whole framework is heading. Formally they haven't been marked deprecated, but new code is better off avoiding them, even for a small form. On top of that, all the logic sits in HTML, so as the form grows the template gets crowded and testing stops being enjoyable.
Reactive forms
Reactive forms flip the roles. Here it's the component class that defines what the form looks like, and the template merely connects to it. You could say HTML stops being the form's "brain" and becomes its "face".
If you work on a larger commercial project, chances are good you'll find reactive forms in it.
The whole thing rests on three basic building blocks:
- FormControl – a single field, e.g. e-mail,
- FormGroup – a group of fields, e.g. the entire login form,
- FormArray – a list of fields whose count can change, e.g. extra phone numbers added with an "Add" button.
Let's rewrite our login form using reactive forms:
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
readonly loginForm = new FormGroup({
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
password: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(8)] }),
});
onSubmit(): void {
console.log(this.loginForm.getRawValue());
}
}
Notice that the validators have moved from HTML into code — we now pass them as functions from the Validators class. And what does that mysterious nonNullable: true do? It makes the field return to its initial value, an empty string, after the form is reset, rather than turning into null. A small detail that saves a lot of grief.
The same form can be written more concisely with FormBuilder, a service that builds the controls for us:
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
private readonly fb = inject(FormBuilder);
readonly loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
}
What does this simplify? Compare the two versions:
- we don't write new FormGroup and new FormControl for every field — this.fb.group(…) is enough,
- each field becomes a short array [‘initial value’, [validators]] instead of an options object,
- fewer imports — FormGroup and FormControl are no longer needed.
The outcome is identical: underneath, a FormGroup with two FormControls is still created. It's simply a more convenient way to write it — something you'll appreciate especially in forms with a dozen or more fields.
All that remains is to tell the template which form it should handle:
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<input type="email" formControlName="email" />
<input type="password" formControlName="password" />
<button type="submit" [disabled]="loginForm.invalid">Log in</button>
</form>
Two things do all the work here:
- [formGroup] – tells the <form> element: "this is your FormGroup from the class",
- formControlName – connects a specific input to the field of the same name inside the group.
Instead of (ngSubmit) on the <form> element, we can also just call the method directly from the button:
<button type="button" (click)="onSubmit()" [disabled]="loginForm.invalid">Log in</button>
That works too, but it's worth knowing the difference. With (ngSubmit), the form is also submitted when Enter is pressed in any field, and Angular marks it as submitted. With (click) on the button, we handle only the click — so handling Enter becomes our responsibility.
And what if we need just one field, without the whole group? A search box, say? Then we can attach a FormControl directly via [formControl]:
<input type="search" [formControl]="searchControl" />
In the component class we then create only the control, with no group:
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-search',
imports: [ReactiveFormsModule],
template: `<input type="search" [formControl]="searchControl" />`,
})
export class SearchComponent {
readonly searchControl = new FormControl('');
}
Such a standalone control has everything a field inside a group has: a value, validators and states.
Form field states
Earlier we said Angular "keeps an eye on" every field. What does that mean in practice?
Every field — and the form as a whole — carries its own state. Think of it as a short note Angular keeps for each control: "is the entered value valid?", "has the user been here yet?", "did they change anything?". The note updates itself, and we can inspect it at any moment.
States usually come in pairs:
| State | Opposite | What it means |
|---|---|---|
| valid | invalid | whether the value passes all validators |
| touched | untouched | whether the user entered the field and left it (focus, then blur) |
| dirty | pristine | whether the user changed the field’s value |
| pending | – | whether asynchronous validation is in progress, e.g. checking on the server whether a username is available |
| disabled | enabled | whether the field is disabled; such a field does not take part in validation |
Beyond those there's errors, the list of "charges" against the field. It's an object telling us which validators failed, e.g. { required: true } or { minlength: { requiredLength: 8, actualLength: 3 } }. When everything is fine, errors is null.
The pair that usually causes the most confusion is touched and dirty. Picture the user clicking into a field, changing their mind, and moving on without typing anything. That field is already touched, because someone was in it, yet still pristine, because nobody changed anything.
Now the reverse: the user types something, later deletes all of it, and the field is empty again. It looks exactly as it did at the start, right? And yet it's already dirty. Angular doesn't compare the current value with the initial one — it simply remembers that the user modified the field. Once a field has been "dirtied", you can't "clean" it again just by deleting the text. Only something like reset() returns it to the pristine state.
What's the point of all this? Most often, showing errors at the right moment. Imagine a form that, the moment the page opens, shouts in red "this field is required!" before you've had any chance to act. Not exactly pleasant. That's why we display errors only when the field is invalid and touched at the same time:
@let email = loginForm.controls.email;
<input type="email" formControlName="email" />
@if (email.invalid && email.touched) {
@if (email.hasError('required')) {
<p class="error">Enter an e-mail address.</p>
}
@if (email.hasError('email')) {
<p class="error">This doesn't look like a valid e-mail address.</p>
}
}
And how do we reach the state in template-driven forms, where there's no FormControl in the class? A template variable suffices:
<input type="email" name="email" [(ngModel)]="email" required #emailField="ngModel" />
@if (emailField.invalid && emailField.touched) {
<p class="error">Enter an e-mail address.</p>
}
Finally, a small bonus. Angular itself attaches CSS classes matching the states to fields: ng-valid / ng-invalid, ng-touched / ng-untouched, ng-dirty / ng-pristine and ng-pending. With that, simple highlighting of invalid fields can be done in CSS alone, without a single line of TypeScript:
input.ng-invalid.ng-touched {
border-color: red;
}
valueChanges and statusChanges
Since we're on the subject of reactive forms, we can't skip what gave them their name. Every field and every group exposes two streams (Observables) that keep us informed about what's going on:
- valueChanges – notifies us every time the value changes,
- statusChanges – notifies us when the validation status changes: ‘VALID’, ‘INVALID’, ‘PENDING’ or ‘DISABLED’.
It's a bit like subscribing to notifications. Instead of repeatedly checking whether a field has changed, we tell Angular: "let me know when something happens".
Where does this help? The classic example is a search box. We want results to appear live, but we'd rather not hammer the server with a request after every letter typed. Typing the word "angular" would mean as many as seven requests! valueChanges comes to the rescue, paired with a few RxJS operators:
readonly searchControl = new FormControl('', { nonNullable: true });
constructor() {
this.searchControl.valueChanges
.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntilDestroyed(),
)
.subscribe(phrase => this.search(phrase));
}
What's going on here?
- debounceTime(300) – we wait 300 ms after the user's last action before performing the search, i.e. e.g. sending a request to the server,
- distinctUntilChanged() – we ignore situations in which the value hasn't actually changed,
- takeUntilDestroyed() – we end the subscription when the component disappears. Without it, the subscription would keep living its own life and could lead to memory leaks.
For the record: template-driven forms have valueChanges too (e.g. via @ViewChild on NgModel), but in practice hardly anyone uses it. Usually the (ngModelChange) event in the template is more than enough.
Signal forms
Anyone tracking recent Angular releases knows that signals now show up everywhere: in component state, in inputs, in HTTP requests. Forms, though, sat out that shift for quite a while. That is, until now.
Signal forms are the newest way to handle forms, and they are built on signals end to end. They do not replace reactive or template-driven forms — they add a third option that pairs naturally with modern components.
We begin by declaring an ordinary signal holding the data the form should manage. Only after that do we “wrap” it with the form() function:
import { Component, signal } from '@angular/core';
import { form, FormField, required, email, minLength } from '@angular/forms/signals';
interface LoginData {
email: string;
password: string;
}
@Component({
selector: 'app-login',
imports: [FormField],
templateUrl: './login.component.html',
})
export class LoginComponent {
readonly loginModel = signal<LoginData>({ email: '', password: '' });
readonly loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email, { message: 'Enter an e-mail address.' });
email(schemaPath.email, { message: 'This doesn\'t look like a valid e-mail address.' });
required(schemaPath.password, { message: 'Enter a password.' });
minLength(schemaPath.password, 8, { message: 'The password must be at least 8 characters long.' });
});
onSubmit(event: Event): void {
event.preventDefault();
console.log(this.loginModel());
}
}
The second argument passed to form() is the schema — the single place where all validation rules live. A handy extra: the error message can sit right beside its validator. Note also that everything is imported from a separate package, @angular/forms/signals.
Where does schemaPath come from? The schema doesn’t touch real form fields; it merely describes the rules ahead of time, before the form exists. So schemaPath.email is the field’s “address”, telling us which field a given rule applies to. The control itself and its state become available later, through loginForm.email().
The { message: … } entry is optional. Leave it out and validation behaves the same — the error will simply carry only its kind, e.g. ‘required’.
In the template, every field is wired up with the [formField] directive:
<form (submit)="onSubmit($event)">
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().invalid() && loginForm.email().touched()) {
@for (error of loginForm.email().errors(); track error.kind) {
<p class="error">{{ error.message }}</p>
}
}
<input type="password" [formField]="loginForm.password" />
<button type="submit" [disabled]="loginForm().invalid()">Log in</button>
</form>
Look closely at the <form> element. There is no (ngSubmit) here, unlike in the earlier examples — that event belongs to FormsModule and ReactiveFormsModule, and with signal forms neither module is imported. Instead we rely on the plain native (submit) event. One catch: by default the browser would reload the page after that event, which is exactly why onSubmit calls event.preventDefault() to stop it.
Recognize some of these? The invalid, touched and dirty states are back — except they are now signals. A field’s state is read by calling it like a function: loginForm.email() returns the state object, from which we read invalid(), touched(), dirty(), errors() or value(). The state of the entire form is accessed the same way, via loginForm().
If reactive forms are already familiar, two differences are worth noting:
- errors() is an array, not an object. Each error carries its kind and message, so rendering all of them is just a simple @for loop.
- There is no valueChanges — and that’s deliberate! The form’s value is itself a signal, so changes are handled like with any other signal.
readonly canSubmit = computed(() =>
this.loginForm().valid() && this.loginModel().email.endsWith('@firma.pl')
);
Synchronization runs in both directions. The user types into a field and loginModel is updated. We change loginModel in code, e.g. through loginModel.set(…), and the form immediately reflects the new values. No manual copying of data either way.
One last caution. Signal forms debuted in Angular 21 as an experiment, and their API shifted somewhat across the following versions. For instance, the [field] directive from version 21.0 is called [formField] today. So if an older tutorial you find online has something that “refuses to work”, check which Angular version it was written for first.
To dig deeper into them, I recommend the article by our GDE Mateusz.
Choosing the right approach
Since this question comes up very often, let’s address it head-on.
Following modern solutions — currently signal forms — is best, provided business requirements permit it. At the same time, be careful not to sow confusion in existing code. In a project built on reactive forms for years, dropping signal forms into one random spot will more likely make life harder for the team than help. Switching the form approach is a team-wide decision, not one for a single component.
Wrapping up
We began from zero — no prior knowledge of Angular forms. By now you know they exist, what they’re for, and which varieties there are. They may look complicated at first glance, but in practice they make our lives considerably easier: they track field values, check validity and tell us what the user did — all without us handling any of it by hand. The fact that Angular keeps evolving them, from template-driven through reactive to signal forms, shows better than anything how important a part of applications they are and that they’re worth improving.
Let’s collect the key concepts in one place:
- Template-driven forms – a form described in HTML, fields bound with [(ngModel)], validation via attributes.
- Reactive forms – a form built in the class from FormControl and FormGroup, connected in HTML via [formGroup] and formControlName.
- Signal forms – the model as a signal, the form created with the form() function, connected in HTML via [formField].
- States – valid/invalid, touched/untouched, dirty/pristine and errors tell us what’s happening with a field.
- valueChanges – a stream of value changes in reactive forms; in signal forms, computed() and effect() take over that role.
I hope this guide was useful. I also recommend an additional article on typed forms by Dominik. Good luck!
