DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Speed up AntD forms with React Hook Form & TypeScript

Introduction

Large Ant Design (AntD) forms can go from snappy to janky fast. I introduced React Hook Form (RHF) expecting a speed boost — but saw the whole form commit on every keystroke and 400–500ms Profiler flamecharts. The problem wasn’t React: it was patterns that accidentally subscribed the entire tree.

This article shows measurement-first fixes that keep re-renders local, preserve RHF’s uncontrolled performance model, and scale to hundreds of rows. Keyword: react-hook-form antd performance.

Why forms get slow

React Hook Form is fast because it keeps values out of React state (uncontrolled). But performance collapses when you accidentally subscribe at the wrong level. Common causes:

  • Calling watch() at the form root (it subscribes and re-renders the caller).
  • Using uncontrolled/controlled switches (undefined default value) and heavy validation on every keystroke.
  • Wrapping AntD controlled inputs directly in parent components so a sibling change rerenders everyone.
  • Using index keys in useFieldArray (breaks on reorder and virtualization).
  • Recreating resolvers/schema or defaultValues each render.

Measure first with React Profiler: which component commits on typing? Narrow the blast radius by ensuring "where you read = where it re-renders."

Five surgical fixes

Below are five focused changes that cut re-renders and returned sub‑50ms interactions on a 200-row table in my case.

1) Move watchers to the leaf (useWatch)

Rule: if you only display or compute a derived value for one cell, call useWatch in that cell component — not watch() at the root.

Bad:

// inside the top-level form
const values = watch(); // re-renders top-level on every change
Enter fullscreen mode Exit fullscreen mode

Good:

// PriceCell.tsx
import { useWatch, Control } from 'react-hook-form';

function PriceCell({ control, name }: { control: Control<any>; name: string }) {
  const price = useWatch({ control, name });
  return <div>{price}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Moving a price watcher from the root into PriceCell reduced renders from dozens to one per cell in my test.

2) Wrap AntD controlled inputs with a tiny adapter (useController)

AntD inputs are controlled; directly rendering them inside the parent makes the parent re-render on sibling changes. Use useController (or Controller) locally to isolate subscriptions.

Example adapter for Select (TypeScript):

import React from 'react';
import { useController, Control } from 'react-hook-form';
import { Select } from 'antd';

type RHFAntdSelectProps = { name: string; control: Control<any>; options: { label: string; value: any }[] };

export const RHFSelect: React.FC<RHFAntdSelectProps> = React.memo(function RHFSelect({ name, control, options }) {
  const { field } = useController({ name, control });
  return <Select value={field.value} onChange={(v) => field.onChange(v)} options={options} />;
});
Enter fullscreen mode Exit fullscreen mode

Because useController subscribes only to that field, parent and siblings stop re-rendering when this input changes.

3) Memoize nested inputs and compare only the props that matter

Wrap expensive field components with React.memo and provide a stable props surface. Only pass primitives or stable refs. When comparing, only care about value, error, and isDirty.

Example:

const Cell = React.memo(function Cell({ value, error, onChange }: CellProps) {
  // render heavy cell
});

// Pass value and error, not the whole form object
Enter fullscreen mode Exit fullscreen mode

Avoid passing inline functions or newly created objects as props unless they’re memoized (useCallback/useMemo).

4) Virtualize field arrays and use stable field.id keys

For hundreds of rows, render only visible rows with a virtualizer (e.g., @tanstack/react-virtual). Always use the stable field.id from useFieldArray as key — never index.

Short virtualization sketch:

import { useVirtualizer } from '@tanstack/react-virtual';
import { useFieldArray, useForm } from 'react-hook-form';

function VirtualizedRows() {
  const { control, register } = useForm({ defaultValues: { rows: [] } });
  const { fields } = useFieldArray({ control, name: 'rows' });
  const parentRef = React.useRef<HTMLDivElement | null>(null);
  const virtualizer = useVirtualizer({
    count: fields.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 56,
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((v) => {
          const index = v.index;
          const field = fields[index];
          return (
            <div key={field.id} style={{ position: 'absolute', transform: `translateY(${v.start}px)` }}>
              <input {...register(`rows.${index}.name`)} />
            </div>
          );
        })}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Virtualization + field.id keeps the DOM light while RHF keeps values in memory.

5) Provide stable defaultValues and use a memoized resolver (zodResolver)

Unstable defaultValues or a fresh resolver/schema every render causes controlled/uncontrolled churn and extra validation work. Define schema and defaultValues outside the component (or memoize) and prefer onTouched validation.

Bad:

function Form() {
  const schema = z.object({ /* defined inline */ }); // recreated every render
  const form = useForm({ resolver: zodResolver(schema), defaultValues: fetchDefault() });
}
Enter fullscreen mode Exit fullscreen mode

Good:

const invoiceSchema = z.object({ items: z.array(z.object({ name: z.string(), price: z.number() })) });
const initialDefaults = { items: [{ name: '', price: 0 }] } as const;

function Form() {
  const form = useForm({ resolver: zodResolver(invoiceSchema), defaultValues: initialDefaults, mode: 'onTouched' });
}
Enter fullscreen mode Exit fullscreen mode

Use mode: 'onTouched' or 'onSubmit' as a default; only use 'onChange' where immediate validation is truly needed.

Putting it together: a concrete win

In my form, a Price column was being watched at the form root to compute totals. Moving that watch into a small PriceCell via useWatch turned dozens of renders into one per cell. Profiler showed dramatically fewer commits and narrow flame widths. On a 200-row form interactions dropped from 400–500ms commits to under 50ms.

Practical checklist

  • Profile first: open React Profiler and record a keystroke. Find the component that re-renders.
  • Replace watch() at the root with useWatch in leaves.
  • Wrap AntD controlled components with useController adapters and React.memo.
  • Virtualize long lists with @tanstack/react-virtual and key by field.id.
  • Move schema and defaultValues out of render; use a memoized zodResolver.
  • Prefer onTouched/onSubmit validation; trigger specific fields manually when necessary.

Conclusion

Don’t rewrite the UI — fix the patterns. RHF gives you the performance potential; the job is to keep subscriptions tiny and stable. A few surgical moves (useWatch at the leaf, small useController adapters, memoization, virtualization, and stable resolver/defaults) will usually turn a janky AntD form into an instant-feeling editor.

If you want, I can paste the tiny adapter components and the full virtualized useFieldArray sample I used in production.

Further reading

  • React Hook Form docs: useController, useWatch, useFieldArray
  • @tanstack/react-virtual docs
  • zod + @hookform/resolvers examples

What’s the biggest performance surprise you’ve found in a form at scale?

Top comments (1)

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

What keeps an offscreen row registered with React Hook Form when virtualization unmounts its input?