Skip to main content
EJ Centeno

Managing Complex Forms in React with React Hook Form and Zod

February 10, 2026 · 5 min read

Managing Complex Forms in React with React Hook Form and Zod

Enterprise applications are form-heavy. Our stock administration system has forms for ESPP enrollment, LTIP grant profile updates, stock option elections, and beneficiary designations. These aren't simple login forms — they involve nested data structures, conditional field visibility, complex validation rules, and integration with a financial backend that has strict requirements.

React Hook Form paired with Zod has been our solution, and after building a dozen of these forms, I have opinions.

Why React Hook Form

Before joining C3, I used Vee-Validate in Vue projects — a similar validation library that integrates with Vue's reactive form model. React Hook Form operates on a different philosophy: it minimizes re-renders by using uncontrolled inputs where possible and only re-renders on validation or submission.

For complex forms with 20+ fields, this performance difference is noticeable. With naive controlled inputs and all state in useState, typing in a single field triggers a full component re-render. React Hook Form avoids this by using ref-based inputs internally. On a form with many fields and complex conditional logic, this matters.

The Zod Schema as the Single Source of Truth

Zod schemas do two things for us: runtime validation and TypeScript type inference. We define the schema once and use it for both.

For an ESPP subscription form, the schema defines contributionRate as a number between 1 and 15, paymentMethod as one of two enum values, and bankDetails as an optional nested object that becomes required when paymentMethod is bank_transfer. The z.infer utility gives us the TypeScript type for free. We pass this type to useForm, and now the entire form is typed — field names, default values, and the submission data.

The useForm + Controller Pattern

We use useForm with zodResolver from the @hookform/resolvers package. For shadcn/ui form components, we wrap each field in FormField, which takes the form control and a field name and provides a render prop with the field value, onChange, and onBlur handlers already wired up.

Inside the render prop, we render a FormItem containing a FormLabel, a FormControl wrapping our actual input component, and a FormMessage that automatically renders the Zod validation error for that field. No conditional rendering of error messages needed — the FormMessage component handles it.

One nuance: for number inputs, you need to coerce the string value from the DOM input back to a number before passing it to field.onChange. React Hook Form receives all input values as strings by default; Zod expects the type you declared. We handle this by wrapping the onChange handler.

Nested Objects and Arrays

Beneficiary designation forms are a good example of nested complexity. A participant can have multiple beneficiaries, each with their own fields: name, relationship, and percentage allocation. We use useFieldArray for the array part.

useFieldArray takes the form control and a field name pointing to the array in your schema. It returns fields (the current array items), append (to add a new item), and remove (to delete by index). We iterate over fields to render each beneficiary's form section. The schema validation applies to the entire nested structure, so validation errors appear on the correct beneficiary's fields.

Conditional Validation

Some fields are only required under certain conditions. Zod's superRefine method handles complex cross-field validation that can't be expressed with simple field-level schemas.

For the bank transfer payment method example: the bank details fields become required only when that payment method is selected. We add a superRefine at the object level that checks the paymentMethod value and adds a Zod issue on the bankDetails.accountNumber path if the condition is met and the field is empty. This puts the error on the correct field path, so the FormMessage for that field picks it up correctly — even though the validation logic lives at the parent schema level.

Error Handling and UX

Surface errors early and clearly. We use mode: 'onChange' for forms with fields that have format requirements (account numbers, email addresses) so validation triggers as the user types. For more complex multi-step forms, mode: 'onBlur' is less intrusive.

On failed submission, we scroll to the first error field using form.formState.errors to find the first errored field name, then querySelector with the field's id to scroll it into view. This is particularly important for long forms where the error might be above the fold.

React Hook Form and Zod together are verbose to set up, but the result is forms that are correct, performant, and maintainable. For enterprise-grade forms with real validation requirements, that tradeoff is worth it.

← Back to all posts