Zod 20 🛡️ React Hook Form Performance — Large Forms, formState Reads, and shouldUnregister Pitfalls
A form with five fields does not need performance tuning. A form with fifty fields, a field array, and a live validation summary does. React Hook Form’s default behavior is already optimized—inputs are uncontrolled, and re-renders are triggered by subscriptions rather than by state changes. The performance problems that appear in large forms are not caused by the library’s design; they are caused by how the form component reads formState, how the schema is constructed, and how conditional fields are unmounted. Each of these has a specific failure mode and a specific fix.
This chapter covers the three performance traps that appear in large forms: reading formState in ways that subscribe to more than intended, building the Zod schema inside the render path, and enabling shouldUnregister in forms that conditionally mount fields. The sections that follow walk through the subscription model, the schema hoisting pattern, and the shouldUnregister trade-off.
Key point: formState is a Proxy that subscribes on read; reading a property during render subscribes the component to it, reading it only in a callback does not, and reading isValid opts the form into validating every field on every change even when mode is onSubmit.
a. The formState subscription model and the isValid trap
formState is a Proxy. Each property has a getter, and the first read of that property during render marks it as subscribed. From that point on, the component re-renders when that property changes . Destructuring formState at the top of the component reads every property in the destructure, which is why it is the recommended habit: it makes the subscription explicit and prevents a property from being read only in a callback where the subscription never fires .
The trap is a property that is never read during render. A component that reads formState.isSubmitting only inside the submit handler never subscribes to it, so the button label does not update when the submission starts . The same applies to a conditional read: {step === 2 && errors.email} does not subscribe to errors until step 2 is reached, so errors that appear on step 1 are not rendered .
The isValid property is the most expensive subscription. Reading it opts the form into continuous validation: _setValid() is gated on isValid being subscribed, and once it is, React Hook Form runs the entire resolver schema on mount and again on every change event . If mode: "onSubmit" was chosen to defer validation, reading isValid to disable the submit button cancels the deferral—the form validates on every keystroke anyway . The fix is either to accept the cost and set mode: "onChange" deliberately, or to avoid reading isValid for button state and let the submit attempt surface the errors .
// BAD: isValid read, so form validates on every change despite onSubmit mode
const { formState: { isValid } } = useForm({ mode: "onSubmit" });
<button disabled={!isValid}>Submit</button>
// GOOD: let the submit attempt surface errors
const { formState: { isSubmitting } } = useForm({ mode: "onSubmit" });
<button disabled={isSubmitting}>Submit</button>
b. Schema construction and resolver caching
React Hook Form has no resolver cache. useForm reassigns control._options on every render, and whatever resolver is passed is used as-is . An inline schema in the useForm call allocates a fresh Zod validator tree on every render, and under mode: "onChange" that allocation happens once per keystroke, on top of the validation itself .
The fix is to hoist the schema to module scope so it is constructed once at import time. If the schema depends on props, hoist a factory and memoize the call with useMemo so it rebuilds when the props change, not when the component renders .
// BAD: schema built on every render
useForm({
resolver: zodResolver(z.object({ email: z.email(), role: z.enum([...]) })),
});
// GOOD: schema built once at module load
const inviteSchema = z.object({ email: z.email(), role: z.enum([...]) });
useForm({ resolver: zodResolver(inviteSchema) });
// GOOD: factory memoized when the schema depends on props
const schema = useMemo(() => createSeatSchema(maxSeats), [maxSeats]);
For cross-field rules, a schema-level .refine() is preferred over a factory. The refinement runs against the parsed object and does not require the schema to be rebuilt when a field value changes .
c. shouldUnregister and the conditional field problem
shouldUnregister controls what happens when a field unmounts. When true, the field is unregistered and its defaultValues entry is removed . This is the correct behavior when a conditional field is hidden and its value should not be submitted, but it has a documented interaction with useFieldArray that the React Hook Form documentation warns about: “this prop should be avoided when using with useFieldArray as unregister function gets called after input unmount/remount and reorder” .
The problem is not limited to field arrays. A discussion on GitHub documents the same issue with nested conditional components: when shouldUnregister is true, calling setValue for fields in an unmounted nested component does not apply the value on the first click, because the component is not yet mounted and the field is not registered . The value appears only after the component mounts, which means the import or programmatic-set pattern behaves differently than expected .
The trade-off is between stale values and programmatic access. With shouldUnregister: false (the default), a hidden field’s value remains in the form data and is submitted even though the user cannot see it. With shouldUnregister: true, the value is removed on unmount, but setValue for a field in an unmounted component has no effect until the component mounts. The correct choice depends on whether the hidden field should be submitted: if it should not, shouldUnregister is the mechanism, but programmatic updates must target fields that are currently mounted.
Complete Example Session
// ============================================
// PART 1: THE SCHEMA (HOISTED)
// ============================================
import { z } from "zod";
const ProfileSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.email(),
bio: z.string().max(500),
});
type ProfileValues = z.infer<typeof ProfileSchema>;
// ============================================
// PART 2: ISOLATED SUBSCRIPTIONS
// ============================================
import { useForm, useFormState } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// Re-renders only when bio changes
function BioCounter({ control }: { control: any }) {
const bio = useWatch({ control, name: "bio" });
return <span>{bio?.length ?? 0} / 500</span>;
}
// Re-renders only when the submit button's state changes
function SubmitButton({ control }: { control: any }) {
const { isSubmitting } = useFormState({ control });
return <button disabled={isSubmitting}>{isSubmitting ? "Saving…" : "Save"}</button>;
}
// ============================================
// PART 3: THE FORM COMPONENT (MINIMAL SUBSCRIPTIONS)
// ============================================
function ProfileForm() {
const { register, control, handleSubmit } = useForm<ProfileValues>({
resolver: zodResolver(ProfileSchema), // hoisted schema
mode: "onBlur", // not onChange: debounced validation
defaultValues: { firstName: "", lastName: "", email: "", bio: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
<input {...register("firstName")} />
<input {...register("lastName")} />
<input {...register("email")} type="email" />
<input {...register("bio")} />
<BioCounter control={control} />
<SubmitButton control={control} />
</form>
);
}
The example hoisted the schema to module scope, isolated the bio counter and submit button into components with their own subscriptions, and chose onBlur mode to avoid per-keystroke validation. The form component itself reads no formState properties, so typing in any field re-renders only that field and the isolated components.
Quick Reference
Subscription Rules
| Read Location | Subscribes | Consequence |
|---|---|---|
| Render body | Yes | Re-renders when property changes |
| Destructure at top | Yes (for each property) | Explicit subscription |
| Callback only | No | Property changes are not reflected |
| Conditional branch | Only when branch runs | Misses changes before branch |
Performance-Critical Properties
| Property | Cost | Recommendation |
|---|---|---|
isValid | Validates all fields on every change | Avoid for button state with onSubmit mode |
errors | Re-renders when any error changes | Isolate to field-level components |
isSubmitting | Low | Safe to read for button state |
isDirty | Low | Safe to read |
shouldUnregister Interactions
| Scenario | Behavior |
|---|---|
Default (false) | Unmounted field value remains in form data |
true | Unmounted field is removed from form data |
true + useFieldArray | Not recommended; unregister on remount/reorder |
true + nested conditional | setValue may not apply until component mounts |
Schema Construction
| Pattern | Cost |
|---|---|
Inline in useForm | Rebuilt every render |
| Module scope | Built once at import |
useMemo factory | Rebuilt when props change |
Best Practices
✅ Do This:
// Hoist the schema to module scope
const schema = z.object({ ... }); // ✅
// Read formState properties in the render body
const { isSubmitting } = useFormState({ control }); // ✅
// Isolate subscriptions to leaf components
function FieldError({ control, name }) {
const { errors } = useFormState({ control, name }); // ✅
}
// Use useWatch for derived values
const bio = useWatch({ control, name: "bio" }); // ✅
❌ Don’t Do This:
// Read isValid with onSubmit mode for button state
const { formState: { isValid } } = useForm({ mode: "onSubmit" }); // ❌
// Build the schema inside the render path
useForm({ resolver: zodResolver(z.object({ ... })) }); // ❌
// Read formState only in callbacks
if (formState.isSubmitting) return; // never subscribes // ❌
// Enable shouldUnregister with useFieldArray
useForm({ shouldUnregister: true }); useFieldArray({ ... }); // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Button never shows loading state | isSubmitting read only in handler | Read during render |
Form validates every keystroke despite onSubmit | isValid subscribed for button | Remove isValid from render, or use onChange deliberately |
| Errors on hidden step not rendered | Conditional read of errors | Destructure at top, or use useFormState with name |
| Schema rebuilt per keystroke | Inline schema in useForm | Hoist to module scope |
setValue ignored on hidden field | shouldUnregister: true + unmounted component | Keep field mounted, or set value after mount |
Real-World Examples
1. Hoisted schema
const schema = z.object({ email: z.email() });
useForm({ resolver: zodResolver(schema) });
2. Isolated error component
function FieldError({ control, name }) {
const { errors } = useFormState({ control, name });
return errors[name] ? <p>{errors[name].message}</p> : null;
}
3. Isolated submit button
function SubmitButton({ control }) {
const { isSubmitting } = useFormState({ control });
return <button disabled={isSubmitting}>Save</button>;
}
4. Isolated derived value
function Total({ control }) {
const items = useWatch({ control, name: "items" });
return <span>{items.reduce((a, b) => a + b.price, 0)}</span>;
}
5. Memoized schema factory
const schema = useMemo(() => createSchema(maxSeats), [maxSeats]);
6. Avoid isValid with onSubmit
// Use isSubmitting instead of isValid
const { isSubmitting } = useFormState({ control });
7. shouldUnregister default
useForm(); // shouldUnregister: false
8. Field array without shouldUnregister
useForm(); // do not set shouldUnregister with useFieldArray
9. Read errors at top
const { formState: { errors } } = useForm();
10. Conditional field with shouldUnregister
// Field unmounts, value removed from form data
Visual
The formState Proxy
┌─────────────────────────────────────────────────────────┐
│ formState.isSubmitting │
│ │ │
│ ▼ │
│ Getter fires on read │
│ │ │
│ ▼ │
│ Property marked as subscribed │
│ │ │
│ ▼ │
│ Future changes re-render the component │
│ │
│ Read in render → subscribed │
│ Read only in callback → never subscribed │
└─────────────────────────────────────────────────────────┘
The isValid Trap
┌─────────────────────────────────────────────────────────┐
│ mode: "onSubmit" │
│ formState.isValid read for button state │
│ │ │
│ ▼ │
│ _setValid() runs on mount and every change │
│ │ │
│ ▼ │
│ Entire schema validates per keystroke │
│ │ │
│ ▼ │
│ onSubmit mode is effectively onChange │
└─────────────────────────────────────────────────────────┘
Schema Hoisting
┌─────────────────────────────────────────────────────────┐
│ INLINE HOISTED │
│ │
│ useForm({ const schema = z... │
│ resolver: zodResolver( useForm({ │
│ z.object({ ... }) resolver: zodResolver(│
│ }) schema) │
│ }) }) │
│ │
│ New tree per render Built once at import │
│ Cost per keystroke One-time cost │
└─────────────────────────────────────────────────────────┘
Isolated Subscriptions
┌─────────────────────────────────────────────────────────┐
│ PARENT FORM LEAF COMPONENTS │
│ ┌──────────────────────┐ ┌──────────────────────┐│
│ │ useForm() │ │ BioCounter ││
│ │ No formState reads │ │ useWatch("bio") ││
│ │ │ │ re-renders on bio ││
│ │ <input {...reg} /> │ └──────────────────────┘│
│ │ │ ┌──────────────────────┐│
│ │ <BioCounter /> │ │ SubmitButton ││
│ │ <SubmitButton /> │ │ useFormState() ││
│ └──────────────────────┘ │ re-renders on submit ││
│ └──────────────────────┘│
│ Typing re-renders only the input and the leaf that │
│ subscribes to the changed value │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
formState model | Proxy, subscribes on read |
isValid cost | Validates all fields on every change |
| Schema construction | Hoist to module scope |
| Isolation | useFormState({ control, name }) |
| Derived values | useWatch({ control, name }) |
shouldUnregister with useFieldArray | Avoid |
shouldUnregister with nested conditional | setValue may not apply until mount |
Key takeaways:
formStateis a Proxy; reading a property during render subscribes to it. A property read only in a callback never subscribes, so its changes are not reflected in the UI. Destructuring at the top of the component is the habit that prevents this .isValidis the most expensive subscription. Reading it opts the form into validating every field on every change, even withmode: "onSubmit". UseisSubmittingfor button state, or accept the cost and setmode: "onChange"deliberately .- The schema must be hoisted. An inline schema in
useFormallocates a new validator tree on every render, which is once per keystroke underonChangemode. Module scope or a memoized factory is the fix . - Isolate subscriptions to leaf components. A
FieldErrorthat readsuseFormState({ control, name })re-renders only when that field’s error changes. ABioCounterthat readsuseWatchre-renders only when the bio changes . shouldUnregisteris a trade-off, not a default. It removes unmounted field values from the form data, but it conflicts withuseFieldArrayand causessetValueto miss fields in unmounted nested components. Use it only when hidden-field submission is the problem it solves .
Remember: Large-form performance is not about the library’s speed; it is about how much of the form re-renders when a single field changes. Read formState properties where they are used, isolate those reads into the components that display them, hoist the schema out of the render path, and treat shouldUnregister as a deliberate choice with known interactions. The form with fifty fields and no subscriptions is faster than the form with five fields and a wide formState destructure.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!