Zod 19 🛡️ React Hook Form Advanced — useFieldArray, multi-step forms, and async validation with debouncing
The previous chapter covered the basic resolver connection. This chapter covers the three patterns that turn a single-screen form into a real application: dynamic field arrays that add and remove rows, multi-step wizards that validate one screen at a time, and asynchronous uniqueness checks that debounce before hitting the network. Each pattern has a failure mode that the basic setup does not address—a field array keyed by index swaps values on reorder, a wizard that validates the whole schema on every step blocks progression for fields the user has not reached, and an async check without debouncing fires a request on every keystroke.
This chapter covers the useFieldArray API and its key rule, the per-step validation pattern that uses trigger with a field list, and the debounced async check that uses setError and clearErrors rather than an async Zod refinement. The sections that follow walk through each pattern and the integration points between them.
Key point: Field arrays use field.id as the render key and write array-level errors to .root; multi-step forms validate only the current step’s fields with trigger(fieldsForStep); and async checks debounce the input, then call setError/clearErrors manually rather than putting the API call inside a Zod refinement.
a. useFieldArray and the array-level error
useFieldArray manages a list of objects inside the form. It returns fields (the rows with generated identifiers) and mutation methods: append, prepend, insert, remove, swap, move, update, and replace . The schema defines the array shape and its length constraints; the hook manages the UI state.
const StoreSchema = z.object({
storeName: z.string().min(2),
products: z.array(z.object({
name: z.string().min(2, "Name must be at least 2 characters."),
price: z.string().min(1, "Price is required."),
})).min(1, "Add at least one product.").max(3, "At most 3 products."),
});
const { control, register, formState: { errors } } = useForm({
resolver: zodResolver(StoreSchema),
defaultValues: { storeName: "", products: [{ name: "", price: "" }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "products" });
The render key must be field.id, not the array index. React Hook Form assigns each row a unique id; using the index causes React to reuse DOM nodes by position, so removing a row from the middle makes the values of subsequent rows shift into the wrong inputs . The append call requires a complete object—every field of the row with its default value—because a partial object leaves the missing fields undefined .
Every <button> inside a form submits by default. The remove and add buttons must have type="button" or they will trigger form submission instead of their handler . The remove button should also carry an aria-label that identifies the row, because a series of buttons that all say “Remove” are indistinguishable to a screen reader .
The array’s own length constraints (min(1), max(3)) produce errors on the array, not on a row. The resolver writes these to errors.products.root, and when the array is completely empty, to errors.products.message. The display code must read both locations .
const listError = errors.products?.root?.message ?? errors.products?.message;
{listError && <p role="alert">{listError}</p>}
b. Multi-step forms with per-step validation
A wizard validates one screen at a time. The naive approach—validating the whole schema before advancing—blocks the user on fields they have not reached yet. The correct pattern is to call trigger with the list of field names that belong to the current step.
const Step1Schema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1) });
const Step2Schema = z.object({ email: z.email() });
const TOTAL_STEPS = 2;
const [currentStep, setCurrentStep] = useState(1);
const methods = useForm({
resolver: zodResolver(FullSchema),
mode: "onBlur",
defaultValues: { firstName: "", lastName: "", email: "" },
});
const handleNext = async () => {
const fieldsForStep = currentStep === 1
? ["firstName", "lastName"]
: ["email"];
const isValid = await methods.trigger(fieldsForStep);
if (isValid) setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS));
};
The trigger(fields) call validates only the listed fields and returns a boolean . The mode: "onBlur" setting means each field validates when it loses focus, so the user sees per-field errors before clicking Next. The full schema is still the resolver, which means the final handleSubmit validates everything, but the step navigation uses the per-step list.
The field list must be derived from the step, not hardcoded in the handler. A getFieldsForStep(step) function keeps the mapping in one place and makes adding a step a change to the schema and the mapping, not to the navigation logic . For forms where the step count is large, a headless library like react-formsteps wraps this pattern with useSteps and useStepForm, calling nextWithValidation to validate the current step’s schema before advancing .
c. Async validation with debouncing
An async uniqueness check—”is this username taken?”—cannot live inside a Zod refinement with zodResolver. The resolver calls parse synchronously, and an async refinement throws an error that the resolver cannot await . The working pattern is to debounce the input, call the API, and use setError and clearErrors to inject or remove the message.
const debouncedCheck = useCallback(
debounce(async (username: string) => {
if (username.length < 3) return;
setIsChecking(true);
try {
const available = await checkUsernameAPI(username);
if (!available) {
setError("username", {
type: "manual",
message: "This username is already taken.",
});
} else {
clearErrors("username");
}
} catch {
setError("username", { type: "manual", message: "Could not verify username." });
} finally {
setIsChecking(false);
}
}, 500),
[setError, clearErrors]
);
const username = watch("username");
useEffect(() => {
if (username) debouncedCheck(username);
}, [username, debouncedCheck]);
The debounce delay prevents a request on every keystroke. The watch("username") subscribes to the field, and the effect triggers the debounced call when the value changes . The setError with type: "manual" adds a field error that the form displays alongside the schema’s errors; clearErrors removes it when the check passes .
A known pitfall is that lodash.debounce does not play well with promises in some resolver configurations. The workaround documented in the React Hook Form discussions is to switch to awesome-debounce-promise or to manage the debounce outside the resolver entirely, which is the pattern above . The mode: "onChange" setting should be avoided for the field with the async check because it triggers validation on every keystroke; onBlur or onTouched is the correct mode when the check is debounced externally.
Complete Example Session
// ============================================
// PART 1: THE SCHEMA WITH FIELD ARRAY
// ============================================
import { z } from "zod";
const SignupSchema = z.object({
username: z.string().min(3).max(20),
contacts: z.array(z.object({
name: z.string().min(1, "Name is required."),
email: z.email("Invalid email."),
})).min(1, "Add at least one contact."),
});
// ============================================
// PART 2: THE MULTI-STEP FORM
// ============================================
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useCallback, useEffect } from "react";
import { debounce } from "lodash";
function SignupWizard() {
const [step, setStep] = useState(1);
const [isChecking, setIsChecking] = useState(false);
const {
register, control, watch, setError, clearErrors, trigger,
handleSubmit, formState: { errors },
} = useForm({
resolver: zodResolver(SignupSchema),
mode: "onBlur",
defaultValues: { username: "", contacts: [{ name: "", email: "" }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "contacts" });
// Debounced username check
const debouncedCheck = useCallback(
debounce(async (username: string) => {
if (username.length < 3) return;
setIsChecking(true);
try {
const available = await checkUsernameAPI(username);
if (!available) {
setError("username", { type: "manual", message: "Username taken." });
} else {
clearErrors("username");
}
} finally {
setIsChecking(false);
}
}, 500),
[setError, clearErrors]
);
const username = watch("username");
useEffect(() => { if (username) debouncedCheck(username); }, [username, debouncedCheck]);
// Per-step navigation
const handleNext = async () => {
const fieldsForStep = step === 1 ? ["username"] : ["contacts"];
const isValid = await trigger(fieldsForStep);
if (isValid) setStep((s) => Math.min(s + 1, 2));
};
return (
<form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
{step === 1 && (
<div>
<label htmlFor="username">Username</label>
<input id="username" {...register("username")} />
{isChecking && <span>Checking…</span>}
{errors.username && <p role="alert">{errors.username.message}</p>}
</div>
)}
{step === 2 && (
<fieldset>
<legend>Contacts</legend>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`contacts.${index}.name`)} placeholder="Name" />
<input {...register(`contacts.${index}.email`)} placeholder="Email" />
<button type="button" onClick={() => remove(index)} aria-label={`Remove contact ${index + 1}`}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: "", email: "" })}>
Add contact
</button>
{(errors.contacts?.root?.message ?? errors.contacts?.message) && (
<p role="alert">{errors.contacts?.root?.message ?? errors.contacts?.message}</p>
)}
</fieldset>
)}
{step < 2 && <button type="button" onClick={handleNext}>Next</button>}
{step === 2 && <button type="submit">Submit</button>}
</form>
);
}
The example combined the three patterns: the field array with field.id and array-level error display, the per-step trigger validation, and the debounced username check with setError/clearErrors.
Quick Reference
useFieldArray API
| Method | Purpose |
|---|---|
fields | Rows with id (v8: key) |
append(value) | Add row at end (complete object) |
remove(index) | Remove row |
swap(from, to) | Swap two rows |
move(from, to) | Move a row |
Multi-Step Validation
| Pattern | Detail |
|---|---|
trigger(fields) | Validate only listed fields |
| Field list per step | Map step number to field names |
mode: "onBlur" | Per-field errors before Next |
| Full schema | Still used by handleSubmit |
Async Check
| Pattern | Detail |
|---|---|
| Debounce | 500ms delay on input change |
setError | Inject manual error (type: "manual") |
clearErrors | Remove manual error on success |
watch | Subscribe to field value |
Best Practices
✅ Do This:
// Use field.id as the key
fields.map((field) => <div key={field.id} />); // ✅
// Pass a complete object to append
append({ name: "", email: "" }); // ✅
// Validate only current step fields
await trigger(fieldsForStep); // ✅
// Debounce async checks
debounce(async (v) => { ... }, 500); // ✅
// Read array-level error from .root
errors.contacts?.root?.message ?? errors.contacts?.message; // ✅
❌ Don’t Do This:
// Use index as key
fields.map((_, i) => <div key={i} />); // ❌
// Stack append and remove in one click
onClick={() => { append({}); remove(0); }}; // ❌
// Forget type="button"
<button onClick={remove}>Remove</button>; // ❌
// Put async API call in Zod refinement with resolver
z.string().refine(async (v) => await check(v)); // ❌
// Use onChange mode with external debounce
mode: "onChange"; // triggers on every keystroke // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Values swap on row remove | index used as key | Use field.id |
| Array error not displayed | Read from .message only | Read .root?.message ?? .message |
| Next button blocked by other step | trigger() called with no args | Pass current step field list |
| Request on every keystroke | No debounce | Wrap in debounce |
| Async refinement throws | zodResolver uses sync parse | Use setError externally |
Real-World Examples
1. Field array with field.id
fields.map((field) => <input key={field.id} {...register(`items.${index}.name`)} />);
2. Complete object to append
append({ name: "", price: "" });
3. Remove with type="button"
<button type="button" onClick={() => remove(i)}>Remove</button>
4. Array-level error
const err = errors.products?.root?.message ?? errors.products?.message;
5. Per-step trigger
await trigger(["firstName", "lastName"]);
6. Debounced check
const debounced = debounce(async (v) => { ... }, 500);
7. Manual error injection
setError("username", { type: "manual", message: "Taken" });
8. Clear on success
clearErrors("username");
9. Watch for changes
const username = watch("username");
10. Disable while checking
<button disabled={isChecking}>Next</button>
Visual
Field Array Flow
┌─────────────────────────────────────────────────────────┐
│ fields from useFieldArray │
│ [{ id: "a1b2", name: "" }, { id: "c3d4", name: "" }] │
│ │
│ key={field.id} ✓ React tracks by identity │
│ key={index} ✗ React tracks by position │
│ │
│ remove(0) │
│ ┌──────────────────────────────────────────┐ │
│ │ field.id: values stay with their row │ │
│ │ index: values shift into wrong inputs │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Multi-Step Validation
┌─────────────────────────────────────────────────────────┐
│ Step 1: username │
│ Step 2: contacts │
│ │
│ handleNext │
│ │ │
│ ▼ │
│ trigger(["username"]) ◄── only step 1 fields │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ valid invalid │
│ │ │ │
│ ▼ ▼ │
│ setStep stay, show errors │
└─────────────────────────────────────────────────────────┘
Debounced Async Check
┌─────────────────────────────────────────────────────────┐
│ User types: "a" → "an" → "ana" │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ debounce waits 500ms after last keystroke │
│ │ │
│ ▼ │
│ checkUsernameAPI("ana") │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ taken available │
│ │ │ │
│ ▼ ▼ │
│ setError clearErrors │
└─────────────────────────────────────────────────────────┘
Array Error Location
┌─────────────────────────────────────────────────────────┐
│ products: z.array(...).min(1, "Add at least one") │
│ │
│ Empty array → errors.products.message │
│ Array has items but min fails → errors.products.root │
│ │
│ const err = errors.products?.root?.message │
│ ?? errors.products?.message; │
│ {err && <p role="alert">{err}</p>} │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Field array hook | useFieldArray({ control, name }) |
| Render key | field.id (v8: field.key) |
| Append argument | Complete object with all fields |
| Array-level error | .root?.message ?? .message |
| Per-step validation | trigger(fieldsForStep) |
| Async check | Debounce + setError/clearErrors |
| Async in resolver | Does not work (sync parse) |
Key takeaways:
- Field arrays require
field.idas the key. Using the index causes values to swap when a row is removed from the middle, because React reuses DOM nodes by position . - Array-level errors live at
.root. Themin(1)andmax(3)constraints on the array produce errors aterrors.arrayName.root, and when the array is empty, aterrors.arrayName.message. Read both . - Multi-step forms validate one step at a time.
trigger(fieldsForStep)validates only the listed fields and returns a boolean. The full schema is still the resolver, but navigation uses the per-step list . - Async checks cannot live in a Zod refinement with
zodResolver. The resolver parses synchronously and cannot await an async refinement. Debounce the input externally, call the API, and usesetError/clearErrorsto inject the result . - Debouncing prevents request floods. A 500ms delay after the last keystroke means one request per pause, not one per character .
Remember: The advanced patterns are where the basic resolver setup meets the real application. Field arrays need stable keys and array-level error handling. Multi-step forms need per-step field lists and trigger. Async checks need to live outside the resolver, with debouncing and manual error injection. Each pattern has one failure mode that the basic setup does not prevent—values swapping on reorder, the Next button blocked by unreached fields, and a request on every keystroke—and each fix is a specific rule rather than a general principle.
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!