Imagine you’re building a complex multi-step form for your web application. However, after tweaking the user interface (UI) or adjusting validations, you save, reload, and lose all the progress, forcing you to start over and re-enter every field to test a single step. The process is tedious, time-consuming, and frustrating.
Creating multi-step forms is a common task for developers building modern web applications. These forms break down long and complex input processes into smaller, more manageable steps, enhancing the user experience.
What if there were a way to preserve your form’s state through these changes? In this article, we show you how to leverage React’s useReducer, the react-hook-form library, and a custom hook to persist state, transforming how you build and test multi-step forms. We also explore how to build this type of form using React.
Why Use useReducer, react-hook-form and custom hook to Persist State?
useReducer is a powerful React hook that provides a predictable way to manage complex state logic. It’s beneficial for multi-step forms because it enables centralized control of the form state, making it easier to handle transitions between steps.
react-hook-form simplifies form validation and management by offering a lightweight and performant solution. It integrates seamlessly with React components, enabling you to reduce boilerplate code and improve performance.
The custom hook to persist the reducer state is particularly valuable for large, multi-step forms because it ensures that its state is preserved even if the development environment undergoes a hot reload. This reduces the need to manually re-enter data during testing, saving time and improving efficiency.
Together, these tools create a robust foundation for building scalable and efficient multi-step forms.
Project Set Up
First, create a new React project and install the necessary dependencies:
npm create vite@latest
cd multi-step-form
npm install react-hook-formStep 1: Define the Form Steps and State
Start by defining the structure of your multi-step form. For this example, let’s assume a three-step form with the following fields:
Form 1: Name and email.
Form 2: Address and phone number.
Form 3: Review and submit.
Create a formReducer to manage the form state. To persist the state between hot reloads, use useEffect and localStorage:
const initialState = {
currentStep: 1,
data: {
name: "",
email: "",
address: "",
phone: "",
},
};
function formReducer(state, action) {
switch (action.type) {
case "NEXT_STEP":
return { …state, currentStep: state.currentStep + 1 };
case "PREV_STEP":
return { …state, currentStep: state.currentStep - 1 };
case "UPDATE_DATA":
return { …state, data: { …state.data, …action.payload } };
default:
return state;
}
}Step 2: Create the Form Components
Define individual components for each step:
- Form 1: Personal Information
import { useForm } from "react-hook-form";
function StepOne({ data, onNext }) {
const { register, handleSubmit } = useForm({ defaultValues: data });
const onSubmit = (formData) => {
onNext(formData);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Name</label>
<input {…register("name", { required: true })} />
{errors.name && <span>This field is required</span>}
</div>
<div>
<label>Email</label>
<input type="email" {…register("email", { required: true })} />
{errors.email && <span>This field is required</span>}
</div>
<button type="submit">Next</button>
</form>
);
}
export default StepOne;- Form 2: Contact Information
import { useForm } from "react-hook-form";
function StepTwo({ data, onNext, onBack }) {
const { register, handleSubmit } = useForm({ defaultValues: data });
const onSubmit = (formData) => {
onNext(formData);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Address</label>
<input {…register("address", { required: true })} />
{errors.address && <span>This field is required</span>}
</div>
<div>
<label>Phone</label>
<input type="tel" {…register("phone", { required: true })} />
{errors.phone && <span>This field is required</span>}
</div>
<button type="button" onClick={onBack}>Back</button>
<button type="submit">Next</button>
</form>
);
}
export default StepTwo;- Form 3: Review and Submit
import { useForm } from "react-hook-form";
function StepThree({ data, onSubmit, onBack }) {
return (
<div>
<h3>Review Your Information</h3>
<pre>{JSON.stringify(data, null, 2)}</pre>
<button onClick={onBack}>Back</button>
<button onClick={onSubmit}>Submit</button>
</div>
);
}
export default StepThree;Step 3: Build the custom hook to the persistent reducer state
The usePersistentStateReducer hook combines React’s useReducer with useState, useEffect, and useCallback to provide a state management solution that persists its state in localStorage. Here’s a detailed explanation of how it works:
- Initializing the State from localStorage:
Initialize the value state by checking if there’s already data in localStorage. If no data exists, it defaults to the initialStatem. The Result is the initial state for the reducer, either from localStorage or initialState.
const [value, setValue] = useState(() => {
let currentValue;
try {
currentValue = JSON.parse(
localStorage.getItem(local_storage_key) || initialState
);
} catch (error) {
currentValue = initialState;
console.log(error);
}
return currentValue;
});- Persisting the State to localStorage
Ensuring that every change to state value is synchronized with localStorage. so the state remains persistent across reloads or hot reloads.
useEffect(() => {
localStorage.setItem(local_storage_key, JSON.stringify(value));
}, [value, local_storage_key]);- Creating a Memoized Reducer with useCallback
Create a wrapper around the reducer function to ensure that state changes are saved to both useReducer and localStorage. useCallback ensures that this function is only re-created when the reducer or setValue changes.
const reducerLocalStorage = useCallback(
(state, action) => {
const newState = reducer(state, action);
setValue(newState);
return newState;
},
[reducer, setValue]
);- Using useReducer with the Enhanced Reducer
This leverages React’s useReducer to manage the state, but uses the memoized reducerLocalStorage to sync state changes with localStorage.
const [state, dispatch] = useReducer(reducerLocalStorage, value);- Returning the State and Dispatch
Provides the state and dispatch for the component using this hook, allowing it to interact with the persisted state.
return { state, dispatch };- Final usePersistentStateReducer hook
export function usePersistentStateReducer(
local_storage_key,
reducer,
initialState
) {
// Get value from Localstorage or set initial state
const [value, setValue] = useState(() => {
let currentValue;
try {
currentValue = JSON.parse(
localStorage.getItem(local_storage_key) || initialState
);
} catch (error) {
currentValue = initialState;
console.log(error);
}
return currentValue;
});
// Set Localstorage value
useEffect(() => {
localStorage.setItem(local_storage_key, JSON.stringify(value));
}, [value, local_storage_key]);
// Memoized function that syncs the newState to localStorage
const reducerLocalStorage = useCallback(
(state, action) => {
const newState = reducer(state, action);
setValue(newState);
return newState;
},
[reducer, setValue]
);
const [state, dispatch] = useReducer(reducerLocalStorage, value);
return { state, dispatch };
}Step 4: Build the Main Form Component
Combine the components and manage state transitions in a parent component:
import React from "react";
import { usePersistentReducer } from "./usePersistentStateReducer";
import StepOne from "./StepOne";
import StepTwo from "./StepTwo";
import StepThree from "./StepThree";
const initialState = {
currentStep: 1,
data: {
name: "",
email: "",
address: "",
phone: "",
},
};
function formReducer(state, action) {
switch (action.type) {
case "NEXT_STEP":
return { …state, currentStep: state.currentStep + 1 };
case "PREV_STEP":
return { …state, currentStep: state.currentStep - 1 };
case "UPDATE_DATA":
return { …state, data: { …state.data, …action.payload } };
default:
return state;
}
}
function MultiStepForm() {
const { state, dispatch } = usePersistentStateReducer(
"local_storage_key",
formReducer,
initialState
);
function nextStep(data) {
dispatch({ type: "UPDATE_DATA", payload: data });
dispatch({ type: "NEXT_STEP" });
}
function prevStep() {
dispatch({ type: "PREV_STEP" });
}
function handleSubmit() {
console.log("Form submitted:", state.data);
alert("Form submitted !");
// Clear state after submission
localStorage.removeItem("local_storage_key");
}
const steps = {
1: <StepOne data={state.data} onNext={nextStep} />,
2: <StepTwo data={state.data} onNext={nextStep} onBack={prevStep} />,
3: <StepThree data={state.data} onSubmit={handleSubmit} onBack={prevStep} />,
};
return steps[state.currentStep];
}
export default MultiStepForm;Conclusion
By combining useReducer for state management and react-hook-form for efficient form handling, you create scalable and user-friendly multi-step forms. Adding state persistence with localStorage ensures a smoother development and testing experience, as your form state will persist even after hot reloads. This approach reduces complexity, improves code maintainability, and provides a great user experience.
Try implementing this in your next project and experience the benefits of building multi-step forms with React.