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

TimingHow (React)ProsCons
On keystrokeDerive the error from controlled stateInstant feedbackShows errors before the user finishes typing
On blurTrack didEdit per field with onBlurWaits until the user leaves the fieldError stays until the next blur unless combined
On submitCheck values in handleSubmitSimple; works uncontrolledFeedback 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 didEdit for 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

React
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.

Angular
@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:

Angular
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/dirty tracking; you build it with state (or a library). Angular has it on every control.
  • In React, validate again in handleSubmit: didEdit may be false for fields the user never touched.
  • Async rules (e.g. "email already taken") are asyncValidators in Angular; in React, run them in the submit handler or a debounced effect.

Quick reference

NeedReactAngular
Error after blurdidEdit state + onBlurcontrol.touched
Live errorDerive from state in rendercontrol.invalid (updates per keystroke)
Validate only on blurCheck in onBlurupdateOn: 'blur'
Show all errors on submitSet every didEdit to trueform.markAllAsTouched()
Custom ruleFunction returning booleanValidatorFn returning ValidationErrors | null
Cross-field ruleCompare in render/submitGroup validator
Disable browser popupsnoValidateDefault (novalidate added)