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.

Photo by Alvaro Reyes on Unsplash
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:
- Welcome Screen— Introduces the form and explains the purpose of collecting skincare-related data.
- Select Skin Type— The user identifies their skin type (e.g., dry, oily, combination), which helps personalize recommendations.
- Select Skin Goals— Users choose their skincare goals, such as hydration, anti-ageing, or acne control. If acne is selected, they are prompted to specify the acne type (Hormonal, Stress-Related, or Congestion).
- Age Group— The user selects their age range to further refine skincare suggestions.
- Environmental Factors— This step collects information about exposure to environmental stressors like pollution and sun damage.
- Lifestyle Factors— Users provide insights into their habits, such as diet, stress levels, and sleep patterns, which influence skin health.
- Exfoliation Tolerance— Determines how often and what type of exfoliation the user prefers. If anythingbut “Never”is selected, additional options appear for choosing betweenPhysical Scrubs, Chemical Exfoliants, or Enzyme Exfoliators.
- Ingredient Preferences— Users can specify preferred or avoided ingredients, such as fragrance-free or natural products.
- Routine Complexity— Allows users to choose between a simple or multi-step skincare regimen.
- Budget Allocation— Determines how much the user is willing to spend on skincare products.
- Ethical Preferences— Collects data on ethical considerations, such as cruelty-free or vegan skincare preferences.
- Makeup Question— Asks about the user’s makeup habits to refine skincare recommendations.
- Final Step— Concludes the form, possibly summarizing responses and suggesting suitable skincare routines.
The form incorporates conditional logic to streamline the user experience. For example:
- If a user selects Acne as a skincare concern, they must specify the acne type before proceeding.
- If the user expresses interest in exfoliation, they must select the preferred exfoliation method(s).
- The form dynamically adapts to each user’s responses, ensuring only relevant questions are displayed.
On top of that, multi-step forms bring several challenges:
- Users may abandon longer forms halfway.
- Switching tabs or losing connection shouldn’t result in losing filled data.
- Conditional logic for rendering relevant steps based on user input complicates things further.ž
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
- React Hook Form: Well-documented, battle-tested form management and validation.
- Tailwind CSS: For styling — nothing more is needed.
- Zustand: For managing form state across steps and sessions — super simple and lightweight state manager.
- Zod: A no-brainer for schema-based validation — natively integrates with RHF.
- Shadcn UI: The current superstar in the UI components world
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:
- Validation per step & type safety: Each step has its validation, ensuring the data is validated as the user progresses.
- Separation of concerns: Zustand handles state management, while React Hook Form (RHF) takes care of the form logic.
- Immediate validation: Each form validates step-by-step.
- Error localization: Errors won’t affect unrelated steps.
- Reusable architecture: Steps can be reused independently.
Downsides:
- Code duplication
- Increased bundle size
- State synchronization complexity
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.

Photo by Tamanna Rumee on Unsplash
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:
- Persistence: Built-in support for localStorage, allowing users to resume their form from where they left off.
- Back and forward navigation: Users can adjust previous choices without losing entered data.
- Better performance: Zustand re-renders only components subscribed to the specific state changes.
- Simpler API: No need for provider boilerplate.
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:
- Tracks the current step.
- Stores user responses as form data.
- Automatically saves progress in localStorage.
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:
1import { SkincareFormData } from '@/types/global'2import { create } from 'zustand'3import { persist } from 'zustand/middleware'45type FormState = {6 currentStep: number7 formData: SkincareFormData8 formId?: string9 pdfUrl?: string10 setCurrentStep: (step: number) => void11 setFormData: (data: SkincareFormData) => void12 setFormId: (id: string) => void13 setPdfUrl: (url: string) => void14 resetForm: () => void15 getLatestState: () => FormState16}1718// Add this helper function to get the latest state19const getStorageData = () => {20 try {21 const storageData = localStorage.getItem('form-storage')22 if (!storageData) return null2324 const parsedData = JSON.parse(storageData)25 return parsedData.state as FormState26 } catch (error) {27 console.error('Error reading from localStorage:', error)28 return null29 }30}3132export const useFormStore = create<FormState>()(33 persist(34 (set, get) => ({35 currentStep: 1,36 formData: {37 skinType: "OILY",38 },39 setCurrentStep: (step) => set({ currentStep: step }),40 setFormData: (data) =>41 set((state) => ({42 formData: { ...state.formData, ...data },43 })),44 setFormId: (id) => set({ formId: id }),45 resetForm: () =>46 set({ currentStep: 1, formData: {}, formId: undefined, pdfUrl: undefined }),47 getLatestState: () => getStorageData() || get(),48 }),49 {50 name: 'form-storage',51 }52 )53)
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.
1export const useFormStore = create<PizzaFormData>()(2 persist(3 (set, get) => ({4 // Store implementation5 }),6 {7 name: 'form-storage', // Storage key in localStorage8 }9 )10)
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.
1setFormData: (data) =>2 set((state) => ({3 formData: { ...state.formData, ...data },4 })),
3. Form Reset Capability
The resetForm action provides a quick way to clear all form data and return to the initial state:
1resetForm: () =>2 set({3 currentStep: 1,4 formData: {},5 formId: undefined,6 pdfUrl: undefined7 }),
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:
1getLatestState: () => 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
Asagood 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, andstep-by-stepnavigation.
DRY (Don’t Repeat Yourself) — Bertrand Meyer
A custom hook will help by:
- Initializing the form with validation schemas.
- Saving step data to Zustand.
- Handling navigation between steps.
Create a new file, src/hooks/use-form-step.ts and paste the code
1// src/hooks/use-form-step.ts2import { useForm, FieldValues, DefaultValues } from "react-hook-form"3import { zodResolver } from "@hookform/resolvers/zod"4import { z } from "zod"5import { useFormStore } from "@/lib/store"67interface UseFormStepProps<T extends FieldValues> {8 schema?: z.ZodSchema<T>9 currentStep: number10}1112export function useFormStep<T extends FieldValues>({13 schema,14 currentStep15}: UseFormStepProps<T>) {16 const { setCurrentStep, setFormData, getLatestState } = useFormStore()1718 const form = useForm<T>({19 resolver: schema ? zodResolver(schema) : undefined,20 mode: "onChange",21 defaultValues: getLatestState().formData as DefaultValues<T>,22 })2324 const handleNext = (data: T) => {25 setFormData(data)26 setCurrentStep(currentStep + 1)27 }2829 const handleNextOveride = (data: T, overideStep?: number) => {30 setFormData(data)31 setCurrentStep(overideStep || currentStep + 1)32 }3334 const handleBack = () => {35 const currentValues = form.getValues()36 setFormData(currentValues)37 setCurrentStep(currentStep - 1)38 }3940 return {41 form,42 setFormData,43 handleNext,44 handleBack,45 handleNextOveride46 }47}
What the Hook Accomplishes:
- Manages Form State Across Steps: Ensures form data is consistently maintained and updated as the user progresses through the steps.
- Handles Validation with Zod: Each step’s form fields are validated using Zod, ensuring accurate and step-specific validation.
- Enables Navigation: The handleNext and handleBack functions facilitate seamless navigation between steps, preserving data integrity.
- Persists Form Data with Zustand: Partial form data is stored in Zustand and persisted in localStorage, ensuring progress is saved even if the user refreshes the page or navigates away.
Props Breakdown:
- schema: Defines the validation rules for the current step’s form fields.
- currentStep: Indicates the current step in the multi-step form, allowing the form to correctly handle navigation (forward or backward).
Creating a typescript interface
To gain the benefits of type safety in TypeScript, we’ll define aSkincareFormDatainterface. 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:
1export type SkincareFormData = {2 skinType?: "OILY" | "DRY" | "COMBINATION" | "SENSITIVE"3 skinGoal?: "ANTI_AGING" | "ACNE" | "HYDRATION" | "EVEN_TONE"4 acneType?: "HORMONAL" | "STRESS" | "CONGESTION"5 sunExposure?: "RARE" | "MODERATE" | "FREQUENT"6 climateType?: "ARID" | "HUMID" | "URBAN"7 exfoliationFrequency?: "NEVER" | "WEEKLY" | "DAILY"8 exfoliationType?: "PHYSICAL_SCRUBS" | "CHEMICAL_EXFOLIANTS" | "ENZYME_EXFOLIATORS"9 ageGroup?: "TWENTIES" | "THIRTIES" | "FORTIES" | "FIFTIES" | "SIXTIES_PLUS"10 blacklistedIngredients?: string[]11 texturePreference?: "LIGHTWEIGHT" | "RICH" | "NO_PREFERENCE"12 packagingPreference?: "ECO_REFILL" | "AIRLESS_PUMP" | "STANDARD"13 makeupTypes?: ("FOUNDATION" | "CONCEALER" | "BLUSH" | "EYESHADOW" | "EYELINER" | "MASCARA" | "LIPSTICK" | "LIP_GLOSS" | "LIP_STAIN")[]14 makeupFrequency?: 'DAILY' | 'FEW_TIMES_WEEK' | 'WEEKENDS_ONLY' | 'SPECIAL_OCCASIONS'15 ethicalPreferences?: ("NONE"|"CRUELTY_FREE" | "VEGAN" | "SUSTAINABLE_PACKAGING" | "REEF_SAFE" | "PALM_OIL_FREE")[]16 stressLevels?: "LOW" | "MEDIUM" | "HIGH"17 sleepPatterns?: "LESS_THAN_6_HRS" | "6_TO_8_HRS" | "MORE_THAN_8_HRS"18 preferredIngredients?: ("HYALURONIC_ACID" | "VITAMIN_C" | "NIACINAMIDE" | "CERAMIDES" | "PEPTIDES" | "PANTHENOL" | "CENTELLA_ASIATICA")[]19 wearsMakeup?: boolean,20 avoidedIngredients?: ("PARABENS" | "SILICONES" | "MINERAL_OIL" | "ESSENTIAL_OILS")[]21 routineComplexity?: "LOW" | "MEDIUM" | "HIGH"22 monthlyBudget?: "LOW" | "MID_RANGE" | "LUXURY"23 hasPreferencesEthical?: boolean24 sustainabilityPriorities?: ("CRUELTY_FREE" | "RECYCLABLE" | "VEGAN")[]25}
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.

First step to "Choose your skin type"
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:
1import { z } from "zod"2import { useFormStep } from "@/lib/hooks/use-form-step"3import { Button } from "@/components/ui/button"4import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"5import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"6import { Label } from "@/components/ui/label"7import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"8import { cn } from "@/lib/utils"910const skinTypeSchema = z.object({11 skinType: z.enum(["OILY", "DRY", "COMBINATION", "SENSITIVE"], {12 required_error: "Please select your skin type",13 }),14})1516type SkinTypeForm = z.infer<typeof skinTypeSchema>1718function SkinTypeStep({step}: {step: number}) {19 const { form, handleBack, handleNext, handleNextOveride } = useFormStep({20 schema: skinTypeSchema,21 currentStep: step,22 })2324 const customHandleSubmit = (data: SkinTypeForm) => {25 if (data.skinType === "SENSITIVE") {26 handleNextOveride(data, 3)27 } else {28 handleNext(data)29 }30 }3132 const skinTypes = [33 {34 value: "OILY",35 title: "Oily Skin",36 description: "Shiny appearance with excess oil, especially in T-zone",37 features: ["Enlarged pores", "Prone to breakouts", "Shiny by midday"],38 imageSrc: "https://ensoulclinic.b-cdn.net/wp-content/uploads/2022/11/oily_skin_woman_makeup_greasy.png"39 },40 {41 value: "DRY",42 title: "Dry Skin",43 description: "Feels tight and may show visible flaking",44 features: ["Rough texture", "Feels tight", "Occasional redness"],45 imageSrc: "https://dl.geimshospital.com/uploads/image/AdobeStock_416637566-jpeg.webp"46 },47 {48 value: "COMBINATION",49 title: "Combination Skin",50 description: "Mix of oily and dry areas, typically oily T-zone",51 features: ["Oily T-zone", "Dry cheeks", "Variable pore size"],52 imageSrc: "https://drdennisgross.com/cdn/shop/articles/A_Guide_to_Skincare_for_Combination_Skin.png"53 },54 {55 value: "SENSITIVE",56 title: "Sensitive Skin",57 description: "Reactive to products and environmental factors",58 features: ["Easily irritated", "Prone to redness", "Reacts to products"],59 imageSrc: "https://deyga.in/cdn/shop/articles/38804b7bfc674ffc6d9dcbe74f0a8e22.jpg?v=1719985168&width=1100"60 }61 ]6263 return (64 <Card className="border-none shadow-none w-full max-w-[95%] sm:max-w-6xl mx-auto">65 <CardHeader className="text-left md:text-center p-4 sm:p-6 animate-in slide-in-from-top duration-700">66 <CardTitle className="text-4xl font-bold">67 What's Your Skin Type?68 </CardTitle>69 <CardDescription className="text-base sm:text-lg mt-2">70 Select the option that best matches your skin's characteristics71 </CardDescription>72 </CardHeader>73 <CardContent className="p-2 sm:p-6">74 <Form {...form}>75 <form onSubmit={form.handleSubmit(customHandleSubmit)} className="space-y-4 sm:space-y-6">76 <FormField77 control={form.control}78 name="skinType"79 render={({ field }) => (80 <FormItem>81 <FormControl>82 <RadioGroup83 onValueChange={field.onChange}84 defaultValue={field.value}85 className="grid gap-10 sm:gap-4 md:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"86 >87 {skinTypes.map((type, index) => (88 <div89 key={type.value}90 className={`relative animate-in fade-in slide-in-from-bottom-4 duration-700`}91 style={{ animationDelay: `${index * 150}ms` }}92 >93 <RadioGroupItem94 value={type.value}95 id={type.value.toLowerCase()}96 className="peer sr-only"97 />98 <Label99 htmlFor={type.value.toLowerCase()}100 className="block cursor-pointer transition-all duration-300"101 >102 <div103 className={cn(104 "rounded-xl overflow-hidden border-2 transition-all duration-300",105 field.value === type.value106 ? "ring-4 ring-primary ring-offset-4 scale-105 border-primary shadow-lg shadow-primary/20"107 : "border-transparent hover:border-primary/50 hover:ring-2 hover:ring-offset-2 hover:ring-primary/50"108 )}109 >110 <div className="relative aspect-[4/3] sm:aspect-[3/4] overflow-hidden">111 <img112 src={type.imageSrc}113 alt={`${type.title} example`}114 className={cn(115 "object-cover w-full h-full transition-all duration-700",116 field.value === type.value117 ? "scale-110 brightness-100"118 : "brightness-75 grayscale-[50%] hover:brightness-90 hover:grayscale-0"119 )}120 />121 <div className={cn(122 "absolute inset-0 bg-gradient-to-t transition-opacity duration-300",123 field.value === type.value124 ? "from-primary/70 to-transparent"125 : "from-black/70 to-transparent"126 )} />127 <div className={cn(128 "absolute bottom-0 left-0 right-0 p-3 sm:p-4 text-white transition-all duration-300",129 field.value === type.value130 ? "translate-y-0 bg-primary/20"131 : "hover:-translate-y-1"132 )}>133 <h3 className="text-lg sm:text-xl font-semibold mb-0.5 sm:mb-1">{type.title}</h3>134 <p className="text-xs sm:text-sm text-white/90 line-clamp-2">{type.description}</p>135 </div>136 </div>137 <div className={cn(138 "p-2 sm:p-4 transition-all duration-300",139 field.value === type.value140 ? "bg-primary/10 shadow-inner"141 : "bg-white hover:bg-gray-50"142 )}>143 <ul className="space-y-1 sm:space-y-2">144 {type.features.map((feature, index) => (145 <li146 key={index}147 className={cn(148 "text-xs sm:text-sm flex items-center transition-all duration-300",149 field.value === type.value150 ? "text-primary font-medium translate-x-2"151 : "text-gray-600 hover:translate-x-1"152 )}153 >154 <span155 className={cn(156 "w-1.5 h-1.5 rounded-full mr-2 flex-shrink-0 transition-all duration-300",157 field.value === type.value158 ? "bg-primary scale-150"159 : "bg-gray-300"160 )}161 />162 <span className="line-clamp-1">{feature}</span>163 </li>164 ))}165 </ul>166 </div>167 </div>168 </Label>169 </div>170 ))}171 </RadioGroup>172 </FormControl>173 <FormMessage />174 </FormItem>175 )}176 />177178 <div className="flex justify-between pt-4 sm:pt-8 animate-in fade-in-50 duration-700 delay-700">179 <Button180 type="button"181 variant="outline"182 onClick={handleBack}183 back184 >185186 Back187 </Button>188 <Button189 type="submit"190 disabled={!form.watch('skinType')}191 front192 >193 Continue194 </Button>195 </div>196 </form>197 </Form>198 </CardContent>199 </Card>200 )201}202203export default SkinTypeStep
What this does:
Schema Validation: We create a form validation schema for this particular step using Zod.
We define options for the user to choose from in skinTypes
Reusable Hook: We call our custom-created hook from the previous step.
Buttons for continuing and going back — only if the form is valid according to schema user will be allowed to move forward.
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.

If choosing minimal routine we will skip the budget step
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:
- If the user selects “Minimal” for Routine Complexity, the Budget Allocation step is skipped.
- If the user selects any other routine complexity level, the Budget Allocation step is included as usual.
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 functionhandleNextOverridedefined in our hook to handle the logic.
The logic could look something like this:
1const customHandleSubmit = (data: RoutineComplexityForm) => {2 if (data.routineComplexity === "MINIMAL") {3 handleNextOverride(data, 11) // Skip Budget step and go directly to Ethical Preferences4 } else {5 handleNext(data) // Proceed as normal6 }7}
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:
- If no special conditions (like skipping steps) are met, the onSubmit function defaults to calling handleNext(data), which processes the form data and advances the user to the next step in the regular sequence.
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.
1...23const { setCurrentStep, getLatestState } = useFormStore()45const { form, handleBack, handleNext, handleNextOveride } = useFormStep({6 schema: skinTypeSchema,7 currentStep: step,8})910..1112const customHandleBack = ()=>{13 const latestState = getLatestState()14 ## The delivery mode was defined earlier so it is safetly store in the state15 if (latestState.formData.routineComplexity === "MINIMAL"){16 setCurrentStep(9)17 } else {18 handleBack()19 }20 }
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.

Conditionally display additional options
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:
- Hormonal
- Stress-Related
- Congestion Acne
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.
1import { useState, useRef, useEffect } from 'react';2import { z } from "zod"3import { useFormStep } from "@/lib/hooks/use-form-step"4import { Button } from "@/components/ui/button"5import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"6import { Label } from "@/components/ui/label"7import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"8import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"9import { AlertCircle } from 'lucide-react'10import { motion, AnimatePresence } from 'framer-motion';11import { cn } from '@/lib/utils';12import { skinGoalOptions } from '@/lib/lifestyle-options';1314// Important! More details below.15const skinGoalsSchema = z.discriminatedUnion("skinGoal", [16 z.object({17 skinGoal: z.literal("ACNE"),18 acneType: z.enum(["HORMONAL", "STRESS_RELATED", "CONGESTION"], {19 required_error: "Please select your acne type"20 })21 }),22 z.object({23 skinGoal: z.enum(["BRIGHTENING", "PORE_MINIMIZATION", "ANTI_AGING", "HYDRATION"]),24 })25])2627function SelectSkinGoals({step}: {step: number}) {28 const { form, handleBack, handleNext } = useFormStep({29 schema: skinGoalsSchema,30 currentStep: step,31 });3233 const [completedSections, setCompletedSections] = useState({34 skinGoal: false,35 acneType: false36 });3738 const primaryGoalRef = useRef<HTMLDivElement>(null);39 const acneTypeRef = useRef<HTMLDivElement>(null);4041 // Smooth scroll function42 const smoothScrollToSection = (ref: React.RefObject<HTMLDivElement>) => {43 if (ref.current) {44 ref.current.scrollIntoView({45 behavior: 'smooth',46 block: 'start',47 inline: 'nearest'48 });49 }50 };5152 // Add custom CSS for scroll behavior and animations53 useEffect(() => {54 const style = document.createElement('style');55 style.textContent = `56 html {57 scroll-behavior: smooth;58 scroll-padding-top: 100px;59 }6061 @keyframes bounce-scroll {62 0%, 100% { transform: translateY(0); }63 50% { transform: translateY(-10px); }64 }6566 .scroll-indicator {67 animation: bounce-scroll 1s infinite;68 }69 `;70 document.head.appendChild(style);7172 return () => {73 document.head.removeChild(style);74 };75 }, []);76777879 const handleSectionComplete = (section: keyof typeof completedSections, value: any) => {80 setCompletedSections(prev => ({81 ...prev,82 [section]: true83 }));84 form.setValue(section as any, value);8586 // Auto-scroll to next section after a brief delay87 requestAnimationFrame(() => {88 if (section === 'skinGoal' && value === "ACNE" && acneTypeRef.current) {89 smoothScrollToSection(acneTypeRef);90 }91 });92 };9394 const renderPrimaryGoalOptions = () => (95 <RadioGroup96 onValueChange={(value) => {97 handleSectionComplete('skinGoal', value);98 if (value === "ACNE") {99 form.setValue("acneType", "HORMONAL");100 }101 }}102 defaultValue={form.getValues("skinGoal")}103 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"104 >105 {skinGoalOptions.map((option, index) => (106 <motion.div107 key={option.value}108 initial={{ opacity: 0, y: 20 }}109 animate={{ opacity: 1, y: 0 }}110 transition={{ delay: index * 0.1 }}111 whileHover={{ scale: 1.02 }}112 whileTap={{ scale: 0.98 }}113 >114 <div115 className={cn(116 "relative rounded-xl border p-6 transition-all duration-300 ease-in-out cursor-pointer",117 form.getValues("skinGoal") === option.value118 ? "bg-primary/5 border-primary shadow-md ring-2 ring-primary"119 : "hover:bg-accent/50"120 )}121 onClick={() => handleSectionComplete('skinGoal', option.value)}122 >123 <div className="absolute top-4 right-4">124 <RadioGroupItem125 value={option.value}126 id={option.value}127 className={cn(128 form.getValues("skinGoal") === option.value129 ? "bg-primary text-primary-foreground"130 : "bg-transparent"131 )}132 />133 </div>134 <div className="flex flex-col items-center text-center">135 {option.illustration}136 <Label137 htmlFor={option.value}138 className="flex flex-col cursor-pointer space-y-2"139 >140 <span className="font-semibold text-lg">141 {option.label}142 {option.value === "ACNE" && (143 <span className="block text-xs text-primary-foreground bg-primary mt-1 px-2 py-1 rounded-full">144 Additional options145 </span>146 )}147 </span>148 <span className="text-sm text-muted-foreground">149 {option.description}150 </span>151 </Label>152 </div>153 </div>154 </motion.div>155 ))}156 </RadioGroup>157 );158159 const renderAcneTypeOptions = () => (160 <RadioGroup161 onValueChange={(value) => {162 form.setValue("acneType", value as "HORMONAL" | "STRESS_RELATED" | "CONGESTION");163 setCompletedSections(prev => ({164 ...prev,165 acneType: true166 }));167 }}168 defaultValue={form.getValues("acneType")}169 className="grid gap-4 md:grid-cols-3"170 >171 {[172 {173 value: "HORMONAL",174 label: "Hormonal Acne",175 description: "Typically appears along jawline and chin, often cyclical with hormonal changes",176 icon: "🌙"177 },178 {179 value: "STRESS_RELATED",180 label: "Stress-Related",181 description: "Flares up during periods of high stress, often accompanied by inflammation",182 icon: "😮💨"183 },184 {185 value: "CONGESTION",186 label: "Congestion",187 description: "Small bumps, blackheads, and clogged pores due to excess oil and debris",188 icon: "🔍"189 }190 ].map((option, index) => (191 <motion.div192 key={option.value}193 initial={{ opacity: 0, x: -20 }}194 animate={{ opacity: 1, x: 0 }}195 transition={{ delay: index * 0.1 }}196 >197 <div198 className={cn(199 "group flex items-start space-x-4 rounded-xl p-6 cursor-pointer",200 "border-2 transition-all duration-300 ease-in-out",201 form.getValues("acneType") === option.value202 ? "border-primary bg-primary/10 shadow-lg"203 : "border-muted hover:border-primary/30 hover:bg-accent/20"204 )}205 onClick={() => {206 form.setValue("acneType", option.value as "HORMONAL" | "STRESS_RELATED" | "CONGESTION");207 setCompletedSections(prev => ({208 ...prev,209 acneType: true210 }));211 }}212 >213 <div className="flex-shrink-0">214 <div className={cn(215 "h-6 w-6 rounded-full border-2 flex items-center justify-center",216 form.getValues("acneType") === option.value217 ? "border-primary bg-primary text-white"218 : "border-muted-foreground group-hover:border-primary"219 )}>220 {form.getValues("acneType") === option.value && (221 <motion.div222 initial={{ scale: 0 }}223 animate={{ scale: 1 }}224 className="w-2 h-2 bg-current rounded-full"225 />226 )}227 </div>228 </div>229 <div className="space-y-2">230 <div className="flex items-center gap-2">231 <span className="text-xl">{option.icon}</span>232 <Label233 htmlFor={`acne-${option.value.toLowerCase()}`}234 className="text-lg font-semibold cursor-pointer"235 >236 {option.label}237 </Label>238 </div>239 <p className="text-muted-foreground text-sm leading-relaxed">240 {option.description}241 </p>242 </div>243 </div>244 </motion.div>245 ))}246 </RadioGroup>247 );248249250251 return (252 <Card className="border-none shadow-none">253 <CardHeader className="text-left md:text-center">254 <motion.div255 initial={{ opacity: 0, y: 20 }}256 animate={{ opacity: 1, y: 0 }}257 transition={{ duration: 0.4 }}258 >259 <CardTitle >260 Your Skin Goals261 </CardTitle>262 <CardDescription>263 Select your primary skin concern to help us create your perfect skincare routine264 </CardDescription>265 </motion.div>266 </CardHeader>267 <CardContent>268 <Form {...form}>269 <form onSubmit={form.handleSubmit(handleNext)} className="space-y-6">270 {/* Primary Goal Section */}271 <motion.div272 ref={primaryGoalRef}273 id="primary-goal-section"274 className="scroll-mt-20"275 initial={{ opacity: 0 }}276 animate={{ opacity: 1 }}277 transition={{ delay: 0.2 }}278 >279 <FormField280 control={form.control}281 name="skinGoal"282 render={() => (283 <FormItem>284 <FormControl>285 {renderPrimaryGoalOptions()}286 </FormControl>287 <FormMessage />288 </FormItem>289 )}290 />291 </motion.div>292293 {/* Acne Type Section */}294 <AnimatePresence>295 {form.getValues("skinGoal") === "ACNE" && (296 <motion.div297 ref={acneTypeRef}298 id="acne-type-section"299 initial={{ opacity: 0, height: 0 }}300 animate={{ opacity: 1, height: 'auto' }}301 exit={{ opacity: 0, height: 0 }}302 className="space-y-4 scroll-mt-20 overflow-hidden"303 >304 <motion.div305 initial={{ opacity: 0, y: 20 }}306 animate={{ opacity: 1, y: 0 }}307 className="flex items-center gap-2 text-primary bg-primary/5 p-4 rounded-lg"308 >309 <AlertCircle className="h-5 w-5" />310 <p className="text-sm font-medium">Help us understand your acne type better</p>311 </motion.div>312 <FormField313 control={form.control}314 name="acneType"315 render={() => (316 <FormItem>317 <FormControl>318 {renderAcneTypeOptions()}319 </FormControl>320 <FormMessage />321 </FormItem>322 )}323 />324 </motion.div>325 )}326 </AnimatePresence>327328 <motion.div329 className="flex justify-between pt-8"330 initial={{ opacity: 0 }}331 animate={{ opacity: 1 }}332 transition={{ delay: 0.2 }}333 >334 <Button335 type="button"336 variant="outline"337 back338 onClick={handleBack}339 >340 Back341 </Button>342 <Button343 type="submit"344 front345 disabled={346 !completedSections.skinGoal ||347 (form.getValues("skinGoal") === "ACNE" && !completedSections.acneType)348 }349 >350 Continue351 </Button>352 </motion.div>353 </form>354 </Form>355 </CardContent>356 </Card>357 )358}359360export 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.
- Conditional Fields:Certain details are only required depending on the user’s selection. For example, if the user selects“Acne”as their skin goal, they must specify their acne type (Hormonal, Stress-Related, or Congestion). However, if they choose Brightening, Pore Minimization, Anti-Aging, or Hydration, no additional details are needed.
- Improved TypeScript Inference:TypeScript can infer which fields are required based on the selected skin goal. If “Acne” is chosen, TypeScript ensures the user provides an acne type, but skips that validation for other skin goals.
- Clear Validation Errors:The discriminated union guarantees that only relevant fields are displayed and validated. This prevents unnecessary validation errors, such as requiring an acne type when a user selects a non-acne skin goal.
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.

Summary step that displays all collected information
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:
Using a switch statement: Based on the current step, we use a switch statement to determine which component should be rendered.
Dynamically rendering components: The current step is derived from Zustand’s currentStep state, and the corresponding component is dynamically rendered based on the user’s progress.
I’ve stored this logic in src/app.tsx, where the flow is coordinated.
1// app.tsx2import { FormLayout } from "./components/layout/form-layout"3import SplashScreen from "./components/steps/splash-screen"4import SelectSkinType from "./components/steps/select-skin-type"5import SelectSkinGoals from "./components/steps/select-skin-goals"67import { useFormStore } from "./lib/store"89import EnvironmentalFactors from "./components/steps/environmental-factors"10import ExfoliationTolerance from "./components/steps/exofiliate-tolerance"11import { IngredientPreferences } from "./components/steps/ingredient-preferences"12import RoutineComplexity from "./components/steps/routine-complexity"13import BudgetAllocation from "./components/steps/budget-allocation"14import MakeupQuestion from "./components/steps/makeup-question"15import LifestyleFactors from "./components/steps/lifestyle-factors"16import AgeGroup from "./components/steps/age-group"17import FinalStep from "./components/steps/final-step"18import EthicalPreferences from "./components/steps/ethical-preferences"1920function App() {21 const currentStep = useFormStore((state) => state.currentStep)2223 const renderStep = () => {24 switch (currentStep) {25 case 1:26 return <SplashScreen />27 case 2:28 return <SelectSkinType step={2}/>29 case 3:30 return <SelectSkinGoals step={3}/>31 case 4:32 return <AgeGroup step={4}/>33 case 5:34 return <EnvironmentalFactors step={5}/>35 case 6:36 return <LifestyleFactors step={6}/>37 case 7:38 return <ExfoliationTolerance step={7}/>39 case 8:40 return <IngredientPreferences step={8}/>41 case 9:42 return <RoutineComplexity step={9}/>43 case 10:44 return <BudgetAllocation step={10}/>45 case 11:46 return <EthicalPreferences step={11}/>47 case 12:48 return <MakeupQuestion step={12}/>49 case 13:50 return <FinalStep step={13}/>51 default:52 return <div>Step {currentStep} coming soon...</div>53 }54 }5556 return (57 <FormLayout>58 {renderStep()}59 </FormLayout>60 )61}6263export 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 ahoneypot. 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.
1<input2 type="text"3 style={{ display: 'none' }}4 name="do_you_like_money"5/>
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:
- Connect to Shopify’s API to fetch real product data and present recommendations in the final step
- For educational platforms requiring legally binding agreements, pre-fill contracts with user data and ask for signatures directly within the flow
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
Thanks,
Matija