โ† Back to list

Angular Forms โ€” Validation and Form State (Part 3) ๐Ÿ”ฅ๐Ÿš€

In Part 1 and Part 2, we covered model setup, UI binding, and data flow across Template-driven, Reactive, and Signal Forms.

Giorgio Galassi ยท 2026-02-17 18:45 ยท 6 claps ยท 4.9 min read
#angular #angular-forms #reactive-forms #frontend-development #web-development
Open on Medium โ†—
Wiki topics: ๐ŸŒ ยท Web Development

Photo by Franck on Unsplash

Photo by Franck on Unsplash

Angular Forms โ€” Validation and Form State (Part 3) ๐Ÿ”ฅ๐Ÿš€

In Part 1 and Part 2, we covered model setup, UI binding, and data flow across Template-driven, Reactive, and Signal Forms.

In this part, we focus on what makes forms production-ready: validation and field state management.

To keep examples comparable, we use the same small domain throughout: a login form with email and password.

โš ๏ธ Signal Forms are experimental The API may evolve in future Angular releases. For long-term stability and mature tooling, Reactive Forms remain the most robust and battle-tested choice today.

๐Ÿงฑ Baseline: the same form in 3 approaches

The goal is not to crown a winner, but to make the differences explicit so you can choose or migrate with confidence.

Template-driven

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-login-template',
  imports: [FormsModule],
  template: `
    <form #f="ngForm" (ngSubmit)="submit()">
      <label>
        Email
        <input name="email" [(ngModel)]="model.email" />
      </label>
      <label>
        Password
        <input
          name="password"
          type="password"
          [(ngModel)]="model.password"
        />
      </label>
      <button type="submit">Login</button>
    </form>
  `
})
export class LoginTemplateComponent {
  model = { email: '', password: '' };

  submit(): void {
    console.log('submit', this.model);
  }
}

Reactive

import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-login-reactive',
  imports: [ReactiveFormsModule],
  template: `
    <form (ngSubmit)="submit()">
      <label>
        Email
        <input [formControl]="email" />
      </label>
      <label>
        Password
        <input type="password" [formControl]="password" />
      </label>
      <button type="submit">Login</button>
    </form>
  `
})
export class LoginReactiveComponent {
  email = new FormControl('', { nonNullable: true });
  password = new FormControl('', { nonNullable: true });

  submit(): void {
    console.log('submit', 
      { 
        email: this.email.value, 
        password: this.password.value 
      }
    );
  }
}

Signal Forms

import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';

@Component({
  selector: 'app-login-signal',
  imports: [FormField],
  template: `
    <form (ngSubmit)="submit()">
      <label>
        Email
        <input [formField]="loginForm.email" />
      </label>
      <label>
        Password
        <input type="password" [formField]="loginForm.password" />
      </label>
      <button type="submit">Login</button>
    </form>
  `
})
export class LoginSignalComponent {
  model = signal({ email: '', password: '' });
  loginForm = form(this.model);

  submit(): void {
    console.log('submit', this.model());
  }
}

๐Ÿ›ก๏ธ Built-in validators

Angular ships with familiar validators such as required, email, minLength, and pattern. What changes across approaches is where you declare them and how you read errors.

Template-driven: validators in the template

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-email-template',
  imports: [FormsModule],
  template: `
    <form #f="ngForm">
      <input
        name="email"
        [(ngModel)]="email"
        required
        email
        #emailModel="ngModel"
      />
      @if (emailModel.invalid && emailModel.touched) {
        <p>
          @if (emailModel.errors?.['required']) { <span>Email is required.</span> }
          @if (emailModel.errors?.['email']) { <span>Invalid email format.</span> }
        </p>
      }
      <button type="button" [disabled]="f.invalid">Continue</button>
    </form>
  `
})
export class EmailTemplateComponent {
  email = '';
}

State is exposed via template references like #emailModel="ngModel".

Reactive: validators in the model

import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-email-reactive',
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="email" />
    @if (email.invalid && email.touched) {
      <p>
        @if (email.hasError('required')) { <span>Email is required.</span> }
        @if (email.hasError('email')) { <span>Invalid email format.</span> }
      </p>
    }
  `
})
export class EmailReactiveComponent {
  email = new FormControl('', {
    nonNullable: true,
    validators: [Validators.required, Validators.email]
  });
}

Here, validation logic and state live in the FormControl API.

Signal Forms: reactive validation + state

Signal Forms expose state via signals such as valid(), invalid(), touched(), dirty(), pending(), and errors().

import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';

@Component({
  selector: 'app-email-signal',
  imports: [FormField],
  template: `
    <input [formField]="f.email" />
    @if (f.email().invalid() && f.email().touched()) {
      <p>
        @if (f.email().errors()?.['required']) { <span>Email is required.</span> }
        @if (f.email().errors()?.['email']) { <span>Invalid email format.</span> }
      </p>
    }
  `
})
export class EmailSignalComponent {
  model = signal({ email: '' });

  f = form(this.model, (path) => {
    path.email.addValidators(['required', 'email']);
  });
}

๐Ÿ’ก Schema-first validation with Zod If you prefer framework-agnostic validation, you can plug a Zod schema into Signal Forms and use it as your single source of truth. I covered this approach in detail in my article on Zod + Angular Signal Forms, where validation works consistently across forms, HTTP, and runtime boundaries.

[embed]Angular v21 โ€” Runtime Validation with Zod: Schemas, Signals, and Boundaries ๐Ÿ”ฅ๐Ÿš€ Angular v21 + Zod: schema-first runtime validation. Integrate Signals, forms, and HTTP boundaries for safer data andโ€ฆblog.stackademic.com

Centralizing validation logic at the schema level makes migrations significantly easier.

๐Ÿงญ Form state patterns that scale

In real applications, showing errors at the right time is just as important as defining them.

A practical strategy:

  • show field errors only after the user interacts (touched)
  • show form-level errors after submit
  • disable submit when the form is invalid (and pending, when applicable)

Letโ€™s see how this looks in practice.

Template-driven

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-state-template',
  imports: [FormsModule],
  template: `
    <form #f="ngForm" (ngSubmit)="submitted = true">
      <input
        name="email"
        [(ngModel)]="email"
        required
        email
        #m="ngModel"
      />

      @if ((m.touched || submitted) && m.invalid) {
        <p>Invalid email.</p>
      }

      <button type="submit" [disabled]="f.invalid">Submit</button>
    </form>
  `
})
export class StateTemplateComponent {
  email = '';
  submitted = false;
}

Here, you often need extra flags like submitted to control UX timing.

Reactive

import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-state-reactive',
  imports: [ReactiveFormsModule],
  template: `
    <form (ngSubmit)="submitted = true">
      <input [formControl]="email" />

      @if ((email.touched || submitted) && email.invalid) {
        <p>Invalid email.</p>
      }

      <button type="submit" [disabled]="email.invalid">Submit</button>
    </form>
  `
})
export class StateReactiveComponent {
  email = new FormControl('', {
    nonNullable: true,
    validators: [Validators.required, Validators.email]
  });

  submitted = false;
}

State is fully available through the control instance. No template references required.

Signal Forms

import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';

@Component({
  selector: 'app-state-signal',
  imports: [FormField],
  template: `
    <form (ngSubmit)="submitted.set(true)">
      <input [formField]="f.email" />

      @if ((f.email().touched() || submitted()) && f.email().invalid()) {
        <p>Invalid email.</p>
      }

      <button type="submit" [disabled]="f.invalid()">Submit</button>
    </form>
  `
})
export class StateSignalComponent {
  model = signal({ email: '' });
  submitted = signal(false);

  f = form(this.model, (path) => {
    path.email.addValidators(['required', 'email']);
  });
}

Because state itself is signal-based, UI conditions stay fully reactive without extra subscriptions.

The conceptual pattern is the same across all approaches. What changes is how explicit and centralized the state management feels.

๐Ÿง  Architectural Comparison (Quick Take)

At this stage, the differences become clearer.

Template-driven forms are concise and approachable, but state and validation logic tend to spread across the template. As complexity grows, this can reduce clarity.

Reactive forms centralize validation and state inside the component class. This makes behavior predictable, easier to test, and more scalable for large forms.

Signal Forms aim to reduce boilerplate while keeping reactivity explicit. State becomes first-class and declarative, but the API is still evolving.

If you are building simple forms, Template-driven works. If you are building complex flows with strong validation needs, Reactive remains the safest bet. If you want modern reactivity with less ceremony and are comfortable with experimental APIs, Signal Forms are promising.

You can explore all the examples shown in this article in the associated โšกStackBlitz project. It includes the three implementations side by side so you can compare behavior and state handling in real time.

๐Ÿš€ Whatโ€™s Next

In Part 4, we will dive into advanced validation patterns: custom validators, cross-field rules, and async validation strategies.

If you found this helpful, follow me here and on **LinkedIn** for more deep dives into Angular, web performance, and modern frontend development.

See you in the next one! ๐Ÿค™๐Ÿป โ€” G.


๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
post_id
0f2db5b4961e
slug
angular-forms-validation-and-form-state-part-3-0f2db5b4961e
url
https://medium.com/@giorgio.galassi/angular-forms-validation-and-form-state-part-3-0f2db5b4961e
canonical_url
https://medium.com/@giorgio.galassi/angular-forms-validation-and-form-state-part-3-0f2db5b4961e
author_url
https://medium.com/@giorgio.galassi
status
ok
fetched_at
2026-07-12 00:07:18