This guide explains the async form validation system in beginner-friendly terms. It covers the reusable validators, API helpers, debounce utilities, and validation UI components.
The validation system has three layers:
src/validation.jscontains the main validators used by forms.src/utils/validationApi.jshandles API requests, retries, timeouts, and normalized response objects.src/components/forms/contains reusable UI pieces for showing validation states accessibly.
Most async validators return this shape:
{
isValid: true,
message: "",
isLoading: false,
}Common validation states used by the UI are:
idle: no validation has run yetvalidatingorloading: validation is in progresssuccessorvalid: validation passederrororinvalid: validation failedwarning: validation needs attention but is not a hard errorinfo: neutral helper information
Use src/validation.js for field-level validation:
validate.email(value)checks email format.validate.required(value)checks whether a value is present.validate.password(value)checks minimum password length.validate.confirmPassword(value, allValues)compares password fields.validateEmailAvailability(email)checks format, then API availability.validateUsernameUnique(username)checks format, then API availability.validatePasswordStrength(password)checks password rules locally.validatePhoneNumber(phone)checks format and optionally calls an API.
Use src/utils/validationApi.js when you need lower-level API behavior:
requestValidation(endpoint, options)makes a validation request.normalizeValidationApiResponse(data, options)converts API data into the standard result.createValidationResponse(isValid, message, extra)creates a standard result.
Use src/utils/debounceUtils.js when validation should wait until typing pauses:
debounceAsync(asyncFn, delay, options)debounces any async function.createDebouncedValidator(validator, delay)debounces a validator and resolves cancelled calls safely.
Sync validators return true when valid, or a message string when invalid.
import validate from "./validation";
const result = validate.email("person@example.com");
if (result === true) {
console.log("Email format is valid");
} else {
console.log(result);
}For required fields:
const result = validate.required(formValues.firstName);For confirm password:
const result = validate.confirmPassword(formValues.confirmPassword, formValues);Async validators return a promise that resolves to a standard validation result.
import { validateEmailAvailability } from "./validation";
const result = await validateEmailAvailability("person@example.com");
if (result.isValid) {
console.log("Email is available");
} else {
console.log(result.message);
}You can customize messages:
const result = await validateEmailAvailability(email, {
messages: {
invalidFormat: "Enter a valid email address",
unavailable: "This email is already in use",
network: "We could not check this email right now",
},
});Empty values are skipped by default for availability checks. Pair async validators with validate.required when a field must be filled.
Debouncing avoids API calls on every keystroke. The validator runs after the user pauses typing.
import { createDebouncedValidator } from "./utils/debounceUtils";
import { validateUsernameUnique } from "./validation";
const validateUsername = createDebouncedValidator(validateUsernameUnique, 500);
const result = await validateUsername("event_builder");If the user types again before the delay finishes, the older call resolves with:
{
isValid: false,
message: "Validation cancelled",
cancelled: true,
}Usually you should ignore results with cancelled: true.
ValidationStatusIcon shows a small icon for a validation state.
import { ValidationStatusIcon } from "./components/forms";
<ValidationStatusIcon state="validating" label="Checking email availability" />;Use ariaHidden when the icon is decorative and a message already explains the state:
<ValidationStatusIcon state="success" ariaHidden />;ValidationMessage renders accessible inline feedback.
import { ValidationMessage } from "./components/forms";
<ValidationMessage
id="email-message"
state="error"
message="Email is already registered"
/>;It renders nothing for empty, null, or undefined messages.
FormFieldWrapper connects labels, helper text, validation messages, and ARIA attributes for one input.
import { FormFieldWrapper } from "./components/forms";
<FormFieldWrapper
id="email"
label="Email"
required
helperText="Use the email you want for event updates."
validationState="error"
message="Email is already registered"
>
<input type="email" value={email} onChange={handleEmailChange} />
</FormFieldWrapper>;The wrapper automatically sets:
idandnamearia-describedbyaria-invalidaria-busyaria-required
import React, { useMemo, useState } from "react";
import {
createDebouncedValidator,
validate,
validateEmailAvailability,
} from "./validation";
import { FormFieldWrapper } from "./components/forms";
const debouncedEmailValidator = createDebouncedValidator(
validateEmailAvailability,
500,
);
const SignupEmailField = () => {
const [email, setEmail] = useState("");
const [state, setState] = useState("idle");
const [message, setMessage] = useState("");
const validateEmail = useMemo(() => debouncedEmailValidator, []);
const handleChange = async (event) => {
const nextEmail = event.target.value;
setEmail(nextEmail);
const requiredResult = validate.required(nextEmail);
if (requiredResult !== true) {
setState("error");
setMessage(requiredResult);
return;
}
const formatResult = validate.email(nextEmail);
if (formatResult !== true) {
setState("error");
setMessage(formatResult);
return;
}
setState("validating");
setMessage("Checking email availability...");
const result = await validateEmail(nextEmail);
if (result.cancelled) return;
setState(result.isValid ? "success" : "error");
setMessage(result.isValid ? "Email is available" : result.message);
};
return (
<FormFieldWrapper
id="signup-email"
label="Email"
required
helperText="We will use this for account updates."
validationState={state}
message={message}
>
<input type="email" value={email} onChange={handleChange} />
</FormFieldWrapper>
);
};- Mistake: Running API validation before required or format checks. Fix: Run cheap sync checks first, then call async validators.
- Mistake: Showing cancelled debounce results as errors.
Fix: Ignore results with
result.cancelled. - Mistake: Treating empty optional fields as invalid.
Fix: Keep
skipEmpty: truefor optional async checks. - Mistake: Forgetting to connect message IDs to inputs.
Fix: Use
FormFieldWrapper, which handlesaria-describedby. - Mistake: Using a custom API response shape without mapping it.
Fix: Use
mapResultincreateCustomAsyncValidatorornormalizeValidationApiResponse.
Focused tests for this system live in:
src/validation.test.jssrc/utils/validationApi.test.jssrc/utils/debounceUtils.test.jssrc/components/forms/__tests__/ValidationStatusIcon.test.jsxsrc/components/forms/__tests__/ValidationMessage.test.jsxsrc/components/forms/__tests__/FormFieldWrapper.test.jsxsrc/components/auth/__tests__/SignupForm.test.jsxsrc/components/auth/__tests__/LoginForm.test.jsx
For async and debounce tests, prefer fake timers or injected fetchImpl functions so tests stay fast and deterministic.
- Error icons and error messages use alert semantics.
- Non-error validation states use polite live regions.
FormFieldWrapperlinks helper text and validation messages througharia-describedby.- Loading states set
aria-busy="true"on the input. - Required fields include both a visual asterisk and screen-reader text.
- Add Storybook interaction tests for typing and async transitions.
- Add shared TypeScript types for validation results if the project moves to TypeScript.
- Add a small hook that manages
idle,validating,success, anderrorstate for common async fields. - Add endpoint-specific examples for production API response shapes.