Master Multi-Step Forms: Build a Dynamic React Form in 6 Simple Steps
Tired of basic forms that can’t handle complex logic? Learn how to build a powerful, multi-step form with React that adapts to user input — step by step.
Scroll down for code examples and a link to the live demo and GitHub repoI recently got tasked to create a multip step form for a client.
In this guide, you’ll discover how to create a robust multi-step form using React Hook Form, Zustand, and Zod. We’ll cover planning, conditional logic, state persistence, and more.
We’ll break down the process into six manageable steps: Scoping the Form, Setting Up State Management, Building Reusable Hooks, Creating Step Components, Implementing Conditional Navigation, and Tying Everything Together
Naturally, I checked some of my favourite libraries — React Hook Form, Shadcn, Headless UI, and Material UI — but none of them had out-of-the-box solutions for multi-step logic.
I couldn’t find anything out there.
Single forms in React are straightforward, but multi-step forms? That’s where things get tricky.
I decided to give it a go myself, and here is my approach to help anyone who wants to build multi-step forms in the future.
Scoping the form
Before we start, we need to define each step in advance.
Give me six hours to chop down a tree, and I will spend the first four sharpening the axe. — Abraham Lincoln.
This includes listing all the options for each step and mapping out any conditional logic. If some selections dynamically trigger additional options within the same step, those conditions must be planned beforehand.
Likewise, if a specific choice requires skipping a future step, that logic should be outlined upfront. The secret to a smooth user experience is meticulous planning — the more thought you put in now, the less time you’ll spend fixing issues later.
I cannot stress this enough, especially with the rise of AI in coding. If you do not plan it and know what you want beforehand, you will end up with code that follows different logic in each component and spend more time deleting and debugging than anything else.
Okay, enough of the talking.
Defining the steps
We are building a skincare routine builder form. Let’s think about the steps it will include, one by one.
Consider what information we need, what details are useful, and what data we must collect to suggest the best possible routine for the user. It all comes down to understanding the problem inside and out.
Imagine creating a form that gathers user preferences for a personalized skincare routine.
The form will gather inputs like:
The form incorporates conditional logic to streamline the user experience. For example:
On top of that, multi-step forms bring several challenges:
Let’s talk shop!
Great, we have defined the steps for our skincare routine builder. Now we can finally move on to what we love the most — code.
Let’s start with the tech stack we will be using to build a multi-step form.
Tech Stack
Having the right tools is the foundation of a great developer experience. Recently, I’ve been experimenting with the best ways to create multi-step forms, which led me to the following tech stack. We’re going with the classics.
“It’s not the tools that you have faith in — tools are just tools. They work, or they don’t work. It’s people you have faith in or not.” — Steve Jobs
Additionally, we’ll save form progress in localStorage to allow users to close the session and return later without losing data.
Zustand natively supports this feature so we won’t have to do anything special with it.
Next, we need to make some decisions what’s the best implementation for our use case.
One Form Instance Per Step
For simplicity, we’ll treat each step as an independent form. This keeps the form logic lightweight and simplifies the overall flow. The alternative is to create one form instance of RHF and make it so all steps belong to it.
Benefits of using multiple instances approach:
Downsides:
The main question is: do these downsides outweigh the benefits for your use case?
For multi-step forms requiring validation, the advantages — such as type safety, validation, and state persistence — typically outweigh the drawbacks, especially with mitigation strategies in place.
Zustand for State Management
React Hook Form ensures that each form only manages its state.
However, since we have multiple forms — each step being its form instance — the data is lost once a form is submitted and a new form is initialized for the next step.
To save and synchronize the data across steps, we’ll use Zustand for global state management. This approach allows us to store the results of each step in one place for later access.
While React Context could work, Zustand is lightweight, easy to use, and provides key advantages for multi-step forms:
Zustand’s middleware, like the persist middleware, and its seamless TypeScript integration make it maintainable and type-safe. While React Context is good for simple state sharing, Zustand is better for managing complex, persistent form state efficiently.
Building the Multi-Step Form Step-by-Step
Yei! Let’s finally get to the code.
We’ll start by creating the store for our multi-step form, where all user input will be saved.
As mentioned earlier, we’ll be using Zustand. To reiterate what this means for our specific case, it will help us:
Step 1: Setting Up Zustand for State Management
Create a file in src/lib/store.ts and paste the following code. Make sure you have installed dependeindes of zsutand via npm i zustand
Here’s how to implement a Zustand store with persistence:
import { SkincareFormData } from '@/types/global'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type FormState = {
currentStep: number
formData: SkincareFormData
formId?: string
pdfUrl?: string
setCurrentStep: (step: number) => void
setFormData: (data: SkincareFormData) => void
setFormId: (id: string) => void
setPdfUrl: (url: string) => void
resetForm: () => void
getLatestState: () => FormState
}
// Add this helper function to get the latest state
const getStorageData = () => {
try {
const storageData = localStorage.getItem('form-storage')
if (!storageData) return null
const parsedData = JSON.parse(storageData)
return parsedData.state as FormState
} catch (error) {
console.error('Error reading from localStorage:', error)
return null
}
}
export const useFormStore = create<FormState>()(
persist(
(set, get) => ({
currentStep: 1,
formData: {
skinType: "OILY",
},
setCurrentStep: (step) => set({ currentStep: step }),
setFormData: (data) =>
set((state) => ({
formData: { ...state.formData, ...data },
})),
setFormId: (id) => set({ formId: id }),
resetForm: () =>
set({ currentStep: 1, formData: {}, formId: undefined, pdfUrl: undefined }),
getLatestState: () => getStorageData() || get(),
}),
{
name: 'form-storage',
}
)
)
Let’s break the code down step by step to see what this code does.
1. Persistent Storage
The store uses Zustand’s persist middleware to automatically save form progress to localStorage. This ensures that users won’t lose their progress if they accidentally close the browser.
Moreover, it retains all choices so that users can revisit the form at any time and edit it as they please.
export const useFormStore = create<PizzaFormData>()(
persist(
(set, get) => ({
// Store implementation
}),
{
name: 'form-storage', // Storage key in localStorage
}
)
)
2. Progressive Form Data Updates
The setFormData action allows partial updates to the form data, merging new fields with existing ones. This will be called on every step in the form.
setFormData: (data) =>
set((state) => ({
formData: { ...state.formData, ...data },
})),
3. Form Reset Capability
The resetForm action provides a quick way to clear all form data and return to the initial state:
resetForm: () =>
set({
currentStep: 1,
formData: {},
formId: undefined,
pdfUrl: undefined
}),
4. State Recovery
To retrieve all the data collected so far, we can use the native methods provided by Zustand to extract it from the store. In our components, we’ll be interested in obtaining the current state of the form. This function will fetch the most recent data, ensuring that the store and form states remain in sync.
The store includes a getLatestState helper to safely retrieve the current state from localStorage:
getLatestState: () => getStorageData() || get(),
With the store out of the way, let’s move to creating the first step.
But before doing that, let’s create a reusable hook.
Step 2: Custom Hook to Manage Form State Per Step
As a good developer, we need to reduce the repetition of information that is likely to change, replacing it with abstractions that are less prone to modification.
To avoid code duplication, we’ll create a custom hook to efficiently handle form state management across each step.
This hook integrates React Hook Form with Zustand, streamlining form initialization, validation, and step-by-step navigation.
DRY (Don’t Repeat Yourself) — Bertrand Meyer
A custom hook will help by:
Create a new file, src/hooks/use-form-step.ts and paste the code
// src/hooks/use-form-step.ts
import { useForm, FieldValues, DefaultValues } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { useFormStore } from "@/lib/store"
interface UseFormStepProps<T extends FieldValues> {
schema?: z.ZodSchema<T>
currentStep: number
}
export function useFormStep<T extends FieldValues>({
schema,
currentStep
}: UseFormStepProps<T>) {
const { setCurrentStep, setFormData, getLatestState } = useFormStore()
const form = useForm<T>({
resolver: schema ? zodResolver(schema) : undefined,
mode: "onChange",
defaultValues: getLatestState().formData as DefaultValues<T>,
})
const handleNext = (data: T) => {
setFormData(data)
setCurrentStep(currentStep + 1)
}
const handleNextOveride = (data: T, overideStep?: number) => {
setFormData(data)
setCurrentStep(overideStep || currentStep + 1)
}
const handleBack = () => {
const currentValues = form.getValues()
setFormData(currentValues)
setCurrentStep(currentStep - 1)
}
return {
form,
setFormData,
handleNext,
handleBack,
handleNextOveride
}
}
What the Hook Accomplishes:
Props Breakdown:
Creating a typescript interface
To gain the benefits of type safety in TypeScript, we’ll define a SkincareFormData interface. This type will be used by the Zustand store, the step components, and the custom hook to ensure consistency and prevent errors during development.
1. Create a Global Type File
Create a new file src/types/global.d.ts if it doesn't already exist.
2. Define the Interface
In the global.d.ts file, define the PizzaFormData interface. Here's an example based on the structure of your multi-step pizza order form:
export type SkincareFormData = {
skinType?: "OILY" | "DRY" | "COMBINATION" | "SENSITIVE"
skinGoal?: "ANTI_AGING" | "ACNE" | "HYDRATION" | "EVEN_TONE"
acneType?: "HORMONAL" | "STRESS" | "CONGESTION"
sunExposure?: "RARE" | "MODERATE" | "FREQUENT"
climateType?: "ARID" | "HUMID" | "URBAN"
exfoliationFrequency?: "NEVER" | "WEEKLY" | "DAILY"
exfoliationType?: "PHYSICAL_SCRUBS" | "CHEMICAL_EXFOLIANTS" | "ENZYME_EXFOLIATORS"
ageGroup?: "TWENTIES" | "THIRTIES" | "FORTIES" | "FIFTIES" | "SIXTIES_PLUS"
blacklistedIngredients?: string[]
texturePreference?: "LIGHTWEIGHT" | "RICH" | "NO_PREFERENCE"
packagingPreference?: "ECO_REFILL" | "AIRLESS_PUMP" | "STANDARD"
makeupTypes?: ("FOUNDATION" | "CONCEALER" | "BLUSH" | "EYESHADOW" | "EYELINER" | "MASCARA" | "LIPSTICK" | "LIP_GLOSS" | "LIP_STAIN")[]
makeupFrequency?: 'DAILY' | 'FEW_TIMES_WEEK' | 'WEEKENDS_ONLY' | 'SPECIAL_OCCASIONS'
ethicalPreferences?: ("NONE"|"CRUELTY_FREE" | "VEGAN" | "SUSTAINABLE_PACKAGING" | "REEF_SAFE" | "PALM_OIL_FREE")[]
stressLevels?: "LOW" | "MEDIUM" | "HIGH"
sleepPatterns?: "LESS_THAN_6_HRS" | "6_TO_8_HRS" | "MORE_THAN_8_HRS"
preferredIngredients?: ("HYALURONIC_ACID" | "VITAMIN_C" | "NIACINAMIDE" | "CERAMIDES" | "PEPTIDES" | "PANTHENOL" | "CENTELLA_ASIATICA")[]
wearsMakeup?: boolean,
avoidedIngredients?: ("PARABENS" | "SILICONES" | "MINERAL_OIL" | "ESSENTIAL_OILS")[]
routineComplexity?: "LOW" | "MEDIUM" | "HIGH"
monthlyBudget?: "LOW" | "MID_RANGE" | "LUXURY"
hasPreferencesEthical?: boolean
sustainabilityPriorities?: ("CRUELTY_FREE" | "RECYCLABLE" | "VEGAN")[]
}
Step 3: Creating a Regular Step Component
Now, let’s focus our attention on creating the first step in our multi-step form.
We will ask the user to select their skin type. We will use Shadcn UI components to create a beautiful UI that matches the beauty theme.
Let’s dive into the code now. We are finally ready to take the first step. Here, I’ve added it into src/components/steps/step-1.tsx.
We rely on the Shadcn UI library and React Hook Form.
Below is the full code for the step where users select their skin type:
import { z } from "zod"
import { useFormStep } from "@/lib/hooks/use-form-step"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"
import { cn } from "@/lib/utils"
const skinTypeSchema = z.object({
skinType: z.enum(["OILY", "DRY", "COMBINATION", "SENSITIVE"], {
required_error: "Please select your skin type",
}),
})
type SkinTypeForm = z.infer<typeof skinTypeSchema>
function SkinTypeStep({step}: {step: number}) {
const { form, handleBack, handleNext, handleNextOveride } = useFormStep({
schema: skinTypeSchema,
currentStep: step,
})
const customHandleSubmit = (data: SkinTypeForm) => {
if (data.skinType === "SENSITIVE") {
handleNextOveride(data, 3)
} else {
handleNext(data)
}
}
const skinTypes = [
{
value: "OILY",
title: "Oily Skin",
description: "Shiny appearance with excess oil, especially in T-zone",
features: ["Enlarged pores", "Prone to breakouts", "Shiny by midday"],
imageSrc: "https://ensoulclinic.b-cdn.net/wp-content/uploads/2022/11/oily_skin_woman_makeup_greasy.png"
},
{
value: "DRY",
title: "Dry Skin",
description: "Feels tight and may show visible flaking",
features: ["Rough texture", "Feels tight", "Occasional redness"],
imageSrc: "https://dl.geimshospital.com/uploads/image/AdobeStock_416637566-jpeg.webp"
},
{
value: "COMBINATION",
title: "Combination Skin",
description: "Mix of oily and dry areas, typically oily T-zone",
features: ["Oily T-zone", "Dry cheeks", "Variable pore size"],
imageSrc: "https://drdennisgross.com/cdn/shop/articles/A_Guide_to_Skincare_for_Combination_Skin.png"
},
{
value: "SENSITIVE",
title: "Sensitive Skin",
description: "Reactive to products and environmental factors",
features: ["Easily irritated", "Prone to redness", "Reacts to products"],
imageSrc: "https://deyga.in/cdn/shop/articles/38804b7bfc674ffc6d9dcbe74f0a8e22.jpg?v=1719985168&width=1100"
}
]
return (
<Card className="border-none shadow-none w-full max-w-[95%] sm:max-w-6xl mx-auto">
<CardHeader className="text-left md:text-center p-4 sm:p-6 animate-in slide-in-from-top duration-700">
<CardTitle className="text-4xl font-bold">
What's Your Skin Type?
</CardTitle>
<CardDescription className="text-base sm:text-lg mt-2">
Select the option that best matches your skin's characteristics
</CardDescription>
</CardHeader>
<CardContent className="p-2 sm:p-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(customHandleSubmit)} className="space-y-4 sm:space-y-6">
<FormField
control={form.control}
name="skinType"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid gap-10 sm:gap-4 md:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
>
{skinTypes.map((type, index) => (
<div
key={type.value}
className={`relative animate-in fade-in slide-in-from-bottom-4 duration-700`}
style={{ animationDelay: `${index * 150}ms` }}
>
<RadioGroupItem
value={type.value}
id={type.value.toLowerCase()}
className="peer sr-only"
/>
<Label
htmlFor={type.value.toLowerCase()}
className="block cursor-pointer transition-all duration-300"
>
<div
className={cn(
"rounded-xl overflow-hidden border-2 transition-all duration-300",
field.value === type.value
? "ring-4 ring-primary ring-offset-4 scale-105 border-primary shadow-lg shadow-primary/20"
: "border-transparent hover:border-primary/50 hover:ring-2 hover:ring-offset-2 hover:ring-primary/50"
)}
>
<div className="relative aspect-[4/3] sm:aspect-[3/4] overflow-hidden">
<img
src={type.imageSrc}
alt={`${type.title} example`}
className={cn(
"object-cover w-full h-full transition-all duration-700",
field.value === type.value
? "scale-110 brightness-100"
: "brightness-75 grayscale-[50%] hover:brightness-90 hover:grayscale-0"
)}
/>
<div className={cn(
"absolute inset-0 bg-gradient-to-t transition-opacity duration-300",
field.value === type.value
? "from-primary/70 to-transparent"
: "from-black/70 to-transparent"
)} />
<div className={cn(
"absolute bottom-0 left-0 right-0 p-3 sm:p-4 text-white transition-all duration-300",
field.value === type.value
? "translate-y-0 bg-primary/20"
: "hover:-translate-y-1"
)}>
<h3 className="text-lg sm:text-xl font-semibold mb-0.5 sm:mb-1">{type.title}</h3>
<p className="text-xs sm:text-sm text-white/90 line-clamp-2">{type.description}</p>
</div>
</div>
<div className={cn(
"p-2 sm:p-4 transition-all duration-300",
field.value === type.value
? "bg-primary/10 shadow-inner"
: "bg-white hover:bg-gray-50"
)}>
<ul className="space-y-1 sm:space-y-2">
{type.features.map((feature, index) => (
<li
key={index}
className={cn(
"text-xs sm:text-sm flex items-center transition-all duration-300",
field.value === type.value
? "text-primary font-medium translate-x-2"
: "text-gray-600 hover:translate-x-1"
)}
>
<span
className={cn(
"w-1.5 h-1.5 rounded-full mr-2 flex-shrink-0 transition-all duration-300",
field.value === type.value
? "bg-primary scale-150"
: "bg-gray-300"
)}
/>
<span className="line-clamp-1">{feature}</span>
</li>
))}
</ul>
</div>
</div>
</Label>
</div>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-between pt-4 sm:pt-8 animate-in fade-in-50 duration-700 delay-700">
<Button
type="button"
variant="outline"
onClick={handleBack}
back
>
Back
</Button>
<Button
type="submit"
disabled={!form.watch('skinType')}
front
>
Continue
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)
}
export default SkinTypeStep
What this does:
Step 4: Creating a Step Component That Moves User Conditional To Their Choice
Let’s consider a case where we want to skip certain steps based on user choices.
For example, in the Routine Complexity step, if the user selects “Minimal”, we assume they are only interested in basic skincare and will skip the Budget Allocation step. Instead of proceeding to step 10 (Budget Allocation), the form jumps directly to step 11 (Ethical Preferences) since budget considerations are irrelevant for minimal routines.
We have two conditional paths:
In the code, instead of simply calling the handleNext function (which moves to the next step sequentially), we introduce a custom function (customHandleSubmit) that first evaluates the user’s selection and then decides whether to follow the normal sequence or skip to a specific step.
We leverage the function handleNextOverride defined in our hook to handle the logic.
The logic could look something like this:
...
const { form, handleBack, handleNext, handleNextOveride } = useFormStep({
schema: skinTypeSchema,
currentStep: step,
})
..
const customHandleSubmit = (data: RoutineComplexityForm) => {
if (data.routineComplexity === "MINIMAL") {
handleNextOverride(data, 11) // Skip Budget step and go directly to Ethical Preferences
} else {
handleNext(data) // Proceed as normal
}
}
Let’s now break down what this function does.
The customSubmitHandler function is triggered once the form is valid and submitted. It captures the form data upon submission and checks the selected Routine Complexity using data.routineComplexity.
Fallback to Default Behavior:
Handling back navigation
Another aspect to consider is the case when the user navigates back. We need to ensure we jump to the right step and don’t show them something they were never asked initially.
For instance, if the user selected “Minimal” for Routine Complexity and was directed to the Ethical Preferences step (Step 11), navigating back would normally take them to the Budget Allocation step (Step 10).
However, since the budget step was skipped, we don’t want the user to land there. Instead, we want to send the user back to the Routine Complexity step (Step 9).
Since we control how users move forward, we can also control how they move backwards by creating a custom back handler. To achieve this, we need to retrieve the latest state by calling the function provided by the hook, ensuring that the back navigation logic respects any skipped steps.
...
const { setCurrentStep, getLatestState } = useFormStore()
const { form, handleBack, handleNext, handleNextOveride } = useFormStep({
schema: skinTypeSchema,
currentStep: step,
})
..
const customHandleBack = ()=>{
const latestState = getLatestState()
## The delivery mode was defined earlier so it is safetly store in the state
if (latestState.formData.routineComplexity === "MINIMAL"){
setCurrentStep(9)
} else {
handleBack()
}
}
If this happens more often, we could define handleBackOverride in our custom hook.
Keep in mind that this time, we are using a function defined in our custom hook that manages the store — useFormStore.
getLatestState will retrieve the latest Zustand store state.
This way, we can determine what the user has input on the routine complexity step.
Step 5: Creating a Step Component to Conditionally Display Additional Fields Based on User Selection
Let’s imagine that we want to display more options dynamically based on the user’s choice within the same step.
Consider the Skin Goals step. If the user selects “Acne”, they are prompted to provide additional details about their acne type.
Before proceeding they must choose between:
On the other hand, if they select other skin goals such as Brightening, Pore Minimization, Anti-Aging, or Hydration, no additional questions appear, and they can move to the next step immediately.
We can achieve this by conditionally rendering fields within the same step, ensuring that users only see the questions relevant to their selected goal.
This approach maintains a clean and intuitive form experience, preventing unnecessary input fields from cluttering the UI.
import { useState, useRef, useEffect } from 'react';
import { z } from "zod"
import { useFormStep } from "@/lib/hooks/use-form-step"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { AlertCircle } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import { skinGoalOptions } from '@/lib/lifestyle-options';
// Important! More details below.
const skinGoalsSchema = z.discriminatedUnion("skinGoal", [
z.object({
skinGoal: z.literal("ACNE"),
acneType: z.enum(["HORMONAL", "STRESS_RELATED", "CONGESTION"], {
required_error: "Please select your acne type"
})
}),
z.object({
skinGoal: z.enum(["BRIGHTENING", "PORE_MINIMIZATION", "ANTI_AGING", "HYDRATION"]),
})
])
function SelectSkinGoals({step}: {step: number}) {
const { form, handleBack, handleNext } = useFormStep({
schema: skinGoalsSchema,
currentStep: step,
});
const [completedSections, setCompletedSections] = useState({
skinGoal: false,
acneType: false
});
const primaryGoalRef = useRef<HTMLDivElement>(null);
const acneTypeRef = useRef<HTMLDivElement>(null);
// Smooth scroll function
const smoothScrollToSection = (ref: React.RefObject<HTMLDivElement>) => {
if (ref.current) {
ref.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}
};
// Add custom CSS for scroll behavior and animations
useEffect(() => {
const style = document.createElement('style');
style.textContent = `
html {
scroll-behavior: smooth;
scroll-padding-top: 100px;
}
@keyframes bounce-scroll {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.scroll-indicator {
animation: bounce-scroll 1s infinite;
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
const handleSectionComplete = (section: keyof typeof completedSections, value: any) => {
setCompletedSections(prev => ({
...prev,
[section]: true
}));
form.setValue(section as any, value);
// Auto-scroll to next section after a brief delay
requestAnimationFrame(() => {
if (section === 'skinGoal' && value === "ACNE" && acneTypeRef.current) {
smoothScrollToSection(acneTypeRef);
}
});
};
const renderPrimaryGoalOptions = () => (
<RadioGroup
onValueChange={(value) => {
handleSectionComplete('skinGoal', value);
if (value === "ACNE") {
form.setValue("acneType", "HORMONAL");
}
}}
defaultValue={form.getValues("skinGoal")}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
{skinGoalOptions.map((option, index) => (
<motion.div
key={option.value}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div
className={cn(
"relative rounded-xl border p-6 transition-all duration-300 ease-in-out cursor-pointer",
form.getValues("skinGoal") === option.value
? "bg-primary/5 border-primary shadow-md ring-2 ring-primary"
: "hover:bg-accent/50"
)}
onClick={() => handleSectionComplete('skinGoal', option.value)}
>
<div className="absolute top-4 right-4">
<RadioGroupItem
value={option.value}
id={option.value}
className={cn(
form.getValues("skinGoal") === option.value
? "bg-primary text-primary-foreground"
: "bg-transparent"
)}
/>
</div>
<div className="flex flex-col items-center text-center">
{option.illustration}
<Label
htmlFor={option.value}
className="flex flex-col cursor-pointer space-y-2"
>
<span className="font-semibold text-lg">
{option.label}
{option.value === "ACNE" && (
<span className="block text-xs text-primary-foreground bg-primary mt-1 px-2 py-1 rounded-full">
Additional options
</span>
)}
</span>
<span className="text-sm text-muted-foreground">
{option.description}
</span>
</Label>
</div>
</div>
</motion.div>
))}
</RadioGroup>
);
const renderAcneTypeOptions = () => (
<RadioGroup
onValueChange={(value) => {
form.setValue("acneType", value as "HORMONAL" | "STRESS_RELATED" | "CONGESTION");
setCompletedSections(prev => ({
...prev,
acneType: true
}));
}}
defaultValue={form.getValues("acneType")}
className="grid gap-4 md:grid-cols-3"
>
{[
{
value: "HORMONAL",
label: "Hormonal Acne",
description: "Typically appears along jawline and chin, often cyclical with hormonal changes",
icon: "??"
},
{
value: "STRESS_RELATED",
label: "Stress-Related",
description: "Flares up during periods of high stress, often accompanied by inflammation",
icon: "????"
},
{
value: "CONGESTION",
label: "Congestion",
description: "Small bumps, blackheads, and clogged pores due to excess oil and debris",
icon: "??"
}
].map((option, index) => (
<motion.div
key={option.value}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
>
<div
className={cn(
"group flex items-start space-x-4 rounded-xl p-6 cursor-pointer",
"border-2 transition-all duration-300 ease-in-out",
form.getValues("acneType") === option.value
? "border-primary bg-primary/10 shadow-lg"
: "border-muted hover:border-primary/30 hover:bg-accent/20"
)}
onClick={() => {
form.setValue("acneType", option.value as "HORMONAL" | "STRESS_RELATED" | "CONGESTION");
setCompletedSections(prev => ({
...prev,
acneType: true
}));
}}
>
<div className="flex-shrink-0">
<div className={cn(
"h-6 w-6 rounded-full border-2 flex items-center justify-center",
form.getValues("acneType") === option.value
? "border-primary bg-primary text-white"
: "border-muted-foreground group-hover:border-primary"
)}>
{form.getValues("acneType") === option.value && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-2 h-2 bg-current rounded-full"
/>
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xl">{option.icon}</span>
<Label
htmlFor={`acne-${option.value.toLowerCase()}`}
className="text-lg font-semibold cursor-pointer"
>
{option.label}
</Label>
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
{option.description}
</p>
</div>
</div>
</motion.div>
))}
</RadioGroup>
);
return (
<Card className="border-none shadow-none">
<CardHeader className="text-left md:text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<CardTitle >
Your Skin Goals
</CardTitle>
<CardDescription>
Select your primary skin concern to help us create your perfect skincare routine
</CardDescription>
</motion.div>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleNext)} className="space-y-6">
{/* Primary Goal Section */}
<motion.div
ref={primaryGoalRef}
id="primary-goal-section"
className="scroll-mt-20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<FormField
control={form.control}
name="skinGoal"
render={() => (
<FormItem>
<FormControl>
{renderPrimaryGoalOptions()}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</motion.div>
{/* Acne Type Section */}
<AnimatePresence>
{form.getValues("skinGoal") === "ACNE" && (
<motion.div
ref={acneTypeRef}
id="acne-type-section"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-4 scroll-mt-20 overflow-hidden"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 text-primary bg-primary/5 p-4 rounded-lg"
>
<AlertCircle className="h-5 w-5" />
<p className="text-sm font-medium">Help us understand your acne type better</p>
</motion.div>
<FormField
control={form.control}
name="acneType"
render={() => (
<FormItem>
<FormControl>
{renderAcneTypeOptions()}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</motion.div>
)}
</AnimatePresence>
<motion.div
className="flex justify-between pt-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Button
type="button"
variant="outline"
back
onClick={handleBack}
>
Back
</Button>
<Button
type="submit"
front
disabled={
!completedSections.skinGoal ||
(form.getValues("skinGoal") === "ACNE" && !completedSections.acneType)
}
>
Continue
</Button>
</motion.div>
</form>
</Form>
</CardContent>
</Card>
)
}
export default SelectSkinGoals;
In case you missed it: An important caveat here is the schema form setup.
If you look closely, you’ll notice it utilizes Zod’s discriminatedUnion for validation.
What is discriminatedUnion
A discriminated union, used with z.discriminatedUnion(), is ideal for forms that include conditional fields based on the user's choice.
In the context of our form, it helps manage field validation only when needed.
Step 6: Coordinator Component That Ties Everything Together
The final missing piece is the Coordinator Component, which will reference each step component and assign the correct order in the entire flow.
This is where we control the sequence of steps and determine which component should be displayed at each step.
To determine the current step the user is on, we can access the state from our store.
Since this state is saved in localStorage, it ensures that the user will be taken back to the most recent step, even if they close and reopen the browser, restart their laptop, or experience a connection reset.
Here are the two main elements of the component:
I’ve stored this logic in src/app.tsx, where the flow is coordinated.
// app.tsx
import { FormLayout } from "./components/layout/form-layout"
import SplashScreen from "./components/steps/splash-screen"
import SelectSkinType from "./components/steps/select-skin-type"
import SelectSkinGoals from "./components/steps/select-skin-goals"
import { useFormStore } from "./lib/store"
import EnvironmentalFactors from "./components/steps/environmental-factors"
import ExfoliationTolerance from "./components/steps/exofiliate-tolerance"
import { IngredientPreferences } from "./components/steps/ingredient-preferences"
import RoutineComplexity from "./components/steps/routine-complexity"
import BudgetAllocation from "./components/steps/budget-allocation"
import MakeupQuestion from "./components/steps/makeup-question"
import LifestyleFactors from "./components/steps/lifestyle-factors"
import AgeGroup from "./components/steps/age-group"
import FinalStep from "./components/steps/final-step"
import EthicalPreferences from "./components/steps/ethical-preferences"
function App() {
const currentStep = useFormStore((state) => state.currentStep)
const renderStep = () => {
switch (currentStep) {
case 1:
return <SplashScreen />
case 2:
return <SelectSkinType step={2}/>
case 3:
return <SelectSkinGoals step={3}/>
case 4:
return <AgeGroup step={4}/>
case 5:
return <EnvironmentalFactors step={5}/>
case 6:
return <LifestyleFactors step={6}/>
case 7:
return <ExfoliationTolerance step={7}/>
case 8:
return <IngredientPreferences step={8}/>
case 9:
return <RoutineComplexity step={9}/>
case 10:
return <BudgetAllocation step={10}/>
case 11:
return <EthicalPreferences step={11}/>
case 12:
return <MakeupQuestion step={12}/>
case 13:
return <FinalStep step={13}/>
default:
return <div>Step {currentStep} coming soon...</div>
}
}
return (
<FormLayout>
{renderStep()}
</FormLayout>
)
}
export default App
Passing the step into each component provides greater modularity and an easier way to swap, delete, or add steps.
BONUS: Add a Honeypot to Prevent Bots
A quick and free trick to avoid bot submissions is to use a honeypot. It’s a method of adding a hidden input field that is invisible to the user but can be filled by a bot. In the form submission flow, we can check who filled this input or not. If the field is filled, it’s a clear sign that the submission is from a bot.
Although it’s not a bulletproof method, it works well against basic bots. To make the solution more robust, we could integrate Google reCAPTCHA, which provides an additional layer of defence, especially with the rise of AI-powered bots that can better understand and bypass simple tricks.
To implement this, we can add the honeypot field to the first step component. The key is to make the name of the hidden field as enticing as possible for the bot, as it’s this field that the bot is likely to fill.
<input
type="text"
style={{ display: 'none' }}
name="do_you_like_money"
/>
Conclusion
With this guide, we’ve built an elegant, fully responsive skincare builder. You can adapt this approach to whatever you need — from custom car configurations and course enrollment systems to catering service ordering platforms. While currently frontend-only, it’s easily extendable with backend integration. For instance:
By combining React Hook Form’s validation, Zustand’s state management, and Zod’s schema enforcement, you can craft multi-step forms with:
? Conditional logic ? Data persistence ? Modular architecture
Do you want additional features like multi-language support or connecting the form to an API? Let me know in the comments below!
Link to the demo tool.
Link to the GitHub repo here