Creating a reusable multi-step form

Kartikey Kumar
SAFE Engineering
Published in
7 min readNov 6, 2023

Forms are a fundamental component of web applications, allowing users to interact with a website by providing input and submitting data. They serve as a crucial means for gathering information, conducting transactions, and enabling user interactions.

In addition to understanding the fundamentals of forms, we’ll embark on a journey to create a powerful and reusable multi-step form component using React. This component will enable developers to seamlessly integrate multi-step forms into their projects, providing a structured and intuitive user experience.

The Power of Reusability

Creating a reusable multi-step form component in React opens up a world of possibilities for developers. It allows for the rapid development of complex forms across various applications, saving time and effort in the process. By encapsulating the form logic into a reusable component, developers can efficiently implement multi-step forms with minimal boilerplate code.

Our Approach: State Uplifting

To achieve re-usability, we’ll employ a technique known as state uplift. This approach involves lifting the form’s state to a higher-level component, enabling it to be shared among multiple child components. By centralizing the form state management, we create a flexible foundation for building multi-step forms.

Throughout the blog, we’ll guide you through each step, providing detailed explanations and code examples. From setting up the initial React app to implementing form validation and submission handling, you’ll gain a comprehensive understanding of how to create a versatile multi-step form component.

Step 1: Setting Up Your React App

Create a new React application to serve as the foundation for our multi-step form component.

  • Use create-react-app or your preferred method to set up a new React project.
  • Navigate to the project directory and ensure it’s ready for development.

Step 2: Planning the Form Structure

Plan and design the structure of the multi-step form, including the data to be collected and the validation requirements.

Plan the architecture according to the way you want. Think along the lines of who will store the data, etc. Think of the form as a component where you would send the content you want to render the form would render it the way you like there should not be additional complexities when validating, storing, and accessing data.

Following is the component architecture:

  • Multi-step form: We would start with a component that would take in the step heading and content to render it. It would also take in the functions that would be called on clicking on submit.
  • Utilizing the form component to be reusable to accept any content and render it with the required validations.
  • We also need to have a separation of concerns so that we know which component is doing what. Like validation is a part of the content provided not the main boilerplate component. The boilerplate component should not allow the user to move to the next step until it is validated.
  • The boilerplate component would be something like this:
interface FormData {
[stepId: string]: unknown;
}

interface StepContentProps {
data: FormData;
onDataChange: (data: unknown) => void;
shouldGoNext?: (shouldGoNext: boolean) => void;
onOk?: React.MutableRefObject<(() => void) | null>;
}

interface StepData {
title: string;
id: string;
content: React.ComponentType<StepContentProps>;
/**
* Provide the option to allow moving to next step
* after should go next is called with true or just
* move to next step without validation
*/
shouldValidate?: boolean;
}

interface MultiStepFormProps {
/**
* Test id associated with the form.
*/
testId: string;
/**
* List of Steps.
*/
stepData: StepData[];
/**
* On Submit Handler for the form.
*/
onSubmit: (data: unknown) => void;
/**
* Name of the submit button
*/
submitButtonName?: string;
/**
* Helps to set the submit button in progress state
*/
isSubmitInProgress?: boolean;
}

Step 3: Implementing the boilerplate component

Implement state uplift to efficiently manage the form’s state across multiple steps.

  • In the parent component (e.g., MultiStepForm), create a state to hold the form data.
  • Pass down the form data and a function to update it as props to each step component.

Example of creating the component with using refs and state uplifting

const MultiStepForm = ()=>{
const [formData, setFormData] = useState<FormData>();
/**
* Create and use the ref to get access of the next and submit button
* as it is out of the context from the boilerplate and content
*/
const okRef = useRef<(() => void) | null>(null);
/**
* Get the content from the stepData and render it.
*/
const CurrentStepContent = stepData[currentStep].content;
/**
* Set the data in the state and use across the component
**/
const handleStepChange = (stepName: string, data: unknown): void => {
setFormData(prevData => ({
...prevData,
[stepName]: data
}));
};
return (
<CurrentStepContent
data={formData}
/**
* We would call the onDataChange function with the data we want the
* step to contain
**/
onDataChange={data =>
handleStepChange(stepData[currentStep].id, data)
}
shouldGoNext={setShouldGoNext}
onOk={okRef}
shouldDisableNext={isDisabled =>
setIsNextDisabled(isDisabled)
}
/>
<Button
type="submit"
text={submitButtonName}
onClick={() => {
/**
* Use and send the ref to get proper accessing of the next button
* as it is out of the context of the content
*/
if (okRef.current && shouldValidate) {
okRef.current();
}
if (!shouldValidate) {
onSubmit(formData);
}
}}
/>
)
}

Step 4: Managing Navigation Between Steps

Manage navigation between steps, allowing users to progress forward and backward in the form.

  • Create logic to track the current step index and update it based on user interactions (Next and Back buttons).
const [currentStep, setCurrentStep] = useState<number>(0);
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(false);

/**
* function to call on clicking of next button
*/
const handleNext = (): void => {
if (currentStep < stepData.length - 1) {
setCurrentStep(currentStep + 1);
}
};

/**
* function to call on clicking of back button
*/
const handleBack = (): void => {
if (currentStep > 0) {
/**
* Reset disabled state on backButton click
*/
setIsNextDisabled(false);
setCurrentStep(currentStep - 1);
}
};

//Back Button
<Button
text="Back"
variant="secondary"
onClick={() => handleBack()}
/>

//Next Button
<Button
text="Next"
variant="primary"
onClick={() => {
if (okRef.current) {
okRef.current();
} else {
handleNext();
}
}}
isDisabled={isNextDisabled}
testId={`button-${currentStep}-next`}
/>

Step 5: Handling Form Data and Validation

Manage form data and implement any necessary validation logic within each step component.

  • Update the form data in response to user input (e.g., typing in input fields).
  • Implement validation logic to ensure the data meets specified criteria.
interface StepData {
/**
* Add a prop to allow validation and only move to next or submit when
* validation is success
*/
shouldValidate?: boolean;
}

/**
*
*/
const { shouldValidate } = stepData[currentStep];

const handleNext = (): void => {
if (currentStep < stepData.length - 1) {
setCurrentStep(currentStep + 1);
}
};
// call the onSubmit the user calls and sends the data as argument so that we
// can access them as the prop when calling the function
const handleSubmit = (): void => {
if (currentStep === stepData.length - 1) {
onSubmit(formData);
}
};
// Call the function if shouldGoNext is called and call handle submit and
// and next
const setShouldGoNext = (shouldGoNext: boolean): void => {
if (shouldGoNext) {
handleSubmit();
handleNext();
}
};

<CurrentStepContent
data={formData}
/**
* We would call the onDataChange function with the data we want the
* step to contain
**/
onDataChange={data =>
handleStepChange(stepData[currentStep].id, data)
}
/**
*
*/
shouldGoNext={setShouldGoNext}
onOk={okRef}
shouldDisableNext={isDisabled =>
setIsNextDisabled(isDisabled)
}
/>

<Button
text="Next"
variant="primary"
onClick={() => {
if (okRef.current && shouldValidate) {
okRef.current();
} else {
handleNext();
}
}}
isDisabled={isNextDisabled}
testId={`button-${currentStep}-next`}
/>

<Button
type="submit"
text={submitButtonName}
onClick={() => {
/**
* Use and send the ref to get proper accessing of the next button
* as it is out of the context of the content if should validate
* is true
*/
if (okRef.current && shouldValidate) {
okRef.current();
}
if (!shouldValidate) {
onSubmit(formData);
}
}
}

Step 6: Handling Form Submission

Implement the final submission logic, ensuring that all steps have been completed before allowing submission.

  • Create a submission handler in the parent component which calls the multi-step form component that processes the form data.
// Calls this function when clicked on submit button
const handleOnSubmit = (data: unknown): void => {
};

const steps: StepData[] = [
{
title: CREATE_GROUP_STEP_NAME.GROUP_DETAILS,
content: GroupDetailsComponent,
id: BUSINESS_GROUP_CREATION_STEP_KEYS.GROUP_DETAILS,
stepIcon: <DocumentationIcon />,
shouldValidate: true
},
{
title: CREATE_GROUP_STEP_NAME.SELECT_BUSINESS_RESOURCES,
content: BusinessResourceComponent,
id: BUSINESS_GROUP_CREATION_STEP_KEYS.BUSINESS_RESOURCE,
stepIcon: <MonitorSearchIcon />,
shouldValidate: true
}
]

//Example of the content
const StepInputContent: React.FC<StepContentProps> = ({
data,
onDataChange,
shouldGoNext,
onOk,
shouldDisableNext
}) => {
const [input, setInput] = useState(
(data["input-component"] as { input1: { value: string } })?.input1
?.value || ""
);
const [error, setError] = useState("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
onDataChange({
["input1"]: { value }
});
setInput(value);
setError("");
};
const inputFunction = () => {
if (shouldGoNext) {
if (!input) {
setError("This is a required field");
shouldGoNext(false);
} else {
shouldGoNext(true);
}
}
};
// helps in adding validations
const handleDisableNext = (): void => {
if (shouldDisableNext) {
if (!input.length) {
shouldDisableNext(true);
} else {
shouldDisableNext(false);
}
}
};
useEffect(() => {
handleDisableNext();
// on clicking of next call the ref and use
if (onOk) {
onOk.current = inputFunction;
}
return () => {
if (onOk) {
onOk.current = null;
}
};
/**
* This is passed as dependency so that on each state change ref gets redefined.
*/
}, [input]);

return (
<Card height={600} title="Input Component">
<Input
id={"inputBox"}
type="text"
value={input}
onChange={handleInputChange}
isRequired={true}
label="Input with Next/Submit Disabled"
errorMessage={error}
/>
</Card>
);
};

<MultiStepForm
stepData={steps}
onSubmit={data => handleOnSubmit(data as CreateGroupsData)}
submitButtonName="Create Group"
/>

Conclusion

By following these steps, we will successfully build a reusable multi-step form component in React that is easy to read and is not complex. This versatile component can be integrated into various applications to provide users with a structured and intuitive form-filling experience.

--

--