<Sagar/>

Build a Validated Multi-Step Form with ShadCN & React Hook Form (Step-by-Step Guide)

347

If you’re developing a multistep form in Next.js, ensuring validation at every step can improve user experience. In this guide, we’ll use ShadCN UI, React Hook Form, and Zod to create a dynamic form with step-specific validation.

Step 1: Build the Multistep Form Logic

The core of your multistep form lies in handling navigation between steps and validating inputs. Here’s how you can structure your form:

"use client"
import { useState, useRef } from "react"
import { z } from "zod"
import { Progress } from "@/components/ui/progress"


import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"

import { Button } from "@/components/ui/button"
import { StepOne, StepTwo } from "@/components/trip/trip-input-steps"

const stepSchemas = [
    z.object({
        destination: z.string().min(1, "Destination is required"),
        tripDuration: z
  .string()
  .transform((value) => parseInt(value, 10))
  .refine((value) => value >= 1, {
    message: "Trip duration must be at least 1 day.",
  }),
    }),
    z.object({
        groupSize: z.string().min(1, "Group size must be at least 1"),
        budget: z.string().min(3, "please fill your budget in ruppes")
    }),
];
const fullSchemas = stepSchemas.reduce((acc, schema) => acc.merge(schema), z.object({}))

function Page() {
    const [loading, setLoading] = useState(false)
    const [step, setStep] = useState(0)
    const totalSteps = stepSchemas.length
    const methods = useForm({
        resolver: zodResolver(fullSchemas),
        defaultValues: {
            destination: "",
            tripDuration: 1,
            groupSize: "",
            budget: ""
        }
    })
    const handleNext = () => {
        const currentSchemas = stepSchemas[step]
        const currentStepData = methods.getValues()
        const currentvalidation = currentSchemas.safeParse(currentStepData)
        if (currentvalidation.success) {
            setStep(step + 1)
        } else {
            console.log("error")
        }
    }
    const onSubmit = async (data) => {
        console.log(data)

    }
    const handleBack = () => {
        if (step > 0) {
            setStep(step - 1)
        }
    }
    return (<FormProvider {...methods}>
                        <div className=" max-w-md mx-auto p-4 ">
                            <Progress className="my-4" value={((step + 1) / totalSteps * 100)} />
                            <form onSubmit={methods.handleSubmit(onSubmit)} className="my-10" >
                                {step === 0 && <StepOne />}
                                {step === 1 && <StepTwo />}
                                {/* {step === 2 && <StepThree />} */}
                                <div className="flex justify-between mt-6">
                                    {loading ? (<Button>loading...</Button>) :

                                        (<> <Button onClick={handleBack} disabled={step === 0}>back</Button>
                                            {(step < totalSteps - 1) ? (<Button onClick={handleNext} >next</Button>) : (<Button type="submit">submit</Button>)}</>)
                                    }

                                </div>
                            </form>
                        </div>
                    </FormProvider>
    )
}

export default Page

Step 2: Create Step Components

For simplicity and reusability, extract each step into its own component:

import React from "react";

import { Search, MapPin } from "lucide-react";
import {
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";

export const StepOne = () => (
  <div className="py-5 ">
    <FormField
      name="destination"
      render={({ field }) => (
        <FormItem className="relative mb-5">
          <FormLabel>Let start with your dream destination.</FormLabel>
          <FormControl>
            <div className="relative">
              <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
                <Search className="h-5 w-5 text-gray-400" />
              </div>
              <Input
                placeholder="Search your place"
                {...field}}
                className="pl-10 pr-4 py-2 border-2 border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              />
            </div>
          </FormControl>
          

          <FormMessage />
        </FormItem>
      )}
    />
    <FormField
      name="tripDuration"
      render={({ field }) => (
        <FormItem>
          <FormLabel>Share the number of days you want to travel</FormLabel>
          <FormControl>
            <div>
              <Input type="number" placeholder="Enter group size" {...field} />
            </div>
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  </div>
);
export const StepTwo = () => (
  <div className="my-4">
    <FormField
      name="groupSize"
      render={({ field }) => (
        <FormItem>
          <FormLabel>Travelling with</FormLabel>
          <Select onValueChange={field.onChange} defaultValue={field.value}>
            <FormControl>
              <SelectTrigger>
                <SelectValue placeholder="Select number of traveller" />
              </SelectTrigger>
            </FormControl>
            <SelectContent>
              <SelectItem value="Couple">Couple </SelectItem>
              <SelectItem value="Single">Single</SelectItem>
              <SelectItem value="Family">Family</SelectItem>
              <SelectItem value="Friends">Friends</SelectItem>
            </SelectContent>
          </Select>
          <FormMessage />
        </FormItem>
      )}
    />
    <FormField
      name="budget"
      render={({ field }) => (
        <FormItem>
          <FormLabel>Trip Budget</FormLabel>
          <Select onValueChange={field.onChange} defaultValue={field.value}>
            <FormControl>
              <SelectTrigger>
                <SelectValue placeholder="Select a Budget" />
              </SelectTrigger>
            </FormControl>
            <SelectContent>
              <SelectItem value="cheap">Cheap </SelectItem>
              <SelectItem value="Moderate">Moderate</SelectItem>
              <SelectItem value="Luxury">Luxury</SelectItem>
            </SelectContent>
          </Select>
          <FormMessage />
        </FormItem>
      )}
    />
  </div>
);

Step 3: Add Progress Tracking

<Progress value={((step + 1) / totalSteps) * 100} className="mb-4" />

Step 4: Bonus Tip (Saving Form Values in Local Storage While Navigating Between Steps)

To enhance the user experience by saving form values to local storage during a multi-step form process, follow this detailed implementation. This ensures that users’ inputs are not lost when they navigate between steps or refresh the page.

Why Save to Local Storage?

  • Retain user input between page refreshes.

  • Provide a seamless experience if the user accidentally leaves or reloads the page.

Implementation

Step 1: Modify the State to Sync with Local Storage

Enhance the useState to initialize form values from local storage if available:

  import React, { useState, useEffect, useRef } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";

const stepSchemas = [
  z.object({
    destination: z.string().min(1, "Destination is required"),
    tripDuration: z
      .string()
      .transform((value) => parseInt(value, 10))
      .refine((value) => value >= 1, {
        message: "Trip duration must be at least 1 day.",
      }),
  }),
  z.object({
    groupSize: z.string().min(1, "Group size is required"),
    budget: z.string().min(3, "Please provide a budget"),
  }),
];

const fullSchema = stepSchemas.reduce((acc, schema) => acc.merge(schema), z.object({}));

function MultiStepForm() {
  const [step, setStep] = useState(0);
  const totalSteps = stepSchemas.length;

  const methods = useForm({
    resolver: zodResolver(fullSchema),
    defaultValues: JSON.parse(localStorage.getItem("formData")) || {
      destination: "",
      tripDuration: 1,
      groupSize: "",
      budget: "",
    },
  });

  const { watch, handleSubmit, getValues, setValue } = methods;

  // Watch for changes and sync with local storage
  useEffect(() => {
    const subscription = watch((data) => {
      localStorage.setItem("formData", JSON.stringify(data));
    });
    return () => subscription.unsubscribe();
  }, [watch]);

  const handleNext = () => {
    const currentSchema = stepSchemas[step];
    const currentData = getValues();
    const validationResult = currentSchema.safeParse(currentData);

    if (validationResult.success) {
      setStep(step + 1);
    } else {
      console.error("Validation errors:", validationResult.error);
    }
  };

  const handleBack = () => {
    if (step > 0) setStep(step - 1);
  };

  const handleSubmitForm = (data) => {
    console.log("Form submitted:", data);
    localStorage.removeItem("formData");
  };

  return <div className=" h-screen">

                    <FormProvider {...methods}>
                        <div className=" max-w-md mx-auto p-4 ">
                            <Progress className="my-4" value={((step + 1) / totalSteps * 100)} />
                            <form onSubmit={methods.handleSubmit(onSubmit)} className="my-10" >
                                {step === 0 && <StepOne />}
                                {step === 1 && <StepTwo />}
                                {/* {step === 2 && <StepThree />} */}
                                <div className="flex justify-between mt-6">
                                    {loading ? (<Button>loading...</Button>) :

                                        (<> <Button onClick={handleBack} disabled={step === 0}>back</Button>
                                            {(step < totalSteps - 1) ? (<Button onClick={handleNext} >next</Button>) : (<Button type="submit">submit</Button>)}</>)
                                    }

                                </div>
                            </form>
                        </div>
                    </FormProvider>

export default MultiStepForm;
  1. watch:
    1. watch is a function from React Hook Form that monitors changes in form values.
    2. It provides the current state of the form whenever any field’s value changes.

  2. localStorage.setItem("formData", JSON.stringify(data))
    1. Whenever the form data changes, this function saves the updated data to localStorage under the key "formData".
    2. This ensures that the form state is persistently saved between page refreshes or navigations.

  3. return () => subscription.unsubscribe():
    1. watch returns a subscription, which is an object that you can use to stop watching form changes.
    2. When the component unmounts, this cleanup function runs to unsubscribe from the watch updates, preventing memory leaks.

Note: please change UI of form

💥 Did you find this blog helpful? 💥

If you enjoyed this post, please clap and follow for more insights on web development and Next.js! Your support helps me continue sharing useful content to enhance your development journey. 🚀

JavaScript
Next.js
Web Development
UI/UX
React
Arnold Gunter

Written by Sagar Sangwan

👨‍💻 Programmer | ✈️ Love Traveling | 🍳 Enjoy Cooking | Building cool tech and exploring the world!

View more blogs by me CLICK HERE

Loading related blogs...

Newsletter subscription

SUBSCRIBE to Newsletter

In this newsletter we provide latest news about technology, business and startup ideas. Hope you like it.