I am creating a multi-step form in NextJs with shadcn/ui forms, react-hook-form and zod. When I fill the step 1 details and move to step2, fill the details of step2 and come back to step1 by clicking the "previous" button, I see that values of step2 are present in step1 fields.
The signup form
"use client";
import { useState, useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { ChevronRight, ChevronLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
registerSchema,
EntityCategory,
type RegisterStep1,
type RegisterStep2,
} from "./validation";
import { getBrowserTimezone, countryTimezones } from "./timezone";
export default function SignupForm() {
const [step, setStep] = useState(1);
const [availableTimezones, setAvailableTimezones] = useState<
{ name: string; utc: string }[]
>([]);
const step1Form = useForm<RegisterStep1>({
resolver: zodResolver(registerSchema.step1),
defaultValues: {
name: "",
entity: "",
category: undefined,
country: "",
timezone: "",
region: "",
},
});
const step2Form = useForm<RegisterStep2>({
resolver: zodResolver(registerSchema.step2),
defaultValues: {
city: "",
email: "",
contact: "",
password: "",
confirm_password: "",
},
});
useEffect(() => {
const browserTz = getBrowserTimezone();
step1Form.setValue("timezone", browserTz.utc);
}, []);
const onStep1Submit = async (data: RegisterStep1) => {
const result = await registerSchema.step1.safeParseAsync(data);
if (result.success) {
setStep(2);
}
};
const onStep2Submit = async (data: RegisterStep2) => {
const step1Data = await step1Form.getValues();
const step2Result = await registerSchema.step2.safeParseAsync(data);
if (step2Result.success) {
const formData = {
...step1Data,
...data,
};
try {
// Add your API call here
console.log("Form submitted:", formData);
} catch (error) {
console.error("Error submitting form:", error);
}
}
};
const handleCountryChange = (value: string) => {
step1Form.setValue("country", value);
const tzs = countryTimezones[value] || [];
setAvailableTimezones(tzs);
if (tzs.length === 1) {
step1Form.setValue("timezone", tzs[0].utc);
}
};
return (
<Card className="max-w-lg mx-auto w-[500px] min-w-[300px]">
<CardHeader>
<CardTitle className="text-2xl font-bold text-center">
Company Registration {step === 1 ? "- Basic Info" : "- Details"}
</CardTitle>
<div className="flex justify-center space-x-2 mt-4">
<div
className={`h-2 w-16 rounded ${step === 1 ? "bg-primary" : "bg-muted"}`}
/>
<div
className={`h-2 w-16 rounded ${step === 2 ? "bg-primary" : "bg-muted"}`}
/>
</div>
</CardHeader>
<CardContent>
{step === 1 ? (
<Form {...step1Form}>
<form
onSubmit={step1Form.handleSubmit(onStep1Submit)}
className="space-y-4"
>
<FormField
control={step1Form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Company Name</FormLabel>
<FormControl>
<Input placeholder="Enter Company Name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={step1Form.control}
name="entity"
render={({ field }) => (
<FormItem>
<FormLabel>Entity Name</FormLabel>
<FormControl>
<Input placeholder="Enter Entity Name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={step1Form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(EntityCategory).map(([key, value]) => (
<SelectItem key={key} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={step1Form.control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>Country Code</FormLabel>
<FormControl>
<Input
placeholder="Select country"
maxLength={2}
{...field}
onChange={(e) => {
const value = e.target.value.toUpperCase();
handleCountryChange(value);
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={step1Form.control}
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Timezone</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={availableTimezones.length === 0}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableTimezones.map((tz) => (
<SelectItem key={tz.name} value={tz.utc}>
{tz.name} ({tz.utc})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={step1Form.control}
name="region"
render={({ field }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<FormControl>
<Input placeholder="Enter region" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Next <ChevronRight className="ml-2 h-4 w-4" />
</Button>
</form>
</Form>
) : (
<Form {...step2Form}>
<form
onSubmit={step2Form.handleSubmit(onStep2Submit)}
className="space-y-4"
>
<FormField
control={step2Form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>City</FormLabel>
<FormControl>
<Input placeholder="Select City" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={step2Form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Enter E-mail Address"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={step2Form.control}
name="contact"
render={({ field }) => (
<FormItem>
<FormLabel>Contact Number</FormLabel>
<FormControl>
<Input placeholder="Enter contact number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={step2Form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={step2Form.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex space-x-4">
<Button
type="button"
variant="outline"
onClick={() => setStep(1)}
className="flex-1"
>
<ChevronLeft className="mr-2 h-4 w-4" /> Back
</Button>
<Button type="submit" className="flex-1">
Register
</Button>
</div>
</form>
</Form>
)}
</CardContent>
</Card>
);
}
The utility function and validation :
export function getBrowserTimezone() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const offset = new Date().getTimezoneOffset();
const hours = Math.abs(Math.floor(offset / 60));
const minutes = Math.abs(offset % 60);
const sign = offset < 0 ? "+" : "-";
return {
name: timezone,
utc: `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`,
};
}
// This would typically come from an API or database
export const countryTimezones: Record<string, { name: string; utc: string }[]> =
{
IN: [{ name: "Asia/Kolkata", utc: "UTC+05:30" }],
RU: [
{ name: "Europe/Kaliningrad", utc: "UTC+02:00" },
{ name: "Europe/Moscow", utc: "UTC+03:00" },
{ name: "Europe/Samara", utc: "UTC+04:00" },
{ name: "Asia/Yekaterinburg", utc: "UTC+05:00" },
{ name: "Asia/Omsk", utc: "UTC+06:00" },
{ name: "Asia/Krasnoyarsk", utc: "UTC+07:00" },
{ name: "Asia/Irkutsk", utc: "UTC+08:00" },
{ name: "Asia/Yakutsk", utc: "UTC+09:00" },
{ name: "Asia/Vladivostok", utc: "UTC+10:00" },
{ name: "Asia/Magadan", utc: "UTC+11:00" },
{ name: "Asia/Kamchatka", utc: "UTC+12:00" },
],
// Add more countries as needed
};
import * as z from "zod";
export const EntityCategory = {
HOTEL: "Hotel",
HOSTEL: "Hostel",
VACATION_RENTAL: "Vacation Rental",
BED_BREAKFAST: "Bed Breakfast",
GROUP: "Group",
RESTAURANT: "Restaurant",
OTHER: "Other",
} as const;
export const registerSchema = {
step1: z.object({
name: z.string().min(3).max(100),
entity: z.string().min(3).max(100),
category: z.enum([
"Hotel",
"Hostel",
"Vacation Rental",
"Bed Breakfast",
"Group",
"Restaurant",
"Other",
]),
country: z.string().min(2).max(2),
timezone: z.string().min(3).max(10),
region: z.string().min(2).max(100),
}),
step2: z
.object({
city: z.string().min(2).max(100),
email: z.string().email(),
contact: z.string().regex(/^\+[1-9]\d{1,14}$/),
password: z.string().min(8).max(30),
confirm_password: z.string().min(8).max(30),
})
.refine((data) => data.password === data.confirm_password, {
message: "Passwords don't match",
path: ["confirm_password"],
}),
};
export type RegisterStep1 = z.infer<typeof registerSchema.step1>;
export type RegisterStep2 = z.infer<typeof registerSchema.step2>;
I tried not using shadcn forms but only react-hook-form and zod but the problem still persists. Thanks for the help in advance.
I believe, this is related to this issue.
In your case, you need to pass a unique key for each <Form>
instance, in order for these two instances to be re-rendered autonomously.
{step === 1 ? (
<Form key="1" {...step1Form}>
...
</Form>
) : (
<Form key="2" {...step2Form}>
...
</Form>
}
Read more: react.dev: Same Component at the Same Position preserves State