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.
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
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>
);
<Progress value={((step + 1) / totalSteps) * 100} className="mb-4" />
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.
Retain user input between page refreshes.
Provide a seamless experience if the user accidentally leaves or reloads the page.
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;
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.
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.
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. 🚀
👨💻 Programmer | ✈️ Love Traveling | 🍳 Enjoy Cooking | Building cool tech and exploring the world!
View more blogs by me CLICK HERE
Loading related blogs...
In this newsletter we provide latest news about technology, business and startup ideas. Hope you like it.