Working with Forms & User InputEntry 8 of 10
Custom Validation: On Keystroke, On Blur, On Submit
Checking values in your own code and showing your own error messages, at a moment you choose.
Notes
| Timing | How (React) | Pros | Cons |
|---|---|---|---|
| On keystroke | Derive the error from controlled state | Instant feedback | Shows errors before the user finishes typing |
| On blur | Track didEdit per field with onBlur | Waits until the user leaves the field | Error stays until the next blur unless combined |
| On submit | Check values in handleSubmit | Simple; works uncontrolled | Feedback only at the end |
The common combination: show an error once the field was blurred, then keep it live while typing until it's fixed, and always check again on submit.
- Derive the error during render (
const emailIsInvalid = …), don't store it in its own state. - Reset
didEditfor a field when the user starts typing again, if you only want errors after blur.
What / Why / When
- What
App-defined rules and messages.
- Why
Consistent, styled messages and rules the browser can't express (password match, availability).
- When
Any form where the default browser popups aren't good enough.
- Use case
Email must contain
@, password at least 6 characters, errors shown after blur.
Code compare
import { useState, type ChangeEvent, type FocusEvent, type FormEvent } from "react";​type Values = { email: string; password: string };type Touched = Record<keyof Values, boolean>;​export default function Login() { const [values, setValues] = useState<Values>({ email: "", password: "" }); const [didEdit, setDidEdit] = useState<Touched>({ email: false, password: false });​ // Derived on every render: no extra state const emailIsInvalid = didEdit.email && !values.email.includes("@"); const passwordIsInvalid = didEdit.password && values.password.trim().length < 6;​ function handleInputChange(event: ChangeEvent<HTMLInputElement>) { const { name, value } = event.target; setValues((prev) => ({ ...prev, [name]: value })); }​ function handleInputBlur(event: FocusEvent<HTMLInputElement>) { const { name } = event.target; setDidEdit((prev) => ({ ...prev, [name]: true })); }​ function handleSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setDidEdit({ email: true, password: true }); // show every error on submit​ if (!values.email.includes("@") || values.password.trim().length < 6) return; console.log("Sending", values); }​ return ( <form onSubmit={handleSubmit} noValidate> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" value={values.email} onChange={handleInputChange} onBlur={handleInputBlur} /> {emailIsInvalid && <p className="control-error">Please enter a valid email address.</p>}​ <label htmlFor="password">Password</label> <input id="password" name="password" type="password" value={values.password} onChange={handleInputChange} onBlur={handleInputBlur} /> {passwordIsInvalid && ( <p className="control-error">Password must be at least 6 characters.</p> )}​ <button type="submit">Login</button> </form> );}noValidate turns off the browser popups so only your messages appear.
Angular tracks the timing for you on every control: touched (blurred at least once), dirty (changed), and invalid. Showing an error "after blur, then live" is one condition.
@Component({ selector: "app-login", imports: [ReactiveFormsModule], template: ` <form [formGroup]="form" (ngSubmit)="onSubmit()"> <label for="email">Email</label> <input id="email" type="email" formControlName="email" /> @if (form.controls.email.touched && form.controls.email.invalid) { <p class="control-error">Please enter a valid email address.</p> }​ <label for="password">Password</label> <input id="password" type="password" formControlName="password" /> @if (form.controls.password.touched && form.controls.password.hasError("minlength")) { <p class="control-error">Password must be at least 6 characters.</p> }​ <button type="submit">Login</button> </form> `,})export class LoginComponent { private authService = inject(AuthService);​ form = new FormGroup({ email: new FormControl("", { nonNullable: true, validators: [Validators.required, Validators.email], }), password: new FormControl("", { nonNullable: true, validators: [Validators.required, Validators.minLength(6)], }), });​ onSubmit() { if (this.form.invalid) { this.form.markAllAsTouched(); // show every error on submit return; } this.authService.login(this.form.getRawValue()).subscribe(); }}Custom rules are plain functions. A cross-field rule goes on the group:
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";​export function mustContainQuestionMark(control: AbstractControl): ValidationErrors | null { return control.value.includes("?") ? null : { noQuestionMark: true };}​export function equalValues(a: string, b: string): ValidatorFn { return (group) => (group.get(a)?.value === group.get(b)?.value ? null : { valuesNotEqual: true });}​// Usagepasswords = new FormGroup( { password: new FormControl("", { nonNullable: true }), confirmPassword: new FormControl("", { nonNullable: true }), }, { validators: [equalValues("password", "confirmPassword")] },);To validate only on blur instead of every keystroke: new FormControl('', { updateOn: 'blur' }).
Differences and gotchas
- React has no built-in
touched/dirtytracking; you build it with state (or a library). Angular has it on every control. - In React, validate again in
handleSubmit:didEditmay befalsefor fields the user never touched. - Async rules (e.g. "email already taken") are
asyncValidatorsin Angular; in React, run them in the submit handler or a debounced effect.
Quick reference
| Need | React | Angular |
|---|---|---|
| Error after blur | didEdit state + onBlur | control.touched |
| Live error | Derive from state in render | control.invalid (updates per keystroke) |
| Validate only on blur | Check in onBlur | updateOn: 'blur' |
| Show all errors on submit | Set every didEdit to true | form.markAllAsTouched() |
| Custom rule | Function returning boolean | ValidatorFn returning ValidationErrors | null |
| Cross-field rule | Compare in render/submit | Group validator |
| Disable browser popups | noValidate | Default (novalidate added) |