19 Oct 2026
12 min

Forms for Beginners – Angular

Think for a moment about the last app you used. You probably logged in somewhere, searched for something, or filled in your order details. Behind each of those screens there is a form – and that’s why in an Angular project you’ll come across one sooner than you expect.

The problem is that at first, forms in Angular can really mess with your head. You look at the code of one project and see [(ngModel)]. You move to another – and there’s formControlName everywhere. In yet another, some [formField] shows up. On top of that, the mysterious invalid, touched or valueChanges keep popping up everywhere, along with complicated configuration. Sound familiar?

Don’t worry, let’s take it one step at a time. In this article we’ll untangle this knot: we’ll look at what kinds of forms Angular gives us, how they differ from each other, how to connect them to HTML, and what all these states mean. We won’t look into every nook and cranny – the point is that after reading this article you can open a component with a form and at least roughly know what you’re looking at.

Forms in Angular

At first glance, a form is simply a <form> with a few <input> elements inside. What else could you need?

It turns out, quite a lot. We want to know what the user typed. We want to check whether the e-mail looks like an e-mail and whether the password has the right length and complexity. We want to show an error – but not right after the page opens, only when the user has actually changed something in the field. Doing this by hand, listening to input or blur events on every field, would quickly turn into an ordeal.

And that’s exactly what forms in Angular take care of. The framework tracks, on our behalf, the value of each field, its validity, and whether the user has already done something with it. We can focus on what to do with that information.

Interestingly, Angular doesn’t give us one 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 with us for years, so you’ll easily come across them in existing projects. Signal forms are the youngest child in the family – they appeared as an experiment in Angular 21, and in Angular 22 they already received stable API status.

The good news is that all three solve the same problem. Once you understand one approach, you’ll find many familiar concepts in the others. So let’s start with the simplest one.

Template-driven forms

Let’s start with the approach that many people find the most natural. Everything related to fields and validation is described in HTML, and the component class only holds the data. Angular takes care of the rest by itself, quietly, under the hood.

Let’s see it with a simple login example. To start, 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);
  }
}

Time to connect 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 so-called two-way binding. Most importantly, this binding works in both directions: when the user types something into the field, the value goes to email in the component, and when we change email in the code, the field in the form is updated as well.

While we’re at it, it’s worth noting a few small details that are easy to trip over at the beginning:

  • the name attribute is mandatory – it’s by this attribute that Angular recognizes the field in the form.
  • we add validation with regular HTML attributes – required, minlength, email or pattern look like standard HTML, but Angular treats them as its own validators.
  • (ngSubmit) instead of (submit) – thanks to this, submitting the form won’t reload the page.

So when should you reach for template-driven forms? Today the answer is: rather only when you come across them in existing code. It’s the oldest of the three approaches, dating back to the times when Angular used modules. It’s based on FormsModule and the ngModel directive, doesn’t provide strong typing, and doesn’t get along well with signals, which the whole framework is heading towards today. Formally, they haven’t been marked as deprecated, but in new code it’s better not to reach for them – even for a small form. On top of that, all the logic sits in HTML, so as the form grows, the template becomes crowded and testing stops being a pleasure.

Reactive forms

In reactive forms we reverse the roles. This time it’s the component class that decides what the form looks like, and the template merely connects to it. You could say that HTML stops being the “brain” of the form and becomes its “face”.

If you work on a larger, commercial project, there’s a good chance you’ll come across reactive forms in it.

The whole thing consists of 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 number can change, e.g. subsequent 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 to code – now we pass them as functions from the Validators class. And what does the mysterious nonNullable: true do? It makes the field return to its initial value, i.e. an empty string, after the form is reset, instead of turning into null. A small detail that saves a lot of nerves.

We can write the same form more concisely using FormBuilder, a service that creates 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 for us? Compare both versions:

  • we don’t write new FormGroup and new FormControl for every field – this.fb.group(…) is enough,
  • each field is a short array [‘initial value’, [validators]] instead of an object with options,
  • fewer imports – we no longer need FormGroup and FormControl.

The result is exactly the same: under the hood, a FormGroup with two FormControls is still created. It’s just a more convenient way of writing it, which you’ll appreciate especially in forms with a dozen or more fields.

All that’s left 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 with the field of the same name inside the group.

Instead of (ngSubmit) on the <form> element, we can also simply call the method directly from the button:

<button type="button" (click)="onSubmit()" [disabled]="loginForm.invalid">Log in</button>

This will also work, but it’s worth knowing the difference. With (ngSubmit), the form will also be submitted when Enter is pressed in any field, and Angular will mark it as submitted. With (click) on the button, we handle only the click, so we have to remember about Enter ourselves.

And what if we only need one field, without the whole group? For example, just a search box? Then we can connect a FormControl directly via [formControl]:

<input type="search" [formControl]="searchControl" />

In the component class, we then create just the control, without any 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 single control has everything a field in a group has: a value, validators and states.

Form field states

We mentioned at the beginning that Angular “keeps an eye on” every field. What does that actually mean?

Every field – and the whole form too – has its own state. You can think of it as a short note that Angular keeps for each control: “is the entered value valid?”, “has the user been here already?”, “have they changed anything?”. This note updates itself, and we can look into 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

On top of that there’s errors, i.e. 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. Imagine that the user clicks into a field, changes their mind and moves on without typing anything. Such a field is already touched, because someone was in it, but still pristine, because nobody changed anything.

And now the opposite situation: the user types something, after a while deletes everything, and the field is empty again. It looks exactly the same as at the beginning, right? And yet it is already dirty. Angular doesn’t compare the current value with the initial one – it simply remembers that the user changed something in the field. Once a field has been “dirtied”, you can’t “clean” it again just by deleting the text. It’s only returned to the pristine state by, e.g., reset().

What do we need all this for? Most often, to show errors at the right moment. Imagine a form that, right after the page opens, shouts in red “this field is required!” before you’ve had a chance to do anything. Not very pleasant, right? That’s why we show 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 get to the state in template-driven forms, since we don’t have a FormControl in the class there? A template variable is enough:

<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 adds CSS classes corresponding to their states to the fields: ng-valid / ng-invalid, ng-touched / ng-untouched, ng-dirty / ng-pristine and ng-pending. Thanks to this, you can do simple highlighting of invalid fields in CSS alone, without a single line of TypeScript:

input.ng-invalid.ng-touched {
  border-color: red;
}

valueChanges and statusChanges

Since we’re talking about reactive forms, we can’t skip what they got their name from. Every field and every group exposes two streams (Observables) that keep us informed about what’s happening:

  • 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 a notification subscription. Instead of checking the field every now and then to see whether something has changed, we tell Angular: “let me know when something happens”.

Where is this useful? The classic example is a search box. We want to show results live, but we’d rather not flood 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 the situation in which the value hasn’t actually changed,
  • takeUntilDestroyed() – we end the subscription when the component disappears. Without this, the subscription would go on living its own life and could lead to memory leaks.

For the record: template-driven forms also have valueChanges (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

If you follow the latest versions of Angular, you know that signals are everywhere today: in component state, in inputs, in HTTP requests. For a long time, however, forms stayed a bit on the sidelines of this revolution. Until now.

Signal forms are the newest approach to forms, built from start to finish on signals. They don’t throw reactive or template-driven forms into the bin – they’re simply a third option that fits modern components perfectly.

First, we define a regular signal object with the data the form is supposed to hold. Only then 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 of the form() function is the so-called schema – the place where we gather all validation rules in one spot. A nice bonus: we can define the error message right next to the validator. It’s also worth noticing that we import everything from a separate package, @angular/forms/signals.

Where does schemaPath come from? The schema doesn’t operate on actual form fields, it only describes the rules upfront, before the form even exists. So schemaPath.email is the “address” of the field – we use it to indicate which field a given rule should apply to. We’ll get the control itself and its state later, via loginForm.email().

The message in { message: … } is optional. If we omit it, validation will work the same way, and the error will only have its kind, e.g. ‘required’.

In the template, we connect each field 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>

Take a look at the <form> element. You won’t find the (ngSubmit) known from the previous examples here – that event comes from FormsModule and ReactiveFormsModule, and in signal forms we don’t import either of those modules. So we use the regular, native (submit) event. There’s just one catch: after such an event, the browser would by default reload the page. That’s exactly why in the onSubmit method we call event.preventDefault(), which blocks that behavior.

See some familiar faces? The invalid, touched and dirty states are back – except now they’re signals. We read a field’s state by calling it like a function: loginForm.email() returns the state object, and from it we read invalid(), touched(), dirty(), errors() or value(). We get the state of the whole form the same way, via loginForm().

If you already know reactive forms, note two differences:

  • errors() is an array, not an object. Each error has its kind and message, so displaying all errors is a simple @for loop.
  • There’s no valueChanges. And that’s not an oversight! The form’s value is itself a signal, so we react to changes just like with any other signal.
readonly canSubmit = computed(() =>
  this.loginForm().valid() && this.loginModel().email.endsWith('@firma.pl')
);

Synchronization works in both directions here. The user types something into a field – loginModel gets updated. We change loginModel in code, e.g. via loginModel.set(…) – the form immediately shows the new values. No manual copying of data back and forth.

Finally, a small word of caution. Signal forms debuted in Angular 21 as an experiment, and over the following versions their API changed a bit. For example, the [field] directive from version 21.0 is called [formField] today. So if you come across an older tutorial online in which something “doesn’t want to work”, first check which Angular version it comes from.

If you want to learn more about them, I recommend the article by our GDE Mateusz.

Which approach to choose?

This question comes up very often, so let’s answer it directly.

It’s best to follow modern solutions – today that’s signal forms – as long as business requirements allow it. At the same time, you have to be careful not to introduce confusion into code that already exists. If a project has been built on reactive forms for years, adding signal forms in one random place will rather make life harder for the team than help. Changing the approach to forms is a decision for the whole team, not for a single component.

Summary

We started from scratch – without any knowledge of forms in Angular. Now you know that they exist at all, what they’re for, and what varieties they come in. Although at first glance they may seem complicated, in practice they make our lives much easier: they keep track of field values, check their validity and tell us what the user did – and we don’t have to do all of this by hand. The fact that Angular keeps developing them, from template-driven, through reactive, all the way to signal forms, best shows how important a part of applications they are and that they’re worth improving.

Let’s gather the most important concepts in one place:

  • Template-driven forms – a form described in HTML, fields bound via [(ngModel)], validation with 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 its role is taken over by computed() and effect().

I hope this guide was helpful to you. I also recommend an additional article on typed forms by Dominik. Good luck!

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.