Multi-step forms are a popular UX pattern when collecting a large amount of information from users. Rather than overwhelming users with a long, single-page form, breaking it into manageable steps improves clarity, reduces friction, and leads to better conversions.
Traditionally, building multi-step forms in React involved managing state with useState
, useReducer
, or external libraries. But with the arrival of the useActionState
hook in React 18.2, there’s a new way to handle form state more declaratively—especially with native HTML forms and async logic.
While useActionState
is typically used for simple, single-form submissions, with the right structure, it can power dynamic multi-step form flows that are both clean and performant.
In this blog, we’ll explore how to build a complete multi-step form experience using useActionState
in React. We’ll handle step transitions, validate form data, manage state transitions, and show success or error messages—all without cluttering the component with manual state management.
Why Use useActionState
for Multi-Step Forms?
Before diving into code, it’s worth asking: why use useActionState
for a form that spans multiple steps?
Here are a few reasons:
- It simplifies async form submission with integrated state updates.
- It works natively with HTML form elements and
FormData
. - You can keep logic centralized in a single reducer-style function.
- It integrates well with server actions in frameworks like Next.js.
That said, building multi-step forms with this hook requires a structured approach, since useActionState
updates state only on submission.
Designing the Multi-Step Flow
The most effective way to approach multi-step forms with useActionState
is to think of the form in terms of steps and transitions. Each step collects a part of the data, and on submission, you move to the next step or show final results.
We’ll structure the form into three steps:
- User Information (name, email)
- Account Setup (username, password)
- Review & Submit
Each step submits partial data, which is merged and passed forward to the next.
Step 1: Define the State Shape
We need to track:
- Current step
- Collected form data
- Errors or messages
type FormStep = 1 | 2 | 3;
type FormDataState = {
step: FormStep;
data: {
name?: string;
email?: string;
username?: string;
password?: string;
};
error: string | null;
success: string | null;
};
This state will be passed and updated through useActionState
.
Step 2: Create the Reducer Function
The reducer will manage form submissions, validations, and step transitions.
const formReducer = async (
prevState: FormDataState,
formData: FormData
): Promise<FormDataState> => {
const step = prevState.step;
const newData = { ...prevState.data };
if (step === 1) {
const name = formData.get('name');
const email = formData.get('email');
if (!name || typeof name !== 'string') {
return { ...prevState, error: 'Name is required' };
}
if (!email || typeof email !== 'string' || !email.includes('@')) {
return { ...prevState, error: 'Valid email is required' };
}
newData.name = name;
newData.email = email;
return { step: 2, data: newData, error: null, success: null };
}
if (step === 2) {
const username = formData.get('username');
const password = formData.get('password');
if (!username || typeof username !== 'string') {
return { ...prevState, error: 'Username is required' };
}
if (!password || typeof password !== 'string' || password.length < 6) {
return { ...prevState, error: 'Password must be at least 6 characters' };
}
newData.username = username;
newData.password = password;
return { step: 3, data: newData, error: null, success: null };
}
if (step === 3) {
return {
...prevState,
error: null,
success: `Account created for ${prevState.data.username}`,
};
}
return prevState;
};
Step 3: Setup Initial State and useActionState
const initialState: FormDataState = {
step: 1,
data: {},
error: null,
success: null,
};
const [state, formAction] = useActionState(formReducer, initialState);
This gives us both the form state and the formAction
handler to attach to our form.
Step 4: Render the Form by Step
Each step will render a different section of the form, but the same action
handler is used for submission.
<form action={formAction}>
{state.step === 1 && (
<>
<h2>Step 1: User Information</h2>
<input name="name" placeholder="Name" defaultValue={state.data.name || ''} />
<input name="email" placeholder="Email" defaultValue={state.data.email || ''} />
</>
)}
{state.step === 2 && (
<>
<h2>Step 2: Account Setup</h2>
<input name="username" placeholder="Username" defaultValue={state.data.username || ''} />
<input name="password" placeholder="Password" type="password" />
</>
)}
{state.step === 3 && (
<>
<h2>Step 3: Review</h2>
<p><strong>Name:</strong> {state.data.name}</p>
<p><strong>Email:</strong> {state.data.email}</p>
<p><strong>Username:</strong> {state.data.username}</p>
<p>Password: ••••••</p>
<p>Click submit to complete</p>
</>
)}
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state.success && <p style={{ color: 'green' }}>{state.success}</p>}
{state.step <= 3 && !state.success && (
<button type="submit">{state.step === 3 ? 'Submit' : 'Next'}</button>
)}
</form>
Additional Enhancements
To keep the flow forward-only, you can omit the “Back” button entirely. However, if you want to support going backward, you can include another button with a local state to manage step changes without submitting the form.
Pre-fill on Refresh
Because useActionState
stores state only during a single session, refreshing the page will reset the form. For persistent multi-step forms, you can sync the state with localStorage or URL parameters, although that adds complexity.
Loading State
You can add a loading spinner by setting a loading
state using useState
inside the reducer or wrapping it around the reducer function with a wrapper that toggles loading before and after reducer execution.
Final Thoughts
Multi-step forms offer a smoother user experience for lengthy inputs, and useActionState
makes handling them cleaner than ever. By centralizing submission logic in one place, you avoid messy event handlers and repetitive state updates. Although not originally designed for multi-step scenarios, useActionState
proves flexible enough when you treat each submission as a state transition.
This approach aligns with modern React practices—favoring declarative form logic, native HTML elements, and streamlined async behavior. Whether you’re building a user registration flow, survey, or onboarding wizard, you can confidently build multi-step forms with useActionState
and keep your components lean, readable, and easy to maintain.