Back to Blog

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.

Viprasol Tech Team
October 21, 2026
13 min read

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


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.

React engineering โ†’ | Start a project โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.