DEV Community

Cover image for Building a Dynamic Multi-Step Form with a Generic Stepper Component in React
Mahdi Abd-Alkareem
Mahdi Abd-Alkareem

Posted on

Building a Dynamic Multi-Step Form with a Generic Stepper Component in React

When building multi-step forms, managing form state and navigation between steps can get complex. In this tutorial, I'll show you how to build a generic, reusable stepper component in React using TypeScript and the Context API. This solution supports custom data and dynamic step navigation while maintaining simplicity.

Stepper Context: Managing Steps and State

The core of our stepper is a context, which manages the active step, form data, and navigation between steps. Let's break down the context and its components.

1. Defining the Stepper Context Types

We start by defining our types using TypeScript generics. The context will store form data, the current active step, and methods for navigating between steps.

StepperContext.tsx

interface IStepperContext<T, S> {
  activeStep: number;
  setActiveStep: (newStep: number) => void;
  navigateTo: (id: IStep<S>["label"]) => void;
  handleSetData: (partial: Partial<T>) => void;
  data: T;
  steps: IStep<S>[];
}

export interface IStep<S> {
  label: S;
  content: React.ReactNode;
}
Enter fullscreen mode Exit fullscreen mode

This interface allows flexibility in how we define the step labels and form data, making it easy to extend for different use cases.

2. Creating the Stepper Context

Next, we create the actual context using React's createContext:

StepperContext.tsx

const StepperContext = createContext<IStepperContext<any, any> | undefined>(undefined);
Enter fullscreen mode Exit fullscreen mode

3. The Stepper Provider

The StepperProvider component wraps around components that need access to stepper data. It handles the navigation logic and stores form data locally in the component's state.

StepperContext.tsx

export const StepperProvider = <T, S extends string>({
  children,
  initialData,
  steps,
}: IStepperProviderProps<T, S>) => {
  const [activeStep, setActiveStep] = useState<number>(0);
  const [data, setData] = useState<T>(initialData);

  const handleSetData: IStepperContext<T, S>["handleSetData"] = (partial) =>
    setData((prev) => ({ ...prev, ...partial }));

  const navigateTo = (id: IStep<S>["label"]) => {
    setActiveStep(steps.findIndex((step) => step.label === id));
  };

  return (
    <StepperContext.Provider
      value={{
        activeStep,
        setActiveStep,
        navigateTo,
        data,
        handleSetData,
        steps,
      }}
    >
      {children}
    </StepperContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Key Features:

State Management: The useState hook manages the current step and form data within the component.
Simple Navigation: You can easily move between steps without complex URL logic, making it simple to handle the navigation internally.

4. Using the Stepper Context

To access the context in child components, we create a custom hook:

useStepper.ts

export const useStepper = <T, S>(): IStepperContext<T, S> => {
  const context = useContext(StepperContext);
  if (context === undefined) {
    throw new Error("useStepper must be used within a StepperProvider");
  }
  return context;
};
Enter fullscreen mode Exit fullscreen mode

This hook ensures that the context is only used within the StepperProvider.

5. The Stepper Component

The Stepper component renders the current step's content based on the active step value.

Stepper.tsx

export const Stepper = <T, S extends string>() => {
  const { activeStep, steps } = useStepper<T, S>();

  return (
    <div className="flex h-full flex-col justify-center gap-10">
      <section className="flex items-center justify-center">
        {steps[activeStep]?.content}
      </section>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

This component automatically renders the correct step content by looking up the current activeStep.

Putting It All Together In One Example

  1. Create the useSignupStepper hook.
import { useStepper } from "@/contexts/stepperContext";

// Define the data structure that will be passed across steps
export interface DataType {
  name: string;
  age: number | null;
  address: string;
}

// Define the step types (these match the steps in your form)
export type StepType =
  | "Introduction"
  | "PersonalInfo"
  | "Address"
  | "Summary"
  | "Confirmation";

export const useSignupStepper = () => useStepper<DataType, StepType>();
Enter fullscreen mode Exit fullscreen mode
  1. To use the Stepper in your project, wrap your component tree with the StepperProvider and define your steps:

SignupStepper.tsx

import React, { useState } from "react";
import { useSignupStepper } from "@/contexts/stepperContext";

const Introduction = () => {
  const { navigateTo } = useSignupStepper();
  return (
    <div>
      <p>Welcome to the form!</p>
      <button onClick={() => navigateTo("PersonalInfo")}>Next</button>
    </div>
  );
};

const PersonalInfo = () => {
  const { navigateTo, handleSetData, data } = useSignupStepper();

  const handleNext = () => {
    if (age >= 18) {
      navigateTo("Address");
    } else {
      navigateTo("Confirmation");
    }
  };

  return (
    <div>
      <p>Please enter your personal information:</p>
      <input
        type="text"
        placeholder="Name"
        value={name}
        onChange={(e) => handleSetData({ name: e.target.value})}
      />
      <input
        type="number"
        placeholder="Age"
        value={age}
        onChange={(e) => handleSetData({ age: e.target.value})}
      />
      <button onClick={handleNext}>Next</button>
      <button onClick={() => navigateTo("Introduction")}>Back</button>
    </div>
  );
};

const Address = () => {
  const { navigateTo, handleSetData, data } = useSignupStepper();

  const handleNext = () => {
    navigateTo("Summary");
  };

  return (
    <div>
      <p>Where do you live?</p>
      <input
        type="text"
        placeholder="Address"
        value={address}
        onChange={(e) => handleSetData({ address: e.target.value})}
      />
      <button onClick={handleNext}>Next</button>
      <button onClick={() => navigateTo("PersonalInfo")}>Back</button>
    </div>
  );
};

const Summary = () => {
  const { data, navigateTo } = useSignupStepper();

  const handleBack = () => {
    if (data.age < 18) {
      navigateTo("PersonalInfo");
    } else {
      navigateTo("Address");
    }
  };

  return (
    <div>
      <p>Here's a summary of your information:</p>
      <p>Name: {data.name}</p>
      <p>Age: {data.age}</p>
      <p>Address: {data.address}</p>
      <button onClick={() => navigateTo("Confirmation")}>Submit</button>
      <button onClick={handleBack}>Back</button>
    </div>
  );
};

const Confirmation = () => {
  const { navigateTo } = useSignupStepper();
  return (
    <div>
      <p>Thank you! You've completed the form.</p>
      <button onClick={() => navigateTo("Introduction")}>Restart</button>
    </div>
  );
};

// Define the steps for the stepper
const steps = [
  { label: "Introduction", content: <Introduction /> },
  { label: "PersonalInfo", content: <PersonalInfo /> },
  { label: "Address", content: <Address /> },
  { label: "Summary", content: <Summary /> },
  { label: "Confirmation", content: <Confirmation /> },
];

// Use the StepperProvider to wrap your stepper
export const StepperExample = () => (
  <StepperProvider
    initialData={{ name: "", age: null, address: "" }} // Set initial form data
    steps={steps}
  >
    <Stepper />
  </StepperProvider>
);
Enter fullscreen mode Exit fullscreen mode

Adding More Functionality
Here are a few extra features you can add to the stepper:

  • Form Validation: Implement custom validation logic before navigating to the next step.
  • Progress Bar: Add a visual representation of progress by tracking the current step index.
  • Error Handling: Show error messages when users fail to fill required fields before proceeding to the next step.

Conclusion

This approach provides a robust and flexible solution for building multi-step forms in React. By using TypeScript and Context API, we ensure that the form can handle a wide range of use cases, whether you're building a simple sign-up flow or a complex survey form.

Feel free to extend this pattern further by adding custom validation, animations, or integrating with backend APIs.

Top comments (0)