Master Multi-Step Forms: Build a Dynamic React Form in 6 Simple Steps

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.

Skincare routine builder multi-step form
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.

Photo by Alvaro Reyes on Unsplash

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:

  1. Welcome Screen — Introduces the form and explains the purpose of collecting skincare-related data.
  2. Select Skin Type — The user identifies their skin type (e.g., dry, oily, combination), which helps personalize recommendations.
  3. 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).
  4. Age Group — The user selects their age range to further refine skincare suggestions.
  5. Environmental Factors — This step collects information about exposure to environmental stressors like pollution and sun damage.
  6. Lifestyle Factors — Users provide insights into their habits, such as diet, stress levels, and sleep patterns, which influence skin health.
  7. Exfoliation Tolerance — Determines how often and what type of exfoliation the user prefers. If anything but “Never” is selected, additional options appear for choosing between Physical Scrubs, Chemical Exfoliants, or Enzyme Exfoliators.
  8. Ingredient Preferences — Users can specify preferred or avoided ingredients, such as fragrance-free or natural products.
  9. Routine Complexity — Allows users to choose between a simple or multi-step skincare regimen.
  10. Budget Allocation — Determines how much the user is willing to spend on skincare products.
  11. Ethical Preferences — Collects data on ethical considerations, such as cruelty-free or vegan skincare preferences.
  12. Makeup Question — Asks about the user’s makeup habits to refine skincare recommendations.
  13. 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

  1. React Hook Form: Well-documented, battle-tested form management and validation.
  2. Tailwind CSS: For styling — nothing more is needed.
  3. Zustand: For managing form state across steps and sessions — super simple and lightweight state manager.
  4. Zod: A no-brainer for schema-based validation — natively integrates with RHF.
  5. 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:

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:

  • 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

// 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:

  • 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 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.

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:

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:

  1. Schema Validation: We create a form validation schema for this particular step using Zod.
  2. We define options for the user to choose from in skinTypes
  3. Reusable Hook: We call our custom-created hook from the previous step.
  4. 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 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:

  • 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.


...

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.

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.

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.

  • 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:

  1. Using a switch statement: Based on the current step, we use a switch statement to determine which component should be rendered.
  2. 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.

// 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:

  • 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

要查看或添加评论,请登录

Matija ?iberna的更多文章