Getting startedTutorial

Getting started

Tutorial

Follow this tutorial to grasp the core concepts of Formity and how it has to be used.


Initial steps

In this tutorial, we'll show you how to turn a basic single-step form into a dynamic multi-step form with conditional logic. The starting point is already set up in the GitHub repository below, so go ahead and clone it to follow along.

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

Make sure you run the following command to install all the dependencies.

npm install

This tutorial explains how to use Formity with TypeScript, but if you want to learn how to use it with JavaScript you can still follow this tutorial since almost everything is the same. The only thing that is different is that in JavaScript you don't define the types.

Single-step form

If you take a look at the app.tsx file, you'll find a single-step form already in place. This form is built using React Hook Form. However, you're not restricted to this library. Formity is designed to work smoothly with any single-step form library you choose.

// 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<object | null>(null);

  const onSubmit = useCallback((output: object) => {
    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, the first thing we'll do is use the Formity component. It is the one that renders the multi-step form, and these are the most important props:

  • schema: Defines the structure and behavior of the multi-step form.
  • onReturn: A callback function that is triggered when the form is completed.

We'll replace the code that we have in app.tsx with the following code.

// 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

The next step is to create the schema, which defines the structure and behavior of the multi-step form. To do this, we'll create 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. There are different types of elements you can use within the schema, and in this example, we've included two form elements.

Additionally, to ensure complete type safety, the Schema accepts a Values type that defines the values handled at each step of the multi-step form.

We can now pass the schema 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 complete the multi-step form, you'll see that the onReturn callback is not called. That's because we 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",
    }),
  },
];

So far, we've covered the form and return elements. However, Formity supports additional elements that allow you to build any logic you need. One of these is the condition element, which can be used as you can see here.

// schema.tsx
import type { Schema, Form, Return, Cond } 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 }>,
  Cond<{
    then: [
      Form<{ expertise: string }>,
      Return<{
        name: string;
        surname: string;
        age: number;
        softwareDeveloper: true;
        expertise: string;
      }>,
    ];
    else: [
      Form<{ interested: string }>,
      Return<{
        name: string;
        surname: string;
        age: number;
        softwareDeveloper: false;
        interested: 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>Next</NextButton>
            </FormStepRow>
          </FormStepContent>
        </FormStep>
      ),
    },
  },
  {
    cond: {
      if: ({ softwareDeveloper }) => softwareDeveloper === "yes",
      then: [
        {
          form: {
            values: () => ({
              expertise: ["frontend", []],
            }),
            render: ({ values, onNext, onBack }) => (
              <FormStep
                key="expertise"
                defaultValues={values}
                resolver={zodResolver(
                  z.object({
                    expertise: z.string(),
                  }),
                )}
                onSubmit={onNext}
              >
                <FormStepContent>
                  <FormStepHeading>
                    What is your area of expertise?
                  </FormStepHeading>
                  <FormStepInputs>
                    <Select
                      name="expertise"
                      label="Expertise"
                      options={[
                        { value: "frontend", label: "Frontend development" },
                        { value: "backend", label: "Backend development" },
                        { value: "mobile", label: "Mobile development" },
                      ]}
                    />
                  </FormStepInputs>
                  <FormStepRow>
                    <BackButton onBack={onBack}>Back</BackButton>
                    <NextButton>Submit</NextButton>
                  </FormStepRow>
                </FormStepContent>
              </FormStep>
            ),
          },
        },
        {
          return: ({ name, surname, age, expertise }) => ({
            name,
            surname,
            age,
            softwareDeveloper: true,
            expertise,
          }),
        },
      ],
      else: [
        {
          form: {
            values: () => ({
              interested: ["yes", []],
            }),
            render: ({ values, onNext, onBack }) => (
              <FormStep
                key="interested"
                defaultValues={values}
                resolver={zodResolver(
                  z.object({
                    interested: z.string(),
                  }),
                )}
                onSubmit={onNext}
              >
                <FormStepContent>
                  <FormStepHeading>
                    Are you interested in learning how to code?
                  </FormStepHeading>
                  <FormStepInputs>
                    <Select
                      name="interested"
                      label="Interested"
                      options={[
                        { value: "yes", label: "Yes, I am interested." },
                        { value: "no", label: "No, it is not for me." },
                        { value: "maybe", label: "Maybe, I am not sure." },
                      ]}
                    />
                  </FormStepInputs>
                  <FormStepRow>
                    <BackButton onBack={onBack}>Back</BackButton>
                    <NextButton>Submit</NextButton>
                  </FormStepRow>
                </FormStepContent>
              </FormStep>
            ),
          },
        },
        {
          return: ({ name, surname, age, interested }) => ({
            name,
            surname,
            age,
            softwareDeveloper: false,
            interested,
          }),
        },
      ],
    },
  },
];

You've successfully created a multi-step form with conditional logic. Be sure to explore the other schema elements to see everything Formity can do.

Form submission

Until now, we have shown the values we want to submit. Now, we need to create the logic to submit these values and keep track of the submission state. To do this, we'll start by creating a types.ts file with the following code.

// types.ts
export type Status = FormityStatus | EndStatus;

export type FormityStatus = {
  type: "formity";
  submitting: boolean;
};

export type EndStatus = {
  type: "end";
};

Then, we'll update the app.tsx file to include the submission logic and display a thank you screen once the values have been successfully submitted.

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

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

import { End } from "./components/end";

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

import type { Status } from "./types";

export default function App() {
  const [status, setStatus] = useState<Status>({
    type: "formity",
    submitting: false,
  });

  const onReturn = useCallback<OnReturn<Values>>(async (output) => {
    setStatus({ type: "formity", submitting: true });

    // Show output in the console
    console.log(output);

    // Simulate a network request
    await new Promise((resolve) => setTimeout(resolve, 2000));

    setStatus({ type: "end" });
  }, []);

  if (status.type === "end") {
    return (
      <End onStart={() => setStatus({ type: "formity", submitting: false })} />
    );
  }

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

As you may have noticed, the form stays interactive during submission, with no feedback shown. To address this, we'll access the status object within the schema to disable the form and provide visual feedback to the user.

// schema.tsx
import type { Schema, Form, Return, Cond } 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";

import type { FormityStatus } from "./types";

export type Values = [
  Form<{ name: string; surname: string; age: number }>,
  Form<{ softwareDeveloper: string }>,
  Cond<{
    then: [
      Form<{ expertise: string }>,
      Return<{
        name: string;
        surname: string;
        age: number;
        softwareDeveloper: true;
        expertise: string;
      }>,
    ];
    else: [
      Form<{ interested: string }>,
      Return<{
        name: string;
        surname: string;
        age: number;
        softwareDeveloper: false;
        interested: string;
      }>,
    ];
  }>,
];

export type Params = {
  status: FormityStatus;
};

export const schema: Schema<Values, object, Params> = [
  {
    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>Next</NextButton>
            </FormStepRow>
          </FormStepContent>
        </FormStep>
      ),
    },
  },
  {
    cond: {
      if: ({ softwareDeveloper }) => softwareDeveloper === "yes",
      then: [
        {
          form: {
            values: () => ({
              expertise: ["frontend", []],
            }),
            render: ({ values, params, onNext, onBack }) => (
              <FormStep
                key="expertise"
                defaultValues={values}
                resolver={zodResolver(
                  z.object({
                    expertise: z.string(),
                  }),
                )}
                disabled={params.status.submitting}
                onSubmit={onNext}
              >
                <FormStepContent>
                  <FormStepHeading>
                    What is your area of expertise?
                  </FormStepHeading>
                  <FormStepInputs>
                    <Select
                      name="expertise"
                      label="Expertise"
                      options={[
                        { value: "frontend", label: "Frontend development" },
                        { value: "backend", label: "Backend development" },
                        { value: "mobile", label: "Mobile development" },
                      ]}
                    />
                  </FormStepInputs>
                  <FormStepRow>
                    <BackButton onBack={onBack}>Back</BackButton>
                    <NextButton submitting={params.status.submitting}>
                      Submit
                    </NextButton>
                  </FormStepRow>
                </FormStepContent>
              </FormStep>
            ),
          },
        },
        {
          return: ({ name, surname, age, expertise }) => ({
            name,
            surname,
            age,
            softwareDeveloper: true,
            expertise,
          }),
        },
      ],
      else: [
        {
          form: {
            values: () => ({
              interested: ["yes", []],
            }),
            render: ({ values, params, onNext, onBack }) => (
              <FormStep
                key="interested"
                defaultValues={values}
                resolver={zodResolver(
                  z.object({
                    interested: z.string(),
                  }),
                )}
                disabled={params.status.submitting}
                onSubmit={onNext}
              >
                <FormStepContent>
                  <FormStepHeading>
                    Are you interested in learning how to code?
                  </FormStepHeading>
                  <FormStepInputs>
                    <Select
                      name="interested"
                      label="Interested"
                      options={[
                        { value: "yes", label: "Yes, I am interested." },
                        { value: "no", label: "No, it is not for me." },
                        { value: "maybe", label: "Maybe, I am not sure." },
                      ]}
                    />
                  </FormStepInputs>
                  <FormStepRow>
                    <BackButton onBack={onBack}>Back</BackButton>
                    <NextButton submitting={params.status.submitting}>
                      Submit
                    </NextButton>
                  </FormStepRow>
                </FormStepContent>
              </FormStep>
            ),
          },
        },
        {
          return: ({ name, surname, age, interested }) => ({
            name,
            surname,
            age,
            softwareDeveloper: false,
            interested,
          }),
        },
      ],
    },
  },
];

The Params type defines the values that are provided to the schema. In this case, we're providing the status object, which is used in the last two forms to disable interaction and indicate that a submission is in progress.

Lastly, we need to update the app.tsx file to pass the status object to the schema. We'll do this by providing it through the params prop of the Formity component.

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

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

import { End } from "./components/end";

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

import type { Status } from "./types";

export default function App() {
  const [status, setStatus] = useState<Status>({
    type: "formity",
    submitting: false,
  });

  const onReturn = useCallback<OnReturn<Values>>(async (output) => {
    setStatus({ type: "formity", submitting: true });

    // Show output in the console
    console.log(output);

    // Simulate a network request
    await new Promise((resolve) => setTimeout(resolve, 2000));

    setStatus({ type: "end" });
  }, []);

  if (status.type === "end") {
    return (
      <End onStart={() => setStatus({ type: "formity", submitting: false })} />
    );
  }

  return (
    <Formity<Values, object, Params>
      schema={schema}
      params={{ status }}
      onReturn={onReturn}
    />
  );
}

Context

One last tip before wrapping up — using the Context API instead of passing the navigation functions and the status directly to the components can lead to cleaner code.

To do this, we recommend creating a multi-step folder with the following files.

multi-step/multi-step-value.ts:

// multi-step/multi-step-value.ts
import type { OnNext, OnBack } from "@formity/react";
import type { FormityStatus } from "@/types";

export interface MultiStepValue {
  onNext: OnNext;
  onBack: OnBack;
  status: FormityStatus;
}

multi-step/multi-step-context.ts:

// multi-step/multi-step-context.ts
import { createContext } from "react";

import type { MultiStepValue } from "./multi-step-value";

export const MultiStepContext = createContext<MultiStepValue | null>(null);

multi-step/multi-step.tsx:

// multi-step/multi-step.tsx
import type { ReactNode } from "react";
import type { OnNext, OnBack } from "@formity/react";

import { useMemo } from "react";

import type { FormityStatus } from "@/types";
import { MultiStepContext } from "./multi-step-context";

interface MultiStepProps {
  onNext: OnNext;
  onBack: OnBack;
  status: FormityStatus;
  children: ReactNode;
}

export function MultiStep({
  onNext,
  onBack,
  status,
  children,
}: MultiStepProps) {
  const values = useMemo(
    () => ({ onNext, onBack, status }),
    [onNext, onBack, status],
  );
  return (
    <MultiStepContext.Provider value={values}>
      {children}
    </MultiStepContext.Provider>
  );
}

multi-step/use-multi-step.ts:

// multi-step/use-multi-step.ts
import { useContext } from "react";

import type { MultiStepValue } from "./multi-step-value";
import { MultiStepContext } from "./multi-step-context";

export function useMultiStep(): MultiStepValue {
  const context = useContext(MultiStepContext);
  if (!context) {
    throw new Error("useMultiStep must be used within a MultiStep");
  }
  return context;
}

multi-step/index.ts:

// multi-step/index.ts
export type { MultiStepValue } from "./multi-step-value";
export { MultiStep } from "./multi-step";
export { useMultiStep } from "./use-multi-step";

Then, you need to update the following components.

components/form-step.tsx:

// components/form-step.tsx
import type { ReactNode } from "react";
import type { DefaultValues, Resolver } from "react-hook-form";

import { FormProvider, useForm } from "react-hook-form";
import { useMultiStep } from "@/multi-step";

interface FormStepProps<T extends Record<string, unknown>> {
  defaultValues: DefaultValues<T>;
  resolver: Resolver<T>;
  children: ReactNode;
}

export function FormStep<T extends Record<string, unknown>>({
  defaultValues,
  resolver,
  children,
}: FormStepProps<T>) {
  const form = useForm({ defaultValues, resolver });
  const { onNext, status } = useMultiStep();
  return (
    <form
      onSubmit={form.handleSubmit(onNext)}
      className="flex h-screen w-full items-center justify-center px-4 py-8 font-sans"
      inert={status.submitting}
    >
      <FormProvider {...form}>{children}</FormProvider>
    </form>
  );
}

interface FormStepContentProps {
  children: ReactNode;
}

export function FormStepContent({ children }: FormStepContentProps) {
  return <div className="w-full max-w-md">{children}</div>;
}

interface FormStepHeadingProps {
  children: ReactNode;
}

export function FormStepHeading({ children }: FormStepHeadingProps) {
  return (
    <h2 className="mb-6 text-center text-4xl font-semibold text-white">
      {children}
    </h2>
  );
}

interface FormStepInputsProps {
  children: ReactNode;
}

export function FormStepInputs({ children }: FormStepInputsProps) {
  return <div className="mb-6 flex flex-col gap-4">{children}</div>;
}

interface FormStepRowProps {
  children: ReactNode;
}

export function FormStepRow({ children }: FormStepRowProps) {
  return <div className="flex gap-4">{children}</div>;
}

components/buttons/next-button.tsx:

// components/buttons/next-button.tsx
import type { ComponentPropsWithoutRef } from "react";

import { Button } from "../button";
import { useMultiStep } from "@/multi-step";

export function NextButton({
  children,
  ...props
}: ComponentPropsWithoutRef<"button">) {
  const { status } = useMultiStep();
  return (
    <Button
      type="submit"
      variant="primary"
      disabled={status.submitting}
      {...props}
    >
      {status.submitting ? "Submitting..." : children}
    </Button>
  );
}

components/buttons/back-button.tsx:

// components/buttons/back-button.tsx
import type { ComponentPropsWithoutRef } from "react";

import { useFormContext } from "react-hook-form";

import { Button } from "../button";
import { useMultiStep } from "@/multi-step";

export function BackButton(props: ComponentPropsWithoutRef<"button">) {
  const { getValues } = useFormContext();
  const { onBack } = useMultiStep();
  return (
    <Button
      type="button"
      variant="secondary"
      onClick={() => onBack(getValues())}
      {...props}
    />
  );
}

Lastly, you need to update the schema to use the MultiStep component.

// schema.tsx
import type { Schema, Form, Return, Cond } 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";

import type { FormityStatus } from "./types";

import { MultiStep } from "./multi-step";

export type Values = [
  Form<{ name: string; surname: string; age: number }>,
  Form<{ softwareDeveloper: string }>,
  Cond<{
    then: [
      Form<{ expertise: string }>,
      Return<{
        name: string;
        surname: string;
        age: number;
        softwareDeveloper: true;
        expertise: string;
      }>,
    ];
    else: [
      Form<{ interested: string }>,
      Return<{
        name: string;
        surname: string;
        age: number;
        softwareDeveloper: false;
        interested: string;
      }>,
    ];
  }>,
];

export type Params = {
  status: FormityStatus;
};

export const schema: Schema<Values, object, Params> = [
  {
    form: {
      values: () => ({
        name: ["", []],
        surname: ["", []],
        age: [20, []],
      }),
      render: ({ values, params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack} status={params.status}>
          <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" }),
              }),
            )}
          >
            <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>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({
        softwareDeveloper: ["yes", []],
      }),
      render: ({ values, params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack} status={params.status}>
          <FormStep
            key="softwareDeveloper"
            defaultValues={values}
            resolver={zodResolver(
              z.object({
                softwareDeveloper: z.string(),
              }),
            )}
          >
            <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>Back</BackButton>
                <NextButton>Next</NextButton>
              </FormStepRow>
            </FormStepContent>
          </FormStep>
        </MultiStep>
      ),
    },
  },
  {
    cond: {
      if: ({ softwareDeveloper }) => softwareDeveloper === "yes",
      then: [
        {
          form: {
            values: () => ({
              expertise: ["frontend", []],
            }),
            render: ({ values, params, onNext, onBack }) => (
              <MultiStep onNext={onNext} onBack={onBack} status={params.status}>
                <FormStep
                  key="expertise"
                  defaultValues={values}
                  resolver={zodResolver(
                    z.object({
                      expertise: z.string(),
                    }),
                  )}
                >
                  <FormStepContent>
                    <FormStepHeading>
                      What is your area of expertise?
                    </FormStepHeading>
                    <FormStepInputs>
                      <Select
                        name="expertise"
                        label="Expertise"
                        options={[
                          { value: "frontend", label: "Frontend development" },
                          { value: "backend", label: "Backend development" },
                          { value: "mobile", label: "Mobile development" },
                        ]}
                      />
                    </FormStepInputs>
                    <FormStepRow>
                      <BackButton>Back</BackButton>
                      <NextButton>Submit</NextButton>
                    </FormStepRow>
                  </FormStepContent>
                </FormStep>
              </MultiStep>
            ),
          },
        },
        {
          return: ({ name, surname, age, expertise }) => ({
            name,
            surname,
            age,
            softwareDeveloper: true,
            expertise,
          }),
        },
      ],
      else: [
        {
          form: {
            values: () => ({
              interested: ["yes", []],
            }),
            render: ({ values, params, onNext, onBack }) => (
              <MultiStep onNext={onNext} onBack={onBack} status={params.status}>
                <FormStep
                  key="interested"
                  defaultValues={values}
                  resolver={zodResolver(
                    z.object({
                      interested: z.string(),
                    }),
                  )}
                >
                  <FormStepContent>
                    <FormStepHeading>
                      Are you interested in learning how to code?
                    </FormStepHeading>
                    <FormStepInputs>
                      <Select
                        name="interested"
                        label="Interested"
                        options={[
                          { value: "yes", label: "Yes, I am interested." },
                          { value: "no", label: "No, it is not for me." },
                          { value: "maybe", label: "Maybe, I am not sure." },
                        ]}
                      />
                    </FormStepInputs>
                    <FormStepRow>
                      <BackButton>Back</BackButton>
                      <NextButton>Submit</NextButton>
                    </FormStepRow>
                  </FormStepContent>
                </FormStep>
              </MultiStep>
            ),
          },
        },
        {
          return: ({ name, surname, age, interested }) => ({
            name,
            surname,
            age,
            softwareDeveloper: false,
            interested,
          }),
        },
      ],
    },
  },
];

Next steps

You've successfully completed the tutorial. To dive deeper into Formity's capabilities, you can continue with the following sections.

Important: The upcoming sections use a different setup. Do not combine them with the tutorial code. Instead, clone the following Github repository.

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

This codebase is similar to the one in this tutorial, but without the submission logic.