React Hook Form plus Zod, multi-step wizards, useFieldArray, cross-field and async validation. The patterns I keep reaching for, the ones I've stopped trusting.
The first time I shipped a form-heavy surface on the creator-economy platform I worked at, I put it together with controlled components and a homemade reducer. Felt fine in the demo. Then a creator opened the App Builder Studio, added a list of upsell offers, started typing in one of the price fields, and the whole canvas re-rendered on every keystroke. The drag preview stuttered. The phone-frame preview lagged half a second behind. I’d built a form. I’d also accidentally built a frame-rate problem.
That was the week I stopped writing form state by hand.
For anything past three fields, I reach for React Hook Form plus Zod. Uncontrolled by default, refs under the hood, no re-render on every keystroke, and Zod gives you one schema that’s also your TypeScript type. That’s the boring center I trust.
I’m opinionated about this. Formik is fine but it re-renders too much. Final Form is fine and almost nobody is reaching for it anymore. Building your own with useReducer is fine until your form grows a dynamic array, a step, or an async check, and then you’ve reinvented half a library on a Friday afternoon.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const upsellSchema = z.object({
title: z.string().min(2, "Give it a title creators will recognize"),
priceCents: z
.number({ invalid_type_error: "Price must be a number" })
.int()
.min(100, "Stripe rejects anything under one dollar"),
sku: z
.string()
.regex(/^[a-z0-9-]+$/, "Lowercase, digits, dashes only"),
});
export type UpsellInput = z.infer<typeof upsellSchema>;
export function UpsellForm({ onSubmit }: { onSubmit: (v: UpsellInput) => Promise<void> }) {
const { register, handleSubmit, formState } = useForm<UpsellInput>({
resolver: zodResolver(upsellSchema),
mode: "onBlur",
reValidateMode: "onChange",
shouldFocusError: true,
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="title">Offer title</label>
<input id="title" aria-invalid={!!formState.errors.title} {...register("title")} />
{formState.errors.title ? (
<p role="alert">{formState.errors.title.message}</p>
) : null}
<button type="submit" disabled={formState.isSubmitting}>
Save offer
</button>
</form>
);
}
A few things I care about in that snippet that get skipped a lot. mode: "onBlur" so I don’t yell at users mid-typing. aria-invalid plus a role="alert" paragraph so screen readers actually hear the error, not just the sighted user. shouldFocusError: true so submit jumps focus to the broken field. None of that is a flourish, it’s the difference between a usable form and a form that looks fine in Chrome on a 27-inch monitor.
The App Builder onboarding wizard had 6 steps and a “save and exit” everywhere. The naive shape, one big form with conditional rendering per step, gets ugly fast. The other naive shape, six separate forms with parent state passed down, gets ugly faster.
What I land on now is one FormProvider for the whole wizard, schemas merged in, and each step validates only its slice on next.
import { FormProvider, useForm, useFormContext } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const wizardSchema = z.object({
brand: z.object({ name: z.string().min(1), color: z.string().regex(/^#/) }),
pricing: z.object({
monthlyCents: z.number().int().min(0),
annualCents: z.number().int().min(0),
}),
publish: z.object({ slug: z.string().min(3) }),
});
type WizardValues = z.infer<typeof wizardSchema>;
const stepFields: Record<number, (keyof WizardValues)[]> = {
0: ["brand"],
1: ["pricing"],
2: ["publish"],
};
export function Wizard() {
const methods = useForm<WizardValues>({
resolver: zodResolver(wizardSchema),
mode: "onTouched",
defaultValues: loadDraftFromLocalStorage(),
});
const [step, setStep] = React.useState(0);
async function next() {
const ok = await methods.trigger(stepFields[step], { shouldFocus: true });
if (ok) setStep((s) => s + 1);
}
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(saveDraft)}>
{step === 0 ? <BrandStep /> : null}
{step === 1 ? <PricingStep /> : null}
{step === 2 ? <PublishStep /> : null}
<button type="button" onClick={next}>Continue</button>
</form>
</FormProvider>
);
}
trigger against the step’s slice gives partial validation without submitting the whole thing. The kids reach for useFormContext instead of receiving 14 props. Drafts survive a refresh because defaultValues reads from localStorage on mount.
If you’ve shipped a real product you’ve shipped a form with a list. Discount codes. Team members. Variant rows. People reach for useFieldArray and then bind to index and watch their list go feral when items reorder.
The rule that fixes 90 percent of the bugs: key your rendered rows on field.id, never on index. The id is stable across reorders, the index isn’t.
import { useFieldArray, useFormContext } from "react-hook-form";
export function VariantList() {
const { control, register } = useFormContext();
const { fields, append, remove, move } = useFieldArray({ control, name: "variants" });
return (
<ol>
{fields.map((field, index) => (
<li key={field.id}>
<input
aria-label={`Variant ${index + 1} SKU`}
{...register(`variants.${index}.sku` as const)}
/>
<button type="button" onClick={() => move(index, index - 1)} disabled={index === 0}>
Up
</button>
<button type="button" onClick={() => remove(index)}>Remove</button>
</li>
))}
<button type="button" onClick={() => append({ sku: "" })}>Add variant</button>
</ol>
);
}
Other thing folks miss, the as const on the field path. Without it, TypeScript can’t infer the leaf type and you lose the only autocomplete that actually mattered.
Cross-field validation belongs in the schema, not in onChange. Zod’s superRefine is built exactly for this. Async checks belong outside the schema, behind a debounce.
const pricingSchema = z
.object({
monthlyCents: z.number().int().min(100),
annualCents: z.number().int().min(100),
})
.superRefine((v, ctx) => {
if (v.annualCents > v.monthlyCents * 12) {
ctx.addIssue({
code: "custom",
path: ["annualCents"],
message: "Annual price shouldn't exceed twelve months of monthly",
});
}
});
async function checkSlugAvailable(slug: string, signal: AbortSignal) {
const res = await fetch(`/api/slugs/${encodeURIComponent(slug)}`, { signal });
if (!res.ok) throw new Error("slug check failed");
const { available } = await res.json();
return available;
}
Wire that checkSlugAvailable into a debounced useEffect watching the slug field and call setError from React Hook Form when it returns false. Always pass the AbortSignal. A creator typing fast will generate eight in-flight requests and the last one back wins, which is the wrong one.
Back to the visual builder. The bug I opened with had a tail. We caught the keystroke re-render in staging, “fixed” it by wrapping every input in React.memo, called it a day. Looked good locally. Shipped. Two days later the canvas got slow again, this time only for creators with more than 40 components on a page.
First wrong fix, we pulled the previewer out of the same render tree thinking the iframe was the problem. It wasn’t. Re-paint stayed bad.
Real fix took longer. I instrumented useForm with the React Profiler and found that one parent component up was reading formState directly, which subscribes to every field change. The memo on the inputs was useless because the parent above them was thrashing anyway. Swapped that parent to use useFormState({ control }) with a narrowed selector, and the per-keystroke render count for a 40-component page went from 60-plus down to 2. Real fix was a subscription model, not a memo wall. Cost us about a week of degraded editor performance for power users. Lesson, when forms get slow, profile the subscriptions before you reach for memo.
If your form gets slow, the order I check things in is roughly this. Pull formState reads up into useFormState with a selector. Move heavy children behind Controller only where you genuinely need a controlled input. Use watch with a field name, never bare watch() at a parent. Don’t memo your way out of a subscription problem, narrow the subscription.
Real label elements, not placeholders pretending to be labels. aria-invalid on broken fields. aria-describedby pointing at the error text so screen readers read them together. role="alert" on the error container so changes are announced. autoComplete attributes that match the WCAG token list. And, this one trips people up, shouldFocusError: true so submit jumps the user to the first broken field. Keyboard users will notice. Sighted users on small screens will notice. Everyone else won’t, and that’s fine.
FormProvider with per-step trigger, not six forms.useFieldArray keys go on field.id, never on index.superRefine, async behind a debounce with an AbortSignal.memo.Thanks for reading. If you’ve got thoughts, send them my way.