12 April 2026

Multi-Step Form with React Hook Form: A Complete Guide

Building a multi-step form in React from scratch requires a surprising amount of boilerplate. You need to track the current step, manage state across all of them, and render the right form at the right time.

What is your name?

Fortunately, there's a library that takes care of all of this for you: Formity. In this article, you'll learn how to use Formity to build a multi-step form in just a few minutes.

Initial Steps

To follow along, start by cloning the GitHub repository below, which contains a basic React project set up with everything you need to get started.

git clone https://github.com/martiserra99/formity-tutorial

Then run the following command to install the dependencies.

npm install

Single-Step Form

If you look at the app.tsx file, you'll see a single-step form built with React Hook Form. Formity works with any single-step form library, though, so you're not limited to this one.

// app.tsx
import { useCallback, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

import {
  FormStep,
  FormStepContent,
  FormStepHeading,
  FormStepInputs,
  FormStepRow,
} from "./components/form-step";

import { TextInput } from "./components/input/text-input";
import { NumberInput } from "./components/input/number-input";
import { NextButton } from "./components/buttons/next-button";
import { Output } from "./components/output";

export default function App() {
  const [output, setOutput] = useState<unknown>(null);

  const onSubmit = useCallback((output: unknown) => {
    setOutput(output);
  }, []);

  if (output) {
    return <Output output={output} onStart={() => setOutput(null)} />;
  }

  return (
    <FormStep
      defaultValues={{
        name: "",
        surname: "",
        age: 20,
      }}
      resolver={zodResolver(
        z.object({
          name: z
            .string()
            .min(1, { message: "Required" })
            .max(20, { message: "Must be at most 20 characters" }),
          surname: z
            .string()
            .min(1, { message: "Required" })
            .max(20, { message: "Must be at most 20 characters" }),
          age: z
            .number()
            .min(18, { message: "Minimum of 18 years old" })
            .max(99, { message: "Maximum of 99 years old" }),
        }),
      )}
      onSubmit={onSubmit}
    >
      <FormStepContent>
        <FormStepHeading>Tell us about yourself</FormStepHeading>
        <FormStepInputs>
          <FormStepRow>
            <TextInput name="name" label="Name" placeholder="Your name" />
            <TextInput
              name="surname"
              label="Surname"
              placeholder="Your surname"
            />
          </FormStepRow>
          <NumberInput name="age" label="Age" placeholder="Your age" />
        </FormStepInputs>
        <NextButton>Submit</NextButton>
      </FormStepContent>
    </FormStep>
  );
}

Formity Component

To get started with Formity, we'll replace the existing code in app.tsx with the Formity component, which is responsible for rendering the multi-step form. Its two most important props are:

  • schema: Defines the structure and behavior of the multi-step form.
  • onReturn: A callback triggered when the form is completed.
// app.tsx
import { useCallback, useState } from "react";

import { Formity, type OnReturn, type ReturnOutput } from "@formity/react";

import { Output } from "./components/output";

export default function App() {
  const [output, setOutput] = useState<ReturnOutput<[]> | null>(null);

  const onReturn = useCallback<OnReturn<[]>>((output) => {
    setOutput(output);
  }, []);

  if (output) {
    return <Output output={output} onStart={() => setOutput(null)} />;
  }

  return <Formity<[]> schema={[]} onReturn={onReturn} />;
}

Form Schema

Next, we'll define the schema, which is what controls the structure and behavior of the multi-step form. We'll add a schema.tsx file with the following code.

// schema.tsx
import type { Schema, Form } from "@formity/react";

import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

import {
  FormStep,
  FormStepContent,
  FormStepHeading,
  FormStepInputs,
  FormStepRow,
} from "./components/form-step";

import { Select } from "./components/input/select";
import { TextInput } from "./components/input/text-input";
import { NumberInput } from "./components/input/number-input";
import { NextButton } from "./components/buttons/next-button";
import { BackButton } from "./components/buttons/back-button";

export type Values = [
  Form<{ name: string; surname: string; age: number }>,
  Form<{ softwareDeveloper: string }>,
];

export const schema: Schema<Values> = [
  {
    form: {
      values: () => ({
        name: ["", []],
        surname: ["", []],
        age: [20, []],
      }),
      render: ({ values, onNext }) => (
        <FormStep
          key="yourself"
          defaultValues={values}
          resolver={zodResolver(
            z.object({
              name: z
                .string()
                .min(1, { message: "Required" })
                .max(20, { message: "Must be at most 20 characters" }),
              surname: z
                .string()
                .min(1, { message: "Required" })
                .max(20, { message: "Must be at most 20 characters" }),
              age: z
                .number()
                .min(18, { message: "Minimum of 18 years old" })
                .max(99, { message: "Maximum of 99 years old" }),
            }),
          )}
          onSubmit={onNext}
        >
          <FormStepContent>
            <FormStepHeading>Tell us about yourself</FormStepHeading>
            <FormStepInputs>
              <FormStepRow>
                <TextInput name="name" label="Name" placeholder="Your name" />
                <TextInput
                  name="surname"
                  label="Surname"
                  placeholder="Your surname"
                />
              </FormStepRow>
              <NumberInput name="age" label="Age" placeholder="Your age" />
            </FormStepInputs>
            <NextButton>Next</NextButton>
          </FormStepContent>
        </FormStep>
      ),
    },
  },
  {
    form: {
      values: () => ({
        softwareDeveloper: ["yes", []],
      }),
      render: ({ values, onNext, onBack }) => (
        <FormStep
          key="softwareDeveloper"
          defaultValues={values}
          resolver={zodResolver(
            z.object({
              softwareDeveloper: z.string(),
            }),
          )}
          onSubmit={onNext}
        >
          <FormStepContent>
            <FormStepHeading>Are you a software developer?</FormStepHeading>
            <FormStepInputs>
              <Select
                name="softwareDeveloper"
                label="Software developer"
                options={[
                  { value: "yes", label: "Yes" },
                  { value: "no", label: "No" },
                ]}
              />
            </FormStepInputs>
            <FormStepRow>
              <BackButton onBack={onBack}>Back</BackButton>
              <NextButton>Submit</NextButton>
            </FormStepRow>
          </FormStepContent>
        </FormStep>
      ),
    },
  },
];

The schema constant is an array of type Schema that can contain different types of elements. Here, we've defined two form elements.

To ensure full type safety, Schema also accepts a Values type that describes the values handled at each step of the form.

Now that the schema is ready, we can pass it to the Formity component as shown below.

// app.tsx
import { useCallback, useState } from "react";

import { Formity, type OnReturn, type ReturnOutput } from "@formity/react";

import { Output } from "./components/output";

import { schema, type Values } from "./schema";

export default function App() {
  const [output, setOutput] = useState<ReturnOutput<Values> | null>(null);

  const onReturn = useCallback<OnReturn<Values>>((output) => {
    setOutput(output);
  }, []);

  if (output) {
    return <Output output={output} onStart={() => setOutput(null)} />;
  }

  return <Formity<Values> schema={schema} onReturn={onReturn} />;
}

If you try completing the form right now, you'll notice the onReturn callback never fires. That's because we still need to add a return element to the schema, as shown below.

// schema.tsx
import type { Schema, Form, Return } from "@formity/react";

import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

import {
  FormStep,
  FormStepContent,
  FormStepHeading,
  FormStepInputs,
  FormStepRow,
} from "./components/form-step";

import { Select } from "./components/input/select";
import { TextInput } from "./components/input/text-input";
import { NumberInput } from "./components/input/number-input";
import { NextButton } from "./components/buttons/next-button";
import { BackButton } from "./components/buttons/back-button";

export type Values = [
  Form<{ name: string; surname: string; age: number }>,
  Form<{ softwareDeveloper: string }>,
  Return<{
    name: string;
    surname: string;
    age: number;
    softwareDeveloper: boolean;
  }>,
];

export const schema: Schema<Values> = [
  {
    form: {
      values: () => ({
        name: ["", []],
        surname: ["", []],
        age: [20, []],
      }),
      render: ({ values, onNext }) => (
        <FormStep
          key="yourself"
          defaultValues={values}
          resolver={zodResolver(
            z.object({
              name: z
                .string()
                .min(1, { message: "Required" })
                .max(20, { message: "Must be at most 20 characters" }),
              surname: z
                .string()
                .min(1, { message: "Required" })
                .max(20, { message: "Must be at most 20 characters" }),
              age: z
                .number()
                .min(18, { message: "Minimum of 18 years old" })
                .max(99, { message: "Maximum of 99 years old" }),
            }),
          )}
          onSubmit={onNext}
        >
          <FormStepContent>
            <FormStepHeading>Tell us about yourself</FormStepHeading>
            <FormStepInputs>
              <FormStepRow>
                <TextInput name="name" label="Name" placeholder="Your name" />
                <TextInput
                  name="surname"
                  label="Surname"
                  placeholder="Your surname"
                />
              </FormStepRow>
              <NumberInput name="age" label="Age" placeholder="Your age" />
            </FormStepInputs>
            <NextButton>Next</NextButton>
          </FormStepContent>
        </FormStep>
      ),
    },
  },
  {
    form: {
      values: () => ({
        softwareDeveloper: ["yes", []],
      }),
      render: ({ values, onNext, onBack }) => (
        <FormStep
          key="softwareDeveloper"
          defaultValues={values}
          resolver={zodResolver(
            z.object({
              softwareDeveloper: z.string(),
            }),
          )}
          onSubmit={onNext}
        >
          <FormStepContent>
            <FormStepHeading>Are you a software developer?</FormStepHeading>
            <FormStepInputs>
              <Select
                name="softwareDeveloper"
                label="Software developer"
                options={[
                  { value: "yes", label: "Yes" },
                  { value: "no", label: "No" },
                ]}
              />
            </FormStepInputs>
            <FormStepRow>
              <BackButton onBack={onBack}>Back</BackButton>
              <NextButton>Submit</NextButton>
            </FormStepRow>
          </FormStepContent>
        </FormStep>
      ),
    },
  },
  {
    return: ({ name, surname, age, softwareDeveloper }) => ({
      name,
      surname,
      age,
      softwareDeveloper: softwareDeveloper === "yes",
    }),
  },
];

You've now seen how straightforward it is to build a multi-step form with Formity. To add conditional logic, check out this article. To dive deeper, the official documentation is a great next step.

Frequently Asked Questions