Type-Safe Form Validation with Ant Design v5: Patterns That Scale
Ant Design v5's Form component provides type-safe validation with a subscription-based performance model. This guide covers useForm, declarative rules, dynamic Field.List, async validators, and cross-form patterns—with gotchas like transform side-effects and key requirements.
10 Jul 2026, 10:23 UTC

The Problem: Form Validation That Doesn't Fight Back
React forms start simple. A few controlled inputs, a submit handler, maybe a required check. Then requirements grow: nested objects, dynamic field arrays, async server-side validation, cross-field dependencies, and TypeScript types that drift from reality. Most libraries solve one piece well but leave the integration gaps to you.
Ant Design's Form component (v5, built on RC-Field-Form) takes a different approach: a single controlled form instance via useForm, declarative rules per Field, and a subscription model that re-renders only what changes. This article walks through the patterns that make it work in production—without the usual boilerplate.
Core Mental Model: One Form Instance, Declarative Fields
The useForm hook returns an object with imperative methods: getFieldsValue, setFieldsValue, validateFields, submit, resetFields. No refs needed. You pass this instance to <Form form={form}> and declare fields inside.
import { Form, Input, Button } from 'antd';
import { useForm } from 'antd/es/form';
interface UserForm {
email: string;
password: string;
}
export function LoginForm() {
const [form] = useForm<UserForm>();
const onFinish = async (values: UserForm) => {
console.log('submitted', values);
};
return (
<Form form={form} onFinish={onFinish} layout="vertical">
<Form.Item
name="email"
label="Email"
rules={[
{ required: true, message: 'Email is required' },
{ type: 'email', message: 'Invalid email format' },
]}
>
<Input placeholder="Enter email" />
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}
>
<Input.Password placeholder="Enter password" />
</Form.Item>
<Button type="primary" htmlType="submit">Sign in</Button>
</Form>
);
}
The name prop accepts a string or string array for nested paths: name={["user", "address", "city"]} produces { user: { address: { city: "..." } } } in the form values. No manual object construction required.
Validation Rules: Declarative, Async, and Transform-Aware
Each Form.Item takes a rules array. Rule types include:
required— boolean with custom messagetype—'email' | 'url' | 'number' | ...for built-in format checkspattern— RegExp for custom formatsmin/max/len— length constraintsvalidator— async or sync function(rule, value, callback) => voidtransform— runs before validation, modifies the stored value
Async validators are common for uniqueness checks:
<Form.Item
name="username"
label="Username"
rules={[
{ required: true },
{
validator: async (_, value) => {
if (!value) return;
const exists = await checkUsernameAPI(value);
if (exists) throw new Error('Username taken');
},
},
]}
>
<Input />
</Form.Item>
Gotcha: the validator receives (rule, value, callback). If you use the callback style, call callback() on success or callback(new Error('msg')) on failure. Throwing works too in modern versions, but mixing styles causes silent failures.
Transform warning: a transform like transform: (v) => v.trim().toUpperCase() changes the actual submitted value, not just display. Use it for normalization (trimming, lowercasing emails), not for formatting that should stay in the view layer.
Dynamic Field Arrays with Form.List
Repeatable sections—phone numbers, address lines, team members—use Form.List. It provides fields, operation (add, remove, move), and meta for index/key.
import { Form, Input, Button, Space } from 'antd';
interface ContactForm {
phones: { number: string; type: 'mobile' | 'work' }[];
}
export function ContactPhones() {
const [form] = useForm<ContactForm>();
return (
<Form form={form} initialValues={{ phones: [{ number: '', type: 'mobile' }] }}>
<Form.List name="phones">
{(fields, { add, remove, move }) => (
<>
{fields.map((field, index) => (
<Space key={field.key} style={{ display: 'flex', width: '100%' }}>
<Form.Item
{...field}
name={[field.name, 'number']}
fieldKey={field.key}
rules={[{ required: true, pattern: /^\d{10}$/, message: '10 digits required' }]}
>
<Input placeholder="Phone number" style={{ flex: 1 }} />
</Form.Item>
<Form.Item {...field} name={[field.name, 'type']} fieldKey={field.key}>
<Select options={[{ value: 'mobile', label: 'Mobile' }, { value: 'work', label: 'Work' }]} placeholder="Type" style={{ width: 120 }} />
</Form.Item>
<Button type="link" onClick={() => remove(field.key)}>Remove</Button>
<Button type="link" onClick={() => move(field.key, index - 1)} disabled={index === 0}>↑</Button>
<Button type="link" onClick={() => move(field.key, index + 1)} disabled={index === fields.length - 1}>↓</Button>
</Space>
))}
<Button type="dashed" onClick={() => add()} block>+ Add phone</Button>
</>
)}
</Form.List>
</Form>
);
}
Critical: the key prop on each child must be the field.key provided by Form.List. Using array index as key breaks move/remove operations and corrupts field state during reordering.
TypeScript Integration: Compile-Time Safety
Generic useForm<Values>() types getFieldsValue and setFieldsValue. Field<Values, 'fieldName'> infers the value type from the generic, so validators and transforms receive correctly typed value parameters.
interface OrderForm {
customer: {
name: string;
email: string;
};
items: { sku: string; qty: number }[];
}
const [form] = useForm<OrderForm>();
// form.getFieldsValue() returns OrderForm
// form.setFieldsValue({ customer: { name: 'A', email: 'a@b.c' } }) // type-checked
// In a Field render prop or custom component:
<Field<OrderForm, 'customer.name'> name="customer.name">
{({ value, onChange }) => <Input value={value} onChange={onChange} />}
</Field>
Run tsc --noEmit in your project to verify types match the actual form shape. Mismatches surface immediately rather than at runtime.
Performance: Subscription Model in v5
Ant Design v5 migrated internal state to RC-Field-Form with a subscription model. Components subscribe to specific field paths; typing in one field of a 50-field form re-renders only that field's Form.Item and its children, not the entire form tree.
To verify: add a console.log in a custom field component or use form.useFormInstance() in a consumer. Observe that only the changed field logs on keystroke. This matters for large forms (wizards, config editors) where the old context-based model caused full-form re-renders.
Cross-Form Validation with Form.Provider
Multi-step wizards often split each step into a separate <Form> but need shared validation state. Form.Provider creates a context that nested forms can register with:
<Form.Provider onFormFinish={handleWizardComplete}>
<StepsForm form={step1Form} current={0} />
<StepsForm form={step2Form} current={1} />
<StepsForm form={step3Form} current={2} />
</Form.Provider>
Each step calls form.validateFields() before advancing. The provider aggregates values and runs final validation on submit. This avoids lifting all fields into one massive form while keeping validation centralized.
Trade-offs and Limitations
- Bundle size:
import { Form } from 'antd'pulls the full component library. Useimport Form from 'antd/es/Form'withbabel-plugin-importfor tree-shaking. Comparedistsizes in your build to confirm. - Initial values: set via
initialValuesprop on<Form>, notform.setFieldsValue()inuseEffect. The latter causes hydration mismatches in SSR and skips initial validation. - Colon rendering:
Form.Itemdefaults tocolon={true}(label gets a colon). Override globally viaConfigProvideror per-item withcolon={false}. - Field dependencies: the
dependenciesprop onForm.Itemtriggers re-validation of dependent fields when the source changes, but only if the dependent field is already mounted and registered. - Learning curve: the API surface (name paths, field keys, meta, operation) is larger than minimal libraries like React Hook Form. Teams new to Ant Design should budget time for the mental model shift.
Verification Checklist for Your Project
- Create a minimal React + TypeScript project with
antd@5.x. - Implement a form with nested fields (
user.address.city), aForm.Listfor dynamic items, an async validator, and a transform. - Run
tsc --noEmit— confirm no type errors ongetFieldsValue/setFieldsValueand validator signatures. - Test validation flow: submit empty form, verify
validateFieldsrejects witherrorFieldsarray; fill valid data, verify resolve returns typed values matching your generic. - Verify
Form.Listkey behavior: render 3 items, remove the middle one, confirm remaining fields retain correct values and validation state without index shift bugs. - Confirm subscription model: add
console.login a field render prop; observe only changed fields trigger re-render when typing in one field of a large form. - Check bundle size: compare
import { Form } from 'antd'vsimport Form from 'antd/es/Form'withbabel-plugin-importin your production build.
Closing: Start Small, Scale with Confidence
Ant Design's Form isn't the smallest library, but it solves the full validation lifecycle—nested values, dynamic arrays, async rules, cross-form coordination, and TypeScript safety—in a single cohesive API. The subscription model keeps performance predictable as forms grow.
Next step: pick one complex form in your codebase (the one with the most useState and manual validation). Rewrite it using useForm, declarative rules, and Form.List where needed. Run the verification checklist above. You'll remove hundreds of lines of imperative logic and gain compile-time guarantees that the form values match your domain types.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.