Choosing Ant Design Form Validation Strategies for Complex Forms
A decision guide for selecting synchronous rules, async validators, and validateTrigger modes in Ant Design forms, with concrete examples for cross-field dependencies and server-side checks.
09 Feb 2026, 18:16 UTC

The Decision: Validation Strategy for Complex Forms
When an Ant Design form grows beyond a few fields — think server‑side availability checks, cross‑field dependencies (e.g., password confirmation), and dynamic field arrays — the default validateTrigger="onChange" with synchronous rules becomes a liability. Excessive API calls, race conditions, and stale validation state are common. This guide lays out the supported options, compares them in a compact table, explains the trade‑offs, and shows a concrete implementation you can adapt.
Constraints and Requirements
- Ant Design v4 or v5 (v5.6+ adds
dependenciesprop, v5.10+ addsvalidateDebounce). - TypeScript project where
Form<Values>infers field names from aValuesinterface. - Need to validate:
- Username format (sync) + server availability (async).
- Password strength (sync) + confirmPassword matching password (cross‑field).
- Dynamic list of emails (each required, format validated).
- Performance goal: avoid API spam on keystrokes, keep UI responsive.
Option Comparison
| Aspect | Synchronous Rule | Async Validator (Promise) | validateTrigger Modes | Cross‑Field Approach |
|---|---|---|---|---|
| Definition | validator: (rule, value) => void | Error | validator: (rule, value) => Promise<void> | 'onChange' | 'onBlur' | 'onSubmit' | string[] | form.getFieldValue inside validator (v4) or dependencies prop (v5.6+) |
| Execution | Runs immediately on trigger | Runs in parallel per field; can chain promises for sequencing | Controls when validation fires | Dependent field re‑validates when dependency changes (v5.6+) |
| Typical Use | Format, length, regex checks | Server uniqueness, remote lookup | onChange for lightweight sync; onBlur/onSubmit for async | Password confirmation, conditional required fields |
| Performance Knob | N/A | N/A | validateDebounce (v5.10+) debounces onChange | Manual form.validateFields([dep]) if dependencies not available |
| Error Aggregation | Collected with other sync errors | Collected; rejected Promise must be Error instance | Errors shown when trigger fires | Errors appear on dependent field |
Trade‑offs
Trigger Mode Selection
onChange gives instant feedback but fires on every keystroke. Pairing it with async validators causes request storms and race conditions (last response wins, UI may flicker). Mitigate with validateDebounce={300} (v5.10+) or restrict async rules to onBlur / onSubmit.
onBlur is the sweet spot for server checks: user finishes editing, then validation runs. onSubmit alone delays all feedback until submit, which hurts UX. A common pattern: validateTrigger={["onBlur", "onSubmit"]} for async fields, validateTrigger="onChange" for lightweight sync fields.
Cross‑Field Validation
In v4 you call form.getFieldValue('password') inside the confirmPassword validator. The dependent field does not re‑validate automatically when password changes — you must call form.validateFields(['confirmPassword']) in an onChange handler of the password field. v5.6+ introduces the dependencies prop on Form.Item: dependencies={["password"]} triggers re‑validation of confirmPassword whenever password changes. Note: dependencies only fires on value change, not on initial mount; call validateFields manually after form initialization if needed.
Async Validator Sequencing
If you need sequential checks (e.g., format then availability), chain promises inside a single async validator:
validator: async (rule, value) => { await checkFormat(value); await checkAvailability(value); }Alternatively, run a pre‑check with form.validateFields({ validateOnly: true }) before the real submit.
Dynamic Fields with Form.List
Each list item receives a unique key. Validation rules must reference the key path: ['users', key, 'email']. Keys must be stable strings/numbers — using array index breaks validation state when items are reordered or deleted.
Concrete Implementation
Below is a complete, typed form component illustrating the recommended setup. It uses v5 APIs (dependencies, validateDebounce, form.submit()). Adjust imports for v4 if necessary.
import React, { useEffect } from 'react';
import { Form, Input, Button, List, Space } from 'antd';
import type { FormInstance, Rule } from 'antd/es/form';
interface Values {
username: string;
password: string;
confirmPassword: string;
users: Array<{ key: string; email: string }>;
}
const UserForm: React.FC = () => {
const [form] = Form.useForm();
// Re‑validate confirmPassword on mount because dependencies doesn't fire initially
useEffect(() => {
form.validateFields(['confirmPassword']);
}, [form]);
// Async username availability check
const checkUsername = async (value: string) => {
if (!value) return;
const res = await fetch(`/api/check-username?username=${encodeURIComponent(value)}`);
if (!res.ok) throw new Error('Username already taken');
};
// Sync password strength
const passwordRules: Rule[] = [
{ required: true, message: 'Password is required' },
{ min: 8, message: 'At least 8 characters' },
{ pattern: /[A-Z]/, message: 'Must contain uppercase' },
];
const onFinish = async (values: Values) => {
console.log('Submitted:', values);
// await submitToBackend(values);
};
return (
form={form}
layout="vertical"
onFinish={onFinish}
validateMessages={{ required: '${label} is required' }}
>
{/* Username: async onBlur + debounced onChange for format */}
{/* Password: sync onChange */}
{/* Confirm password: cross‑field via dependencies (v5.6+) */}
{
if (getFieldValue('password') !== getFieldValue('confirmPassword')) {
throw new Error('Passwords do not match');
}
},
]}
dependencies={["password"]}
validateTrigger="onChange"
>
{/* Dynamic email list */}
{(fields, { add, remove }) => (
{fields.map((field) => (
remove(field.key)}>Remove
))}
add()} block>Add Email
)}
Submit
);
};
export default UserForm;Key Configuration Points
- Username:
validateTrigger={["onBlur", "onSubmit"]}+validateDebounce={300}ensures the async check runs only after the user pauses typing or leaves the field. - Password: lightweight sync rules on
onChangefor immediate feedback. - ConfirmPassword:
dependencies={["password"]}triggers re‑validation whenever password changes; the validator usesgetFieldValue(provided by the rule's second argument in v5) to compare. - Form.List: each item gets a stable
key(generated byadd). ThefieldKeyprop ties validation state to that key. - Submission:
onFinishreceives validatedValues. The native submit button (htmlType="submit") works withform.submit()if you prefer imperative submission.
Verification Checklist
- Network tab: type rapidly in username field — only one request should fire after 300 ms pause. Switch to
onBluronly and verify request fires on blur. - Cross‑field: change password after confirmPassword has an error; confirmPassword error should clear/update automatically (v5.6+). In v4, add an
onChangehandler on password that callsform.validateFields(['confirmPassword']). - Dynamic list: add two emails, remove the first, verify the second retains its validation state (no error reset).
- TypeScript: hover
form.validateFields()— return type should bePromise<Values>. - Native submit: press Enter in any field; validation runs before
onFinish.
Limitations
dependenciesdoes not trigger on initial mount — manualvalidateFieldsinuseEffectis required.- Async validators that reject with non‑
Errorvalues (e.g., strings) break error aggregation; always throwErrorinstances. - Mixing legacy callback validators (
callback(error?)) with async validators works but error display order may be unpredictable. ConfigProvidervalidateMessagessets global defaults; per‑rulemessagealways wins.- Race conditions on
onChangeasync validators are not automatically cancelled;validateDebouncemitigates but does not abort in‑flight requests.
Use the verification checklist to confirm behavior in your environment. Adjust debounce timing and trigger modes per field based on actual API latency and UX requirements.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.