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;
}
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);
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>
);
};
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;
};
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>
);
};
This component automatically renders the correct step content by looking up the current activeStep.
Putting It All Together In One Example
- 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>();
- 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>Heres 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! Youve 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>
);
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.