Form Validation Async
Try in playground →opens this recipe’s markup and SCSS, live-compiled
Try it: type a username. The check is simulated (no real network call, this site is a static export — see the recipe’s Pitfalls) but the debounce, cancellation, and loading/success/error states are the real, live pattern.
Try “admin” (taken) vs. anything else (available). Type quickly through a few names to see the in-flight cancellation — only the last one you stop on ever resolves.
Use this when
A field's validity depends on something the browser can't know on its own — a username availability check, an email that must not already be registered, a coupon code that has to be looked up. This needs a network round trip per keystroke-pause, which means three problems the other validation recipes don't have: debouncing (don't fire a request per keystroke), cancellation (an in-flight request for "al" shouldn't overwrite the result for "alice" if it resolves later), and a genuine loading state (the field is neither valid nor invalid while the check is pending).
Structure (raw HTML)
<div class="field" data-status="idle">
<label for="username">Username</label>
<div class="field-input-wrap">
<input id="username" name="username" type="text" aria-describedby="username-status" autocomplete="off" />
<span class="field-spinner" aria-hidden="true"></span>
</div>
<span class="field-status" id="username-status" role="status"></span>
</div>
data-status on the wrapper drives which of idle / checking / available /
taken the field shows — set from script, since none of these are states CSS
can derive on its own (no pseudo-class means "a fetch is pending").
Styling (cia mixins)
@use 'css-is-awesome/api' as cia;
.field {
display: flex;
flex-direction: column;
gap: cia.space(1);
margin-block-end: cia.space(4);
}
.field-input-wrap {
position: relative;
input {
@include cia.input-base;
padding-inline-end: cia.space(8); // room for the spinner/icon
}
}
.field-spinner {
display: none;
position: absolute;
inset-inline-end: cia.space(3);
inset-block-start: 50%;
translate: 0 -50%;
}
// data-status drives which affordance shows — spinner while checking,
// nothing extra once settled (the status text below carries the result).
.field[data-status="checking"] {
.field-spinner {
display: block;
@include cia.spinner($size: 1em);
}
}
.field[data-status="available"] input {
border-color: var(--success-default);
}
.field[data-status="taken"] input {
border-color: var(--error-default);
}
.field-status {
font-size: 0.85em;
.field[data-status="available"] & {
color: var(--success-text);
}
.field[data-status="taken"] & {
color: var(--error-text);
}
}
Interactivity
const AbortableCheck = (() => {
let controller = null;
let timer = null;
return function check(username, onResult) {
clearTimeout(timer);
controller?.abort(); // cancel whatever was in flight — its result would be stale
timer = setTimeout(() => {
controller = new AbortController();
checkUsername(username, controller.signal)
.then(onResult)
.catch((err) => {
if (err.name !== "AbortError") onResult({ error: true });
});
}, 500);
};
})();
// The "endpoint." A real project replaces this with fetch(url, { signal });
// AbortController cancellation works identically either way — the browser
// aborts a real fetch's underlying request, this just rejects the timer.
const TAKEN = new Set(["admin", "root", "test", "cia"]);
function checkUsername(username, signal) {
return new Promise((resolve, reject) => {
const delay = setTimeout(() => resolve({ available: !TAKEN.has(username.toLowerCase()) }), 600);
signal.addEventListener("abort", () => {
clearTimeout(delay);
reject(new DOMException("aborted", "AbortError"));
});
});
}
const field = document.querySelector('[data-status]');
const input = field.querySelector("input");
const status = field.querySelector(".field-status");
input.addEventListener("input", () => {
const value = input.value.trim();
if (value.length < 3) {
field.dataset.status = "idle";
status.textContent = "";
return;
}
field.dataset.status = "checking";
input.setAttribute("aria-busy", "true");
status.textContent = "Checking availability…";
AbortableCheck(value, (result) => {
input.removeAttribute("aria-busy");
if (result.error) {
field.dataset.status = "idle";
status.textContent = "Couldn't check right now — try again.";
} else if (result.available) {
field.dataset.status = "available";
status.textContent = `"${value}" is available.`;
} else {
field.dataset.status = "taken";
status.textContent = `"${value}" is already taken.`;
}
});
});
- Debounce: the 500ms
setTimeout, reset on every keystroke — only the pause after typing stops actually fires a check. - Cancel-in-flight:
controller.abort()on the previous check before starting a new one. Without this, typing "al" → "ali" → "alice" quickly can have "al"'s slower response arrive after "alice"'s faster one and stomp the correct result — a real, common bug class this pattern exists specifically to prevent. - Loading state:
aria-busy="true"on the input plus a visible spinner for the checking window; both clear whether the check resolves or errors.
A11y checklist
-
aria-busy="true"on the input while a check is pending, removed when it settles — screen readers announce the busy state instead of silence (WCAG 2.1 SC 4.1.2 Name, Role, Value) - The result text lives in a
role="status"element (notalert— a live availability check isn't urgent enough to interrupt,statusis announced politely) so screen readers pick up "available"/"taken" without a page reload (WCAG 2.2 SC 4.1.3 Status Messages) - The spinner is
aria-hidden="true"— it's decorative; therole="status"text ("Checking availability…") already conveys the same state to assistive tech (WCAG 2.2 SC 1.1.1 Non-text Content) - The debounce delay (500ms here) stays well under 1 second — long enough to avoid a request per keystroke, short enough that a keyboard user doesn't wonder if the field is broken
Framework examples
React
import { useRef, useState } from "react";
export function UsernameField() {
const [status, setStatus] = useState<"idle" | "checking" | "available" | "taken">("idle");
const [message, setMessage] = useState("");
const controllerRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value.trim();
clearTimeout(timerRef.current);
controllerRef.current?.abort();
if (value.length < 3) {
setStatus("idle");
setMessage("");
return;
}
setStatus("checking");
timerRef.current = setTimeout(async () => {
const controller = new AbortController();
controllerRef.current = controller;
try {
const available = await checkUsername(value, controller.signal);
setStatus(available ? "available" : "taken");
setMessage(available ? `"${value}" is available.` : `"${value}" is already taken.`);
} catch (err) {
if ((err as Error).name !== "AbortError") setStatus("idle");
}
}, 500);
}
return (
<div className="field" data-status={status}>
<label htmlFor="username">Username</label>
<div className="field-input-wrap">
<input id="username" onChange={handleChange} aria-busy={status === "checking"} aria-describedby="username-status" />
<span className="field-spinner" aria-hidden="true" />
</div>
<span className="field-status" id="username-status" role="status">{message}</span>
</div>
);
}
Vue
<template>
<div class="field" :data-status="status">
<label for="username">Username</label>
<div class="field-input-wrap">
<input id="username" @input="handleInput" :aria-busy="status === 'checking'" aria-describedby="username-status" />
<span class="field-spinner" aria-hidden="true" />
</div>
<span class="field-status" id="username-status" role="status">{{ message }}</span>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const status = ref<"idle" | "checking" | "available" | "taken">("idle");
const message = ref("");
let controller: AbortController | null = null;
let timer: ReturnType<typeof setTimeout>;
function handleInput(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
clearTimeout(timer);
controller?.abort();
if (value.length < 3) { status.value = "idle"; message.value = ""; return; }
status.value = "checking";
timer = setTimeout(async () => {
controller = new AbortController();
try {
const available = await checkUsername(value, controller.signal);
status.value = available ? "available" : "taken";
message.value = available ? `"${value}" is available.` : `"${value}" is already taken.`;
} catch (err) {
if ((err as Error).name !== "AbortError") status.value = "idle";
}
}, 500);
}
</script>
Svelte
<script lang="ts">
let status: "idle" | "checking" | "available" | "taken" = "idle";
let message = "";
let controller: AbortController | null = null;
let timer: ReturnType<typeof setTimeout>;
function handleInput(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
clearTimeout(timer);
controller?.abort();
if (value.length < 3) { status = "idle"; message = ""; return; }
status = "checking";
timer = setTimeout(async () => {
controller = new AbortController();
try {
const available = await checkUsername(value, controller.signal);
status = available ? "available" : "taken";
message = available ? `"${value}" is available.` : `"${value}" is already taken.`;
} catch (err) {
if ((err as Error).name !== "AbortError") status = "idle";
}
}, 500);
}
</script>
<div class="field" data-status={status}>
<label for="username">Username</label>
<div class="field-input-wrap">
<input id="username" on:input={handleInput} aria-busy={status === "checking"} aria-describedby="username-status" />
<span class="field-spinner" aria-hidden="true" />
</div>
<span class="field-status" id="username-status" role="status">{message}</span>
</div>
Vanilla
// See "Interactivity" above — that example IS the vanilla implementation,
// framework-free (AbortableCheck + the field/input/status wiring).
Pitfalls
- Debounce alone doesn't fix the race condition. It reduces how often a
request fires, but two in-flight requests can still resolve out of
order —
AbortControlleris the actual fix, not an optional extra. - Aborting the timer isn't the same as aborting the request.
clearTimeoutstops a check that hasn't started yet;controller.abort()cancels one that has. This recipe'sAbortableCheckdoes both, in that order, every time. - A static export can't have a real API route for this (no Node
server) — if you're statically exporting, the "check" has to be a client-
side call to an external API, or (as in this recipe's own live demo) a
simulated one. Don't reach for a Next.js Route Handler expecting it to
work on
output: "export".
Related recipes
form-validation-html5— for validation that doesn't need a network round trip.