| | |

Zod 19 🛡️ React Hook Form Advanced — useFieldArray, multi-step forms, and async validation with debouncing

⏱️ Estimated read: 11 min | |

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

MethodPurpose
fieldsRows 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

PatternDetail
trigger(fields)Validate only listed fields
Field list per stepMap step number to field names
mode: "onBlur"Per-field errors before Next
Full schemaStill used by handleSubmit

Async Check

PatternDetail
Debounce500ms delay on input change
setErrorInject manual error (type: "manual")
clearErrorsRemove manual error on success
watchSubscribe 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

PitfallWhy It HappensFix
Values swap on row removeindex used as keyUse field.id
Array error not displayedRead from .message onlyRead .root?.message ?? .message
Next button blocked by other steptrigger() called with no argsPass current step field list
Request on every keystrokeNo debounceWrap in debounce
Async refinement throwszodResolver uses sync parseUse 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

ItemValue
Field array hookuseFieldArray({ control, name })
Render keyfield.id (v8: field.key)
Append argumentComplete object with all fields
Array-level error.root?.message ?? .message
Per-step validationtrigger(fieldsForStep)
Async checkDebounce + setError/clearErrors
Async in resolverDoes not work (sync parse)

Key takeaways:

  • Field arrays require field.id as 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. The min(1) and max(3) constraints on the array produce errors at errors.arrayName.root, and when the array is empty, at errors.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 use setError/clearErrors to 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.

Was this helpful?


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!