Build forms that validate themselves — template-driven forms with ngModel, typed reactive forms with built-in validators, and custom validators of your own.
Why: for one or two fields, [(ngModel)] keeps an input and a signal in sync in both directions — type and the signal updates, set the signal and the input updates. Import FormsModule to enable it. Note: (ngSubmit) replaces the browser's full-page reload; inputs inside a form need a name attribute.
import { Component, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
@Component({
selector: 'app-signup',
imports: [FormsModule], // enables ngModel
template: `
@if (submitted()) {
<!-- After submit, greet the user by the part before the @ -->
<p>Welcome, {{ email().split('@')[0] }}</p>
} @else {
<form (ngSubmit)="submitted.set(true)">
<!-- [(ngModel)] = two-way sync between input and signal -->
<input
type="email"
name="email"
[(ngModel)]="email"
placeholder="you@example.com"
/>
<button [disabled]="!email().includes('@')">Sign up</button>
</form>
}
`,
})
export class Signup {
email = signal('')
submitted = signal(false)
}Why: for real forms, reactive forms put the shape, defaults, and rules in one typed object in the class — the template just wires inputs to it by name. The form tracks validity, touched state, and errors for you; the submit button stays disabled until everything passes.
import { Component, inject } from '@angular/core'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" placeholder="Email" />
@if (form.controls.email.invalid && form.controls.email.touched) {
<p>Enter a valid email</p>
}
<input formControlName="password" type="password" placeholder="Password" />
@if (form.controls.password.invalid && form.controls.password.touched) {
<p>At least 8 characters</p>
}
<button [disabled]="form.invalid">Log in</button>
</form>
`,
})
export class Login {
private fb = inject(NonNullableFormBuilder)
// The form's shape, defaults, and rules — all in one typed object
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
})
onSubmit() {
if (this.form.valid) console.log(this.form.getRawValue())
}
}Why: when the built-in rules run out, a validator is just a function — control in, error object (or null for valid) out. Drop it into the validators array next to the built-in ones; the error key tells the template which message to show.
import { AbstractControl, ValidationErrors } from '@angular/forms'
// A validator is just a function: control in, errors (or null) out
export function strongPassword(
control: AbstractControl,
): ValidationErrors | null {
const value: string = control.value ?? ''
const ok = /[A-Z]/.test(value) && /[0-9]/.test(value)
// null = valid; the key names the error for the template
return ok ? null : { strongPassword: true }
}
// Use it next to the built-in ones:
// password: ['', [Validators.required, strongPassword]]
//
// And show its message in the template:
// @if (form.controls.password.hasError('strongPassword')) {
// <p>Needs an uppercase letter and a number</p>
// }