React Hook Form + Zod: Complex Validation, File Uploads, and Multi-Step Wizards
Master React Hook Form with Zod validation: build complex nested forms, validate file uploads with type and size constraints, implement multi-step wizard forms with shared state, and handle async server-side validation without compromising UX.
React Hook Form (RHF) is the standard for performant React forms โ it avoids unnecessary re-renders by using uncontrolled components and only triggers renders on submission and validation. Zod provides the schema validation layer with TypeScript type inference so form values are typed from schema definition to submission handler.
The combination: useForm with zodResolver gives you a fully typed form with declarative validation, zero boilerplate, and no performance problems on large forms.
Basic Setup
// src/components/forms/CreateProjectForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Schema: source of truth for both validation and TypeScript types
const createProjectSchema = z.object({
name: z
.string()
.trim()
.min(1, "Project name is required")
.max(100, "Name must be 100 characters or less"),
description: z.string().max(500, "Description must be 500 characters or less").optional(),
visibility: z.enum(["private", "team", "public"], {
errorMap: () => ({ message: "Please select a visibility option" }),
}),
tags: z
.array(z.string().min(1).max(50))
.max(10, "Maximum 10 tags")
.default([]),
deadline: z
.string()
.optional()
.refine(
(val) => !val || new Date(val) > new Date(),
"Deadline must be in the future"
),
});
type CreateProjectFormValues = z.infer<typeof createProjectSchema>;
export function CreateProjectForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, dirtyFields },
setError,
reset,
watch,
} = useForm<CreateProjectFormValues>({
resolver: zodResolver(createProjectSchema),
defaultValues: {
visibility: "private",
tags: [],
},
mode: "onBlur", // Validate on field blur (not every keystroke)
});
const onSubmit = async (data: CreateProjectFormValues) => {
try {
const result = await createProject(data);
reset();
// navigate to new project
} catch (error) {
// Map server errors back to specific fields
if (error instanceof ValidationError) {
for (const [field, message] of Object.entries(error.fieldErrors)) {
setError(field as keyof CreateProjectFormValues, {
type: "server",
message,
});
}
} else {
setError("root", { message: "Failed to create project. Please try again." });
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div>
<label htmlFor="name" className="block text-sm font-medium">
Project Name <span className="text-red-500">*</span>
</label>
<input
id="name"
type="text"
{...register("name")}
className={`mt-1 block w-full rounded border px-3 py-2 ${
errors.name ? "border-red-500" : "border-gray-300"
}`}
aria-describedby={errors.name ? "name-error" : undefined}
aria-invalid={!!errors.name}
/>
{errors.name && (
<p id="name-error" className="mt-1 text-sm text-red-600" role="alert">
{errors.name.message}
</p>
)}
</div>
{/* Root-level errors (server errors) */}
{errors.root && (
<p className="text-sm text-red-600 bg-red-50 p-3 rounded" role="alert">
{errors.root.message}
</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Creating..." : "Create Project"}
</button>
</form>
);
}
File Upload Validation
// src/components/forms/AvatarUploadForm.tsx
"use client";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useState } from "react";
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
const MAX_FILE_SIZE_MB = 5;
const avatarSchema = z.object({
avatar: z
.custom<FileList>()
.refine((files) => files?.length === 1, "Please select a file")
.refine(
(files) => files?.[0]?.size <= MAX_FILE_SIZE_MB * 1024 * 1024,
`Maximum file size is ${MAX_FILE_SIZE_MB}MB`
)
.refine(
(files) => ACCEPTED_IMAGE_TYPES.includes(files?.[0]?.type),
"Only JPEG, PNG, and WebP images are accepted"
),
cropX: z.number().min(0).default(0),
cropY: z.number().min(0).default(0),
});
type AvatarFormValues = z.infer<typeof avatarSchema>;
export function AvatarUploadForm() {
const [preview, setPreview] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
} = useForm<AvatarFormValues>({
resolver: zodResolver(avatarSchema),
});
// Watch file input to show preview
const avatarFiles = watch("avatar");
useEffect(() => {
const file = avatarFiles?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [avatarFiles]);
const onSubmit = async (data: AvatarFormValues) => {
const formData = new FormData();
formData.append("avatar", data.avatar[0]);
formData.append("cropX", String(data.cropX));
formData.append("cropY", String(data.cropY));
await uploadAvatar(formData); // Server action
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="avatar" className="block text-sm font-medium">
Profile Picture
</label>
<input
id="avatar"
type="file"
accept={ACCEPTED_IMAGE_TYPES.join(",")}
{...register("avatar")}
className="mt-1 block"
/>
{errors.avatar && (
<p className="mt-1 text-sm text-red-600">{errors.avatar.message as string}</p>
)}
</div>
{preview && (
<img
src={preview}
alt="Upload preview"
className="h-24 w-24 rounded-full object-cover"
/>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Uploading..." : "Upload Avatar"}
</button>
</form>
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Multi-Step Wizard Form
// src/components/forms/OnboardingWizard.tsx
"use client";
import { useForm, FormProvider, useFormContext } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useState } from "react";
// Full schema for all steps
const onboardingSchema = z.object({
// Step 1: Account
companyName: z.string().min(1, "Company name is required"),
companySize: z.enum(["1-10", "11-50", "51-200", "201-1000", "1000+"]),
industry: z.string().min(1, "Industry is required"),
// Step 2: Use case
primaryUseCase: z.enum(["project-management", "reporting", "api", "other"]),
teamCount: z.number().int().min(1).max(10000),
// Step 3: Setup
timezone: z.string().min(1, "Timezone is required"),
notificationsEmail: z.boolean().default(true),
notificationsSlack: z.boolean().default(false),
slackWebhook: z.string().url().optional().or(z.literal("")),
}).refine(
(data) => !data.notificationsSlack || !!data.slackWebhook,
{
message: "Slack webhook URL is required when Slack notifications are enabled",
path: ["slackWebhook"],
}
);
type OnboardingValues = z.infer<typeof onboardingSchema>;
// Each step validates only the fields on that step
const stepSchemas = [
onboardingSchema.pick({ companyName: true, companySize: true, industry: true }),
onboardingSchema.pick({ primaryUseCase: true, teamCount: true }),
onboardingSchema.pick({ timezone: true, notificationsEmail: true, notificationsSlack: true, slackWebhook: true }),
];
const STEPS = ["Your Company", "Use Case", "Preferences"] as const;
export function OnboardingWizard() {
const [currentStep, setCurrentStep] = useState(0);
// Single form instance shared across all steps
const methods = useForm<OnboardingValues>({
resolver: zodResolver(onboardingSchema),
mode: "onBlur",
defaultValues: {
companySize: "1-10",
teamCount: 1,
notificationsEmail: true,
notificationsSlack: false,
},
});
const { handleSubmit, trigger } = methods;
// Validate only current step's fields before advancing
const goToNextStep = async () => {
const stepSchema = stepSchemas[currentStep];
const stepFields = Object.keys(stepSchema.shape) as (keyof OnboardingValues)[];
const valid = await trigger(stepFields);
if (valid) {
setCurrentStep((s) => Math.min(s + 1, STEPS.length - 1));
}
};
const onSubmit = async (data: OnboardingValues) => {
await completeOnboarding(data);
};
return (
<FormProvider {...methods}>
<div className="max-w-lg mx-auto">
{/* Step indicator */}
<div className="flex mb-8">
{STEPS.map((step, i) => (
<div key={step} className="flex-1 text-center">
<div
className={`h-8 w-8 rounded-full mx-auto flex items-center justify-center text-sm font-medium ${
i < currentStep
? "bg-blue-600 text-white"
: i === currentStep
? "border-2 border-blue-600 text-blue-600"
: "bg-gray-100 text-gray-400"
}`}
>
{i < currentStep ? "โ" : i + 1}
</div>
<p className="text-xs mt-1 text-gray-500">{step}</p>
</div>
))}
</div>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Render only the current step */}
{currentStep === 0 && <StepCompany />}
{currentStep === 1 && <StepUseCase />}
{currentStep === 2 && <StepPreferences />}
<div className="flex justify-between mt-6">
{currentStep > 0 && (
<button
type="button"
onClick={() => setCurrentStep((s) => s - 1)}
className="px-4 py-2 border rounded"
>
Back
</button>
)}
{currentStep < STEPS.length - 1 ? (
<button
type="button"
onClick={goToNextStep}
className="ml-auto px-4 py-2 bg-blue-600 text-white rounded"
>
Next
</button>
) : (
<button
type="submit"
disabled={methods.formState.isSubmitting}
className="ml-auto px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
>
{methods.formState.isSubmitting ? "Setting up..." : "Complete Setup"}
</button>
)}
</div>
</form>
</div>
</FormProvider>
);
}
// Step component โ accesses shared form state via useFormContext
function StepCompany() {
const { register, formState: { errors } } = useFormContext<OnboardingValues>();
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium">Company Name</label>
<input {...register("companyName")} className="mt-1 w-full border rounded px-3 py-2" />
{errors.companyName && (
<p className="text-sm text-red-600 mt-1">{errors.companyName.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium">Company Size</label>
<select {...register("companySize")} className="mt-1 w-full border rounded px-3 py-2">
{["1-10", "11-50", "51-200", "201-1000", "1000+"].map((size) => (
<option key={size} value={size}>{size} employees</option>
))}
</select>
</div>
</div>
);
}
Async Server-Side Validation
// Validate email uniqueness on blur โ not on every keystroke
const signupSchema = z.object({
email: z
.string()
.email("Invalid email address")
// Async refinement: called only after synchronous validation passes
.refine(async (email) => {
const response = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
const { available } = await response.json();
return available;
}, "This email is already registered"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
// Use with async resolver
const form = useForm({
resolver: zodResolver(signupSchema),
mode: "onBlur", // Async validation only fires on blur โ prevents excessive API calls
});
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
See Also
- React Server Actions โ server-side form handling
- TypeScript Advanced Patterns โ Zod schema inference
- Web Accessibility Engineering โ accessible form patterns
- SaaS Onboarding UX โ onboarding wizard design
Working With Viprasol
Complex forms โ multi-step wizards, file uploads with validation, dynamic field arrays, cross-field validation โ are where form libraries earn their keep. Our React engineers implement form architectures that handle real-world complexity without performance problems or validation gaps.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.