← Back to Blog

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.

·Matija Žiberna·
CodingCustom App Development
Master Multi-Step Forms: Build a Dynamic React Form in 6 Simple Steps
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.

Person planning on whiteboard for skincare routine builder build with react

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.

An image symbolising state management in programming

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'
4
5type FormState = {
6 currentStep: number
7 formData: SkincareFormData
8 formId?: string
9 pdfUrl?: string
10 setCurrentStep: (step: number) => void
11 setFormData: (data: SkincareFormData) => void
12 setFormId: (id: string) => void
13 setPdfUrl: (url: string) => void
14 resetForm: () => void
15 getLatestState: () => FormState
16}
17
18// Add this helper function to get the latest state
19const getStorageData = () => {
20 try {
21 const storageData = localStorage.getItem('form-storage')
22 if (!storageData) return null
23
24 const parsedData = JSON.parse(storageData)
25 return parsedData.state as FormState
26 } catch (error) {
27 console.error('Error reading from localStorage:', error)
28 return null
29 }
30}
31
32export 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 implementation
5 }),
6 {
7 name: 'form-storage', // Storage key in localStorage
8 }
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: undefined
7 }),

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.ts
2import { useForm, FieldValues, DefaultValues } from "react-hook-form"
3import { zodResolver } from "@hookform/resolvers/zod"
4import { z } from "zod"
5import { useFormStore } from "@/lib/store"
6
7interface UseFormStepProps<T extends FieldValues> {
8 schema?: z.ZodSchema<T>
9 currentStep: number
10}
11
12export function useFormStep<T extends FieldValues>({
13 schema,
14 currentStep
15}: UseFormStepProps<T>) {
16 const { setCurrentStep, setFormData, getLatestState } = useFormStore()
17
18 const form = useForm<T>({
19 resolver: schema ? zodResolver(schema) : undefined,
20 mode: "onChange",
21 defaultValues: getLatestState().formData as DefaultValues<T>,
22 })
23
24 const handleNext = (data: T) => {
25 setFormData(data)
26 setCurrentStep(currentStep + 1)
27 }
28
29 const handleNextOveride = (data: T, overideStep?: number) => {
30 setFormData(data)
31 setCurrentStep(overideStep || currentStep + 1)
32 }
33
34 const handleBack = () => {
35 const currentValues = form.getValues()
36 setFormData(currentValues)
37 setCurrentStep(currentStep - 1)
38 }
39
40 return {
41 form,
42 setFormData,
43 handleNext,
44 handleBack,
45 handleNextOveride
46 }
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?: boolean
24 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.

React component displaying different option of the type of skin

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"
9
10const skinTypeSchema = z.object({
11 skinType: z.enum(["OILY", "DRY", "COMBINATION", "SENSITIVE"], {
12 required_error: "Please select your skin type",
13 }),
14})
15
16type SkinTypeForm = z.infer<typeof skinTypeSchema>
17
18function SkinTypeStep({step}: {step: number}) {
19 const { form, handleBack, handleNext, handleNextOveride } = useFormStep({
20 schema: skinTypeSchema,
21 currentStep: step,
22 })
23
24 const customHandleSubmit = (data: SkinTypeForm) => {
25 if (data.skinType === "SENSITIVE") {
26 handleNextOveride(data, 3)
27 } else {
28 handleNext(data)
29 }
30 }
31
32 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 ]
62
63 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 characteristics
71 </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 <FormField
77 control={form.control}
78 name="skinType"
79 render={({ field }) => (
80 <FormItem>
81 <FormControl>
82 <RadioGroup
83 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 <div
89 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 <RadioGroupItem
94 value={type.value}
95 id={type.value.toLowerCase()}
96 className="peer sr-only"
97 />
98 <Label
99 htmlFor={type.value.toLowerCase()}
100 className="block cursor-pointer transition-all duration-300"
101 >
102 <div
103 className={cn(
104 "rounded-xl overflow-hidden border-2 transition-all duration-300",
105 field.value === type.value
106 ? "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 <img
112 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.value
117 ? "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.value
124 ? "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.value
130 ? "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.value
140 ? "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 <li
146 key={index}
147 className={cn(
148 "text-xs sm:text-sm flex items-center transition-all duration-300",
149 field.value === type.value
150 ? "text-primary font-medium translate-x-2"
151 : "text-gray-600 hover:translate-x-1"
152 )}
153 >
154 <span
155 className={cn(
156 "w-1.5 h-1.5 rounded-full mr-2 flex-shrink-0 transition-all duration-300",
157 field.value === type.value
158 ? "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 />
177
178 <div className="flex justify-between pt-4 sm:pt-8 animate-in fade-in-50 duration-700 delay-700">
179 <Button
180 type="button"
181 variant="outline"
182 onClick={handleBack}
183 back
184 >
185
186 Back
187 </Button>
188 <Button
189 type="submit"
190 disabled={!form.watch('skinType')}
191 front
192 >
193 Continue
194 </Button>
195 </div>
196 </form>
197 </Form>
198 </CardContent>
199 </Card>
200 )
201}
202
203export 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.

Step prompting user to choose her preferred routine complexity

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 Preferences
4 } else {
5 handleNext(data) // Proceed as normal
6 }
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...
2
3const { setCurrentStep, getLatestState } = useFormStore()
4
5const { form, handleBack, handleNext, handleNextOveride } = useFormStep({
6 schema: skinTypeSchema,
7 currentStep: step,
8})
9
10..
11
12const customHandleBack = ()=>{
13 const latestState = getLatestState()
14 ## The delivery mode was defined earlier so it is safetly store in the state
15 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.

Step prompting user to select her skin goals

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';
13
14// 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])
26
27function SelectSkinGoals({step}: {step: number}) {
28 const { form, handleBack, handleNext } = useFormStep({
29 schema: skinGoalsSchema,
30 currentStep: step,
31 });
32
33 const [completedSections, setCompletedSections] = useState({
34 skinGoal: false,
35 acneType: false
36 });
37
38 const primaryGoalRef = useRef<HTMLDivElement>(null);
39 const acneTypeRef = useRef<HTMLDivElement>(null);
40
41 // Smooth scroll function
42 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 };
51
52 // Add custom CSS for scroll behavior and animations
53 useEffect(() => {
54 const style = document.createElement('style');
55 style.textContent = `
56 html {
57 scroll-behavior: smooth;
58 scroll-padding-top: 100px;
59 }
60
61 @keyframes bounce-scroll {
62 0%, 100% { transform: translateY(0); }
63 50% { transform: translateY(-10px); }
64 }
65
66 .scroll-indicator {
67 animation: bounce-scroll 1s infinite;
68 }
69 `;
70 document.head.appendChild(style);
71
72 return () => {
73 document.head.removeChild(style);
74 };
75 }, []);
76
77
78
79 const handleSectionComplete = (section: keyof typeof completedSections, value: any) => {
80 setCompletedSections(prev => ({
81 ...prev,
82 [section]: true
83 }));
84 form.setValue(section as any, value);
85
86 // Auto-scroll to next section after a brief delay
87 requestAnimationFrame(() => {
88 if (section === 'skinGoal' && value === "ACNE" && acneTypeRef.current) {
89 smoothScrollToSection(acneTypeRef);
90 }
91 });
92 };
93
94 const renderPrimaryGoalOptions = () => (
95 <RadioGroup
96 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.div
107 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 <div
115 className={cn(
116 "relative rounded-xl border p-6 transition-all duration-300 ease-in-out cursor-pointer",
117 form.getValues("skinGoal") === option.value
118 ? "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 <RadioGroupItem
125 value={option.value}
126 id={option.value}
127 className={cn(
128 form.getValues("skinGoal") === option.value
129 ? "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 <Label
137 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 options
145 </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 );
158
159 const renderAcneTypeOptions = () => (
160 <RadioGroup
161 onValueChange={(value) => {
162 form.setValue("acneType", value as "HORMONAL" | "STRESS_RELATED" | "CONGESTION");
163 setCompletedSections(prev => ({
164 ...prev,
165 acneType: true
166 }));
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.div
192 key={option.value}
193 initial={{ opacity: 0, x: -20 }}
194 animate={{ opacity: 1, x: 0 }}
195 transition={{ delay: index * 0.1 }}
196 >
197 <div
198 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.value
202 ? "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: true
210 }));
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.value
217 ? "border-primary bg-primary text-white"
218 : "border-muted-foreground group-hover:border-primary"
219 )}>
220 {form.getValues("acneType") === option.value && (
221 <motion.div
222 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 <Label
233 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 );
248
249
250
251 return (
252 <Card className="border-none shadow-none">
253 <CardHeader className="text-left md:text-center">
254 <motion.div
255 initial={{ opacity: 0, y: 20 }}
256 animate={{ opacity: 1, y: 0 }}
257 transition={{ duration: 0.4 }}
258 >
259 <CardTitle >
260 Your Skin Goals
261 </CardTitle>
262 <CardDescription>
263 Select your primary skin concern to help us create your perfect skincare routine
264 </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.div
272 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 <FormField
280 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>
292
293 {/* Acne Type Section */}
294 <AnimatePresence>
295 {form.getValues("skinGoal") === "ACNE" && (
296 <motion.div
297 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.div
305 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 <FormField
313 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>
327
328 <motion.div
329 className="flex justify-between pt-8"
330 initial={{ opacity: 0 }}
331 animate={{ opacity: 1 }}
332 transition={{ delay: 0.2 }}
333 >
334 <Button
335 type="button"
336 variant="outline"
337 back
338 onClick={handleBack}
339 >
340 Back
341 </Button>
342 <Button
343 type="submit"
344 front
345 disabled={
346 !completedSections.skinGoal ||
347 (form.getValues("skinGoal") === "ACNE" && !completedSections.acneType)
348 }
349 >
350 Continue
351 </Button>
352 </motion.div>
353 </form>
354 </Form>
355 </CardContent>
356 </Card>
357 )
358}
359
360export 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 selectsAcneas 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.

Final step that includes review of the user choices

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.tsx
2import { 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"
6
7import { useFormStore } from "./lib/store"
8
9import 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"
19
20function App() {
21 const currentStep = useFormStore((state) => state.currentStep)
22
23 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 }
55
56 return (
57 <FormLayout>
58 {renderStep()}
59 </FormLayout>
60 )
61}
62
63export 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<input
2 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

0
Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.