Ant Design async validation and submission: an architecture note
Async field validation in Ant Design is a UX layer, not a security boundary. Here is the smallest design that maps server 400 responses back onto fields, plus the checks and failure modes that decide when to change it.
11 Aug 2025, 13:26 UTC

A field-level async validator in Ant Design can tell a user that an email is already taken, but it cannot stop anyone from posting that email anyway. The validator is a UX affordance running in a browser the user controls; the server is the only place a rule is actually enforced. Most of the design work in an async form is deciding where that line sits, how server rejections get back onto the right field, and what happens when the network misbehaves.
This note describes the smallest Ant Design v5 + React design that survives those questions, then lists the checks and failure modes that would push you to a different one.
Requirements and version assumptions
- React 16.8 or later (hooks) and Ant Design v5. API names below are v5 names; verify against your installed version before copying.
- A backend endpoint that returns JSON, and a second endpoint or rule that returns
400with per-field messages when a value is rejected. - Somewhere to hold submission state — a local
useStateis enough for a single form; React Context or Redux only if other parts of the app need it. - A decision, made up front, about whether an infrastructure failure during validation should block the user or not. The example below fails open.
The smallest design that works
One Form, one Form.Item per field with a name path, and an async function attached as a rule validator. Ant Design treats a rejected promise from that function as an invalid field and uses the rejection message as the error text.
// emailAvailable.js — runs in the browser
export async function emailAvailable(_rule, value) {
if (!value) return; // the required rule owns the empty case
const res = await fetch('/api/users/email-available', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: value }),
});
if (res.ok) return; // resolving means valid
if (res.status === 400) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? 'This email cannot be used');
}
return; // 5xx/429: fail open, see trade-off below
}
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
onFinishFailed={({ errorFields }) => {
if (errorFields.length) {
form.scrollToField(errorFields[0].name, { behavior: 'smooth' });
}
}}
>
<Form.Item
label="Work email"
name="email"
hasFeedback
validateFirst
validateTrigger="onBlur"
rules={[
{ required: true, message: 'Enter an email address' },
{ type: 'email', message: 'That does not look like an email address' },
{ validator: emailAvailable },
]}
>
<Input autoComplete="email" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={submitting}>
Create account
</Button>
</Form.Item>
</Form>
Three details carry most of the weight. validateTrigger="onBlur" keeps the network call off every keystroke. validateFirst stops the rule chain at the first failure, so the endpoint is never called for a value that already failed a cheap local rule. hasFeedback gives the user a visible pending state while the request is in flight, which matters more than it sounds — an onBlur check with no feedback reads as a frozen input.
If you need debouncing and your installed release exposes a validateDebounce prop on Form.Item, that is the tidier route; on older releases, debounce inside the validator and mark the choice for review against your version's changelog.
Where the trust boundary sits
Everything above is display logic. The server must re-run the same uniqueness check, the same authorization check, and every business rule on submit, because the client can be edited, replayed, or bypassed entirely. Two consequences follow:
- Never treat a passed client validation as permission to skip a server check, and never let the client decide a value is acceptable because its own validator resolved.
- Server error strings are untrusted data. Render them as text (React escapes by default) and do not branch on their contents — a message that says "already registered" is a hint for the user, not a state machine input.
Mapping a 400 back onto fields
Ant Design will not infer which field a server error belongs to. Define a response contract and map it explicitly with form.setFields. A workable shape is {"errors": {"email": ["already registered"]}} — the key is your field name path, the value is a list of messages.
const [submitting, setSubmitting] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
async function handleSubmit(values) {
if (submitting) return; // Enter key bypasses a disabled button
setSubmitting(true);
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey.current,
},
body: JSON.stringify(values),
signal: AbortSignal.timeout(15000),
});
if (res.status === 400) {
const body = await res.json();
const fieldErrors = body.errors ?? {};
form.setFields(
Object.entries(fieldErrors).map(([name, messages]) => ({
name,
errors: messages,
})),
);
const first = Object.keys(fieldErrors)[0];
if (first) form.scrollToField(first, { behavior: 'smooth' });
return;
}
if (!res.ok) throw new Error(`submit failed: ${res.status}`);
// success: navigate, reset, or show a result
} catch (err) {
reportError(err); // your monitoring client
} finally {
setSubmitting(false);
}
}
The idempotency key is generated once per form instance, not per attempt, so a retry after a timeout does not create two records. crypto.randomUUID() requires a secure context (HTTPS or localhost). AbortSignal.timeout is a modern-browser API — check your support matrix, and note that fetch has no default timeout, so without one a hung request leaves the button spinning indefinitely.
Operational checks
| Check | How to confirm | What it catches |
|---|---|---|
| Submit is single-flight | Click twice quickly; count POSTs in the Network tab | Duplicate records, double-charged operations |
| Button state resets | Force a 500 and confirm the button leaves the loading state | Permanently stuck UI after any error path |
| Field errors land on the right input | Return errors for two fields at once | Name-path mismatches between client and server |
| Network failures are visible somewhere | Go offline and submit | Silent failures that look like a no-op |
| Validation does not fire on every keystroke | Type a long value and watch request count | Endpoint hammering, rate-limit bans |
Failure modes and what would change the design
- Latency above roughly 400 ms. On-blur validation starts to feel like a broken input. Either debounce, or drop the async rule entirely and validate only on submit, showing the server's verdict afterwards.
- Out-of-order responses. If two validations for one field are in flight, the later-issued result should win. Test this by delaying one response in your mock; if you see a stale error, add an
AbortControllerand ignore aborted results rather than assuming the library orders them for you. - The form outgrows a single screen. Past roughly fifteen fields, split into sub-forms or a stepped flow, or use
Form.Listfor repeating groups. One giantvalidateFieldscall re-fires every async validator on every submit attempt. - Token refresh. Wrap
fetchin a client that refreshes once and retries once. A validator that retries in a loop will spin against an expired session. - Fail-open versus fail-closed. The example treats a 5xx as "not my problem, let the server decide at submit time." If a value must be verified before the user can proceed, fail closed with an explicit "could not verify, try again" message instead — and accept that a backend outage blocks the form.
- No per-field errors from the server. If the API only returns a single message, skip
setFieldsand render a form-level alert; inventing field mappings from prose is fragile.
Verifying it locally
- Run the dev server and open the form. Enter a value your mock endpoint rejects with
400, then blur the field. Expect an error under that input. Note that the submit button stays clickable — Ant Design does not disable it on a field error; clicking it re-runs validation and routes toonFinishFailedinstead ofonFinish. - Open DevTools → Network. Submit a valid form and confirm one POST, a visible loading state, and the expected success behaviour.
- Add a two-second delay to the mock endpoint. Confirm the page stays responsive and the pending indicator appears.
- Return a
500from the validation endpoint and confirm the behaviour matches your fail-open or fail-closed decision. - Return errors for two fields in one
400and confirm both are highlighted and the view scrolls to the first.
The code above is illustrative, not tested output; treat the endpoint paths, response shape, and API names as placeholders to check against your own backend and your installed Ant Design version. The parts worth keeping regardless of version are the boundary — client validation for feedback, server validation for truth — and the explicit mapping step between them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.