Multi-Step Forms Using useActionState in React

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:

  1. User Information (name, email)
  2. Account Setup (username, password)
  3. 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

Disable Back Navigation

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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top