Advanced concepts

Jump to steps

Learn how to jump to specific steps by updating the state.


Initial steps

We can go to specific steps by updating the state of the multi-step form. To learn how to do it, clone the following GitHub repository so that you don't need to start from scratch.

git clone https://github.com/martiserra99/formity-react-advanced-concepts

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

npm install

Multi-step form

To create the functionality to jump to specific steps we need to create the multi-step in a different way. Because of that, we'll update schema.tsx with the following code.

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

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

import {
  Step,
  Layout,
  Row,
  TextField,
  NumberField,
  Listbox,
  NextButton,
  BackButton,
} from "./components";

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

export type Values = [Form<object>, Form<object>, Form<object>, Form<object>];

export const schema: Schema<Values> = [
  {
    form: {
      values: () => ({}),
      render: ({ onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="nameSurname"
            defaultValues={{ name: "", surname: "" }}
            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" }),
              }),
            )}
          >
            <Layout
              heading="Tell us what is your name"
              description="We would want to know what is your name"
              fields={[
                <Row
                  key="nameSurname"
                  items={[
                    <TextField key="name" name="name" label="Name" />,
                    <TextField key="surname" name="surname" label="Surname" />,
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="age"
            defaultValues={{ age: 0 }}
            resolver={zodResolver(
              z.object({
                age: z
                  .number()
                  .min(18, { message: "Minimum of 18 years old" })
                  .max(99, { message: "Maximum of 99 years old" }),
              }),
            )}
          >
            <Layout
              heading="Tell us what is your age"
              description="We would want to know what is your age"
              fields={[<NumberField key="age" name="age" label="Age" />]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="gender"
            defaultValues={{ gender: "" }}
            resolver={zodResolver(
              z.object({
                gender: z.string().nonempty("Required"),
              }),
            )}
          >
            <Layout
              heading="Tell us what is your gender"
              description="We would want to know what is your gender"
              fields={[
                <Listbox
                  key="gender"
                  name="gender"
                  label="Gender"
                  options={[
                    { value: "", label: "Select a gender" },
                    { value: "man", label: "Man" },
                    { value: "woman", label: "Woman" },
                    { value: "other", label: "Other" },
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="country"
            defaultValues={{ country: "" }}
            resolver={zodResolver(
              z.object({
                country: z
                  .string()
                  .min(1, { message: "Required" })
                  .max(20, { message: "Must be at most 20 characters" }),
              }),
            )}
          >
            <Layout
              heading="Tell us what is your country"
              description="We would want to know what is your country"
              fields={[
                <TextField key="country" name="country" label="Country" />,
              ]}
              button={<NextButton>Finish</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
];

As you may have noticed, the form values are not provided using the values function. That's because we'll handle the form values in a different way.

Form fields

We'll create a fields object with all the values of all the forms of the multi-step form. This object will be passed using the params prop of the Formity component.

To do it, we first need to update the schema.tsx file as shown below.

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

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

import {
  Step,
  Layout,
  Row,
  TextField,
  NumberField,
  Listbox,
  NextButton,
  BackButton,
} from "./components";

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

export type Values = [Form<object>, Form<object>, Form<object>, Form<object>];

export type Params = {
  fields: Fields;
};

export type Fields = {
  name: string;
  surname: string;
  age: number;
  gender: string;
  country: string;
};

export const schema: Schema<Values, object, Params> = [
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="nameSurname"
            defaultValues={{
              name: params.fields.name,
              surname: params.fields.surname,
            }}
            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" }),
              }),
            )}
          >
            <Layout
              heading="Tell us what is your name"
              description="We would want to know what is your name"
              fields={[
                <Row
                  key="nameSurname"
                  items={[
                    <TextField key="name" name="name" label="Name" />,
                    <TextField key="surname" name="surname" label="Surname" />,
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="age"
            defaultValues={{ age: params.fields.age }}
            resolver={zodResolver(
              z.object({
                age: z
                  .number()
                  .min(18, { message: "Minimum of 18 years old" })
                  .max(99, { message: "Maximum of 99 years old" }),
              }),
            )}
          >
            <Layout
              heading="Tell us what is your age"
              description="We would want to know what is your age"
              fields={[<NumberField key="age" name="age" label="Age" />]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="gender"
            defaultValues={{ gender: params.fields.gender }}
            resolver={zodResolver(
              z.object({
                gender: z.string().nonempty("Required"),
              }),
            )}
          >
            <Layout
              heading="Tell us what is your gender"
              description="We would want to know what is your gender"
              fields={[
                <Listbox
                  key="gender"
                  name="gender"
                  label="Gender"
                  options={[
                    { value: "", label: "Select a gender" },
                    { value: "man", label: "Man" },
                    { value: "woman", label: "Woman" },
                    { value: "other", label: "Other" },
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="country"
            defaultValues={{ country: params.fields.country }}
            resolver={zodResolver(
              z.object({
                country: z
                  .string()
                  .min(1, { message: "Required" })
                  .max(20, { message: "Must be at most 20 characters" }),
              }),
            )}
          >
            <Layout
              heading="Tell us what is your country"
              description="We would want to know what is your country"
              fields={[
                <TextField key="country" name="country" label="Country" />,
              ]}
              button={<NextButton>Finish</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
];

Then, we can update the App.tsx file as you can see here.

// App.tsx
import { useState } from "react";

import { Formity } from "@formity/react";

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

const initialFields: Fields = {
  name: "",
  surname: "",
  age: 0,
  gender: "",
  country: "",
};

export default function App() {
  const [fields] = useState(initialFields);
  return (
    <Formity<Values, object, Params> schema={schema} params={{ fields }} />
  );
}

Field changes

We need to update the fields object every time form values are changed. To do it, we need to include an onChange prop to the Step component as shown below.

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

import { useEffect } from "react";
import { FormProvider, useForm } from "react-hook-form";

import { useMultiStep } from "@/multi-step";
import { Fields } from "@/schema";

interface StepProps {
  defaultValues: UseFormProps["defaultValues"];
  resolver: UseFormProps["resolver"];
  children: ReactNode;
  onChange: (fields: Partial<Fields>) => void;
}

export default function Step({
  defaultValues,
  resolver,
  children,
  onChange,
}: StepProps) {
  const form = useForm({ defaultValues, resolver });
  const { onNext } = useMultiStep();

  useEffect(() => {
    const { unsubscribe } = form.watch((values) => onChange(values));
    return () => unsubscribe();
  }, [form, onChange]);

  return (
    <form onSubmit={form.handleSubmit(onNext)} className="relative h-full">
      <FormProvider {...form}>{children}</FormProvider>
    </form>
  );
}

The function will be passed using the params prop of the Formity component. For this reason, we need to update the schema.tsx file as shown below.

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

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

import {
  Step,
  Layout,
  Row,
  TextField,
  NumberField,
  Listbox,
  NextButton,
  BackButton,
} from "./components";

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

export type Values = [Form<object>, Form<object>, Form<object>, Form<object>];

export type Params = {
  fields: Fields;
  onChange: (fields: Partial<Fields>) => void;
};

export type Fields = {
  name: string;
  surname: string;
  age: number;
  gender: string;
  country: string;
};

export const schema: Schema<Values, object, Params> = [
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="nameSurname"
            defaultValues={{
              name: params.fields.name,
              surname: params.fields.surname,
            }}
            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" }),
              }),
            )}
            onChange={params.onChange}
          >
            <Layout
              heading="Tell us what is your name"
              description="We would want to know what is your name"
              fields={[
                <Row
                  key="nameSurname"
                  items={[
                    <TextField key="name" name="name" label="Name" />,
                    <TextField key="surname" name="surname" label="Surname" />,
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="age"
            defaultValues={{ age: params.fields.age }}
            resolver={zodResolver(
              z.object({
                age: z
                  .number()
                  .min(18, { message: "Minimum of 18 years old" })
                  .max(99, { message: "Maximum of 99 years old" }),
              }),
            )}
            onChange={params.onChange}
          >
            <Layout
              heading="Tell us what is your age"
              description="We would want to know what is your age"
              fields={[<NumberField key="age" name="age" label="Age" />]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="gender"
            defaultValues={{ gender: params.fields.gender }}
            resolver={zodResolver(
              z.object({
                gender: z.string().nonempty("Required"),
              }),
            )}
            onChange={params.onChange}
          >
            <Layout
              heading="Tell us what is your gender"
              description="We would want to know what is your gender"
              fields={[
                <Listbox
                  key="gender"
                  name="gender"
                  label="Gender"
                  options={[
                    { value: "", label: "Select a gender" },
                    { value: "man", label: "Man" },
                    { value: "woman", label: "Woman" },
                    { value: "other", label: "Other" },
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="country"
            defaultValues={{ country: params.fields.country }}
            resolver={zodResolver(
              z.object({
                country: z
                  .string()
                  .min(1, { message: "Required" })
                  .max(20, { message: "Must be at most 20 characters" }),
              }),
            )}
            onChange={params.onChange}
          >
            <Layout
              heading="Tell us what is your country"
              description="We would want to know what is your country"
              fields={[
                <TextField key="country" name="country" label="Country" />,
              ]}
              button={<NextButton>Finish</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
];

Then, we can update the App.tsx file as you can see here.

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

import { Formity } from "@formity/react";

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

const initialFields: Fields = {
  name: "",
  surname: "",
  age: 0,
  gender: "",
  country: "",
};

export default function App() {
  const [fields, setFields] = useState(initialFields);

  const onChange = useCallback(
    (values: Partial<Fields>) => {
      setFields({ ...fields, ...values });
    },
    [fields],
  );

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

Submit form

We need to pass an onSubmit function using the params prop of Formity. It will be called on the last step, and to do it we need to update schema.tsxas shown below.

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

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

import {
  Step,
  Layout,
  Row,
  TextField,
  NumberField,
  Listbox,
  NextButton,
  BackButton,
} from "./components";

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

export type Values = [Form<object>, Form<object>, Form<object>, Form<object>];

export type Params = {
  fields: Fields;
  onChange: (fields: Partial<Fields>) => void;
  onSubmit: () => void;
};

export type Fields = {
  name: string;
  surname: string;
  age: number;
  gender: string;
  country: string;
};

export const schema: Schema<Values, object, Params> = [
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="nameSurname"
            defaultValues={{
              name: params.fields.name,
              surname: params.fields.surname,
            }}
            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" }),
              }),
            )}
            onChange={params.onChange}
          >
            <Layout
              heading="Tell us what is your name"
              description="We would want to know what is your name"
              fields={[
                <Row
                  key="nameSurname"
                  items={[
                    <TextField key="name" name="name" label="Name" />,
                    <TextField key="surname" name="surname" label="Surname" />,
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="age"
            defaultValues={{ age: params.fields.age }}
            resolver={zodResolver(
              z.object({
                age: z
                  .number()
                  .min(18, { message: "Minimum of 18 years old" })
                  .max(99, { message: "Maximum of 99 years old" }),
              }),
            )}
            onChange={params.onChange}
          >
            <Layout
              heading="Tell us what is your age"
              description="We would want to know what is your age"
              fields={[<NumberField key="age" name="age" label="Age" />]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="gender"
            defaultValues={{ gender: params.fields.gender }}
            resolver={zodResolver(
              z.object({
                gender: z.string().nonempty("Required"),
              }),
            )}
            onChange={params.onChange}
          >
            <Layout
              heading="Tell us what is your gender"
              description="We would want to know what is your gender"
              fields={[
                <Listbox
                  key="gender"
                  name="gender"
                  label="Gender"
                  options={[
                    { value: "", label: "Select a gender" },
                    { value: "man", label: "Man" },
                    { value: "woman", label: "Woman" },
                    { value: "other", label: "Other" },
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onBack }) => (
        <MultiStep onNext={params.onSubmit} onBack={onBack}>
          <Step
            key="country"
            defaultValues={{ country: params.fields.country }}
            resolver={zodResolver(
              z.object({
                country: z
                  .string()
                  .min(1, { message: "Required" })
                  .max(20, { message: "Must be at most 20 characters" }),
              }),
            )}
            onChange={params.onChange}
          >
            <Layout
              heading="Tell us what is your country"
              description="We would want to know what is your country"
              fields={[
                <TextField key="country" name="country" label="Country" />,
              ]}
              button={<NextButton>Finish</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
];

Then, we can update the App.tsx file as you can see here.

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

import { Formity } from "@formity/react";

import { Data } from "./components";

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

const initialFields: Fields = {
  name: "",
  surname: "",
  age: 0,
  gender: "",
  country: "",
};

export default function App() {
  const [fields, setFields] = useState(initialFields);
  const [submit, setSubmit] = useState(false);

  const onChange = useCallback(
    (values: Partial<Fields>) => {
      setFields({ ...fields, ...values });
    },
    [fields],
  );

  const onSubmit = useCallback(() => {
    setSubmit(true);
  }, []);

  if (submit) {
    return (
      <Data
        data={fields}
        onStart={() => {
          setSubmit(false);
          setFields(initialFields);
        }}
      />
    );
  }

  return (
    <Formity<Values, object, Params>
      schema={schema}
      params={{ fields, onChange, onSubmit }}
    />
  );
}

Steps component

We'll create a Steps component used to navigate to specific steps, and we will start by creating a components/steps.tsx file with the following code.

// components/steps.tsx
import { cn } from "@/utils";

interface StepsProps {
  steps: {
    label: string;
  }[];
  selected: number;
}

export default function Steps({ steps, selected }: StepsProps) {
  return (
    <div className="pointer-events-none absolute inset-x-4 top-5 z-50 flex items-center justify-end gap-3">
      {steps.map((step, index) => (
        <Step key={index} label={step.label} selected={index === selected} />
      ))}
    </div>
  );
}

interface StepProps {
  label: string;
  selected: boolean;
}

function Step({ label, selected }: StepProps) {
  return (
    <button
      type="button"
      className={cn(
        "pointer-events-auto block rounded-full border border-neutral-800 bg-neutral-950 px-4 py-2 text-sm text-white ring-offset-2 ring-offset-black",
        "hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/10",
        { "bg-neutral-800 ring-2 ring-neutral-800": selected },
      )}
    >
      {label}
    </button>
  );
}

We'll also need to export the Steps component from components/index.tsx.

// components/index.ts
export { default as Steps } from "@/components/steps";
// ...

Then, we'll update schema.tsx to include the Steps component.

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

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

import {
  Step,
  Steps,
  Layout,
  Row,
  TextField,
  NumberField,
  Listbox,
  NextButton,
  BackButton,
} from "./components";

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

export type Values = [Form<object>, Form<object>, Form<object>, Form<object>];

export type Params = {
  fields: Fields;
  onChange: (fields: Partial<Fields>) => void;
  onSubmit: () => void;
};

export type Fields = {
  name: string;
  surname: string;
  age: number;
  gender: string;
  country: string;
};

const steps: { label: string }[] = [
  { label: "1" },
  { label: "2" },
  { label: "3" },
  { label: "4" },
];

export const schema: Schema<Values, object, Params> = [
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="nameSurname"
            defaultValues={{
              name: params.fields.name,
              surname: params.fields.surname,
            }}
            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" }),
              }),
            )}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={0} />
            <Layout
              heading="Tell us what is your name"
              description="We would want to know what is your name"
              fields={[
                <Row
                  key="nameSurname"
                  items={[
                    <TextField key="name" name="name" label="Name" />,
                    <TextField key="surname" name="surname" label="Surname" />,
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="age"
            defaultValues={{ age: params.fields.age }}
            resolver={zodResolver(
              z.object({
                age: z
                  .number()
                  .min(18, { message: "Minimum of 18 years old" })
                  .max(99, { message: "Maximum of 99 years old" }),
              }),
            )}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={1} />
            <Layout
              heading="Tell us what is your age"
              description="We would want to know what is your age"
              fields={[<NumberField key="age" name="age" label="Age" />]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="gender"
            defaultValues={{ gender: params.fields.gender }}
            resolver={zodResolver(
              z.object({
                gender: z.string().nonempty("Required"),
              }),
            )}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={2} />
            <Layout
              heading="Tell us what is your gender"
              description="We would want to know what is your gender"
              fields={[
                <Listbox
                  key="gender"
                  name="gender"
                  label="Gender"
                  options={[
                    { value: "", label: "Select a gender" },
                    { value: "man", label: "Man" },
                    { value: "woman", label: "Woman" },
                    { value: "other", label: "Other" },
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onBack }) => (
        <MultiStep onNext={params.onSubmit} onBack={onBack}>
          <Step
            key="country"
            defaultValues={{ country: params.fields.country }}
            resolver={zodResolver(
              z.object({
                country: z
                  .string()
                  .min(1, { message: "Required" })
                  .max(20, { message: "Must be at most 20 characters" }),
              }),
            )}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={3} />
            <Layout
              heading="Tell us what is your country"
              description="We would want to know what is your country"
              fields={[
                <TextField key="country" name="country" label="Country" />,
              ]}
              button={<NextButton>Finish</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
];

Completed steps

The Steps component should indicate what are the steps that have been completed. To do it, we'll use validation rules to check what are the completed steps.

We'll update Steps to include a check property in the object of the steps array.

// components/steps.tsx
import type { ZodType } from "zod";

import { cn } from "@/utils";
import { Fields } from "@/schema";

interface StepsProps {
  steps: {
    label: string;
    check: ZodType;
  }[];
  selected: number;
  fields: Fields;
}

export default function Steps({ steps, selected, fields }: StepsProps) {
  return (
    <div className="pointer-events-none absolute inset-x-4 top-5 z-50 flex items-center justify-end gap-3">
      {steps.map((step, index) => (
        <Step
          key={index}
          label={step.label}
          check={step.check}
          selected={index === selected}
          fields={fields}
        />
      ))}
    </div>
  );
}

interface StepProps {
  label: string;
  check: ZodType;
  selected: boolean;
  fields: Fields;
}

function Step({ label, check, selected, fields }: StepProps) {
  return (
    <button
      type="button"
      className={cn(
        "pointer-events-auto block rounded-full border border-neutral-800 bg-neutral-950 px-4 py-2 text-sm text-white ring-offset-2 ring-offset-black",
        "hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/10",
        { "bg-neutral-800 ring-2 ring-neutral-800": selected },
        { "bg-neutral-800": check.safeParse(fields).success },
      )}
    >
      {label}
    </button>
  );
}

Then, we'll update schema.tsx to include the validation rules in the steps array.

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

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

import {
  Step,
  Steps,
  Layout,
  Row,
  TextField,
  NumberField,
  Listbox,
  NextButton,
  BackButton,
} from "./components";

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

export type Values = [Form<object>, Form<object>, Form<object>, Form<object>];

export type Params = {
  fields: Fields;
  onChange: (fields: Partial<Fields>) => void;
  onSubmit: () => void;
};

export type Fields = {
  name: string;
  surname: string;
  age: number;
  gender: string;
  country: string;
};

const steps: { label: string; check: ZodType }[] = [
  {
    label: "1",
    check: 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" }),
    }),
  },
  {
    label: "2",
    check: z.object({
      age: z
        .number()
        .min(18, { message: "Minimum of 18 years old" })
        .max(99, { message: "Maximum of 99 years old" }),
    }),
  },
  {
    label: "3",
    check: z.object({
      gender: z.string().nonempty("Required"),
    }),
  },
  {
    label: "4",
    check: z.object({
      country: z
        .string()
        .min(1, { message: "Required" })
        .max(20, { message: "Must be at most 20 characters" }),
    }),
  },
];

export const schema: Schema<Values, object, Params> = [
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="nameSurname"
            defaultValues={{
              name: params.fields.name,
              surname: params.fields.surname,
            }}
            resolver={zodResolver(steps[0].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={0} fields={params.fields} />
            <Layout
              heading="Tell us what is your name"
              description="We would want to know what is your name"
              fields={[
                <Row
                  key="nameSurname"
                  items={[
                    <TextField key="name" name="name" label="Name" />,
                    <TextField key="surname" name="surname" label="Surname" />,
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="age"
            defaultValues={{ age: params.fields.age }}
            resolver={zodResolver(steps[1].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={1} fields={params.fields} />
            <Layout
              heading="Tell us what is your age"
              description="We would want to know what is your age"
              fields={[<NumberField key="age" name="age" label="Age" />]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack }) => (
        <MultiStep onNext={onNext} onBack={onBack}>
          <Step
            key="gender"
            defaultValues={{ gender: params.fields.gender }}
            resolver={zodResolver(steps[2].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={2} fields={params.fields} />
            <Layout
              heading="Tell us what is your gender"
              description="We would want to know what is your gender"
              fields={[
                <Listbox
                  key="gender"
                  name="gender"
                  label="Gender"
                  options={[
                    { value: "", label: "Select a gender" },
                    { value: "man", label: "Man" },
                    { value: "woman", label: "Woman" },
                    { value: "other", label: "Other" },
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onBack }) => (
        <MultiStep onNext={params.onSubmit} onBack={onBack}>
          <Step
            key="country"
            defaultValues={{ country: params.fields.country }}
            resolver={zodResolver(steps[3].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={3} fields={params.fields} />
            <Layout
              heading="Tell us what is your country"
              description="We would want to know what is your country"
              fields={[
                <TextField key="country" name="country" label="Country" />,
              ]}
              button={<NextButton>Finish</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
];

Jump to steps

To jump to steps, we need to use the setState function. We'll access this function using the Context API, so we'll need to update the files in the multi-step folder.

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

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

export interface MultiStepValue {
  onNext: OnNext;
  onBack: OnBack;
  setState: SetState;
}

multi-step/multi-step.tsx:

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

import { useMemo } from "react";

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

interface MultiStepProps {
  onNext: OnNext;
  onBack: OnBack;
  setState: SetState;
  children: ReactNode;
}

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

Then, we'll update Steps to include a state property in the object of the steps array. When we click a step, the setState will be called with the corresponding state.

// components/steps.tsx
import type { ZodType } from "zod";
import type { State } from "@formity/react";

import { cn } from "@/utils";
import { Fields } from "@/schema";
import { useMultiStep } from "@/multi-step";

interface StepsProps {
  steps: {
    label: string;
    check: ZodType;
    state: State;
  }[];
  selected: number;
  fields: Fields;
}

export default function Steps({ steps, selected, fields }: StepsProps) {
  return (
    <div className="pointer-events-none absolute inset-x-4 top-5 z-50 flex items-center justify-end gap-3">
      {steps.map((step, index) => (
        <Step
          key={index}
          label={step.label}
          check={step.check}
          state={step.state}
          selected={index === selected}
          fields={fields}
        />
      ))}
    </div>
  );
}

interface StepProps {
  label: string;
  check: ZodType;
  state: State;
  selected: boolean;
  fields: Fields;
}

function Step({ label, check, state, selected, fields }: StepProps) {
  const { setState } = useMultiStep();
  return (
    <button
      type="button"
      onClick={() => setState(state)}
      className={cn(
        "pointer-events-auto block rounded-full border border-neutral-800 bg-neutral-950 px-4 py-2 text-sm text-white ring-offset-2 ring-offset-black",
        "hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/10",
        { "bg-neutral-800 ring-2 ring-neutral-800": selected },
        { "bg-neutral-800": check.safeParse(fields).success },
      )}
    >
      {label}
    </button>
  );
}

After that, we'll update schema.tsx to include the states in the steps array and to pass the setState function to the MultiStep component.

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

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

import {
  Step,
  Steps,
  Layout,
  Row,
  TextField,
  NumberField,
  Listbox,
  NextButton,
  BackButton,
} from "./components";

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

export type Values = [Form<object>, Form<object>, Form<object>, Form<object>];

export type Params = {
  fields: Fields;
  onChange: (fields: Partial<Fields>) => void;
  onSubmit: () => void;
};

export type Fields = {
  name: string;
  surname: string;
  age: number;
  gender: string;
  country: string;
};

const steps: { label: string; check: ZodType; state: State }[] = [
  {
    label: "1",
    check: 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" }),
    }),
    state: {
      points: [{ path: [{ type: "list", slot: 0 }], values: {} }],
      inputs: { type: "list", list: [] },
    },
  },
  {
    label: "2",
    check: z.object({
      age: z
        .number()
        .min(18, { message: "Minimum of 18 years old" })
        .max(99, { message: "Maximum of 99 years old" }),
    }),
    state: {
      points: [
        { path: [{ type: "list", slot: 0 }], values: {} },
        { path: [{ type: "list", slot: 1 }], values: {} },
      ],
      inputs: { type: "list", list: [] },
    },
  },
  {
    label: "3",
    check: z.object({
      gender: z.string().nonempty("Required"),
    }),
    state: {
      points: [
        { path: [{ type: "list", slot: 0 }], values: {} },
        { path: [{ type: "list", slot: 1 }], values: {} },
        { path: [{ type: "list", slot: 2 }], values: {} },
      ],
      inputs: { type: "list", list: [] },
    },
  },
  {
    label: "4",
    check: z.object({
      country: z
        .string()
        .min(1, { message: "Required" })
        .max(20, { message: "Must be at most 20 characters" }),
    }),
    state: {
      points: [
        { path: [{ type: "list", slot: 0 }], values: {} },
        { path: [{ type: "list", slot: 1 }], values: {} },
        { path: [{ type: "list", slot: 2 }], values: {} },
        { path: [{ type: "list", slot: 3 }], values: {} },
      ],
      inputs: { type: "list", list: [] },
    },
  },
];

export const schema: Schema<Values, object, Params> = [
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack, setState }) => (
        <MultiStep onNext={onNext} onBack={onBack} setState={setState}>
          <Step
            key="nameSurname"
            defaultValues={{
              name: params.fields.name,
              surname: params.fields.surname,
            }}
            resolver={zodResolver(steps[0].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={0} fields={params.fields} />
            <Layout
              heading="Tell us what is your name"
              description="We would want to know what is your name"
              fields={[
                <Row
                  key="nameSurname"
                  items={[
                    <TextField key="name" name="name" label="Name" />,
                    <TextField key="surname" name="surname" label="Surname" />,
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack, setState }) => (
        <MultiStep onNext={onNext} onBack={onBack} setState={setState}>
          <Step
            key="age"
            defaultValues={{ age: params.fields.age }}
            resolver={zodResolver(steps[1].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={1} fields={params.fields} />
            <Layout
              heading="Tell us what is your age"
              description="We would want to know what is your age"
              fields={[<NumberField key="age" name="age" label="Age" />]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack, setState }) => (
        <MultiStep onNext={onNext} onBack={onBack} setState={setState}>
          <Step
            key="gender"
            defaultValues={{ gender: params.fields.gender }}
            resolver={zodResolver(steps[2].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={2} fields={params.fields} />
            <Layout
              heading="Tell us what is your gender"
              description="We would want to know what is your gender"
              fields={[
                <Listbox
                  key="gender"
                  name="gender"
                  label="Gender"
                  options={[
                    { value: "", label: "Select a gender" },
                    { value: "man", label: "Man" },
                    { value: "woman", label: "Woman" },
                    { value: "other", label: "Other" },
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onBack, setState }) => (
        <MultiStep onNext={params.onSubmit} onBack={onBack} setState={setState}>
          <Step
            key="country"
            defaultValues={{ country: params.fields.country }}
            resolver={zodResolver(steps[3].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={3} fields={params.fields} />
            <Layout
              heading="Tell us what is your country"
              description="We would want to know what is your country"
              fields={[
                <TextField key="country" name="country" label="Country" />,
              ]}
              button={<NextButton>Finish</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
];

Block submit

Lastly, we need to disable the button on the last step when there are uncompleted steps. To do that, we'll update the schema.tsx file as shown below.

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

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

import {
  Step,
  Steps,
  Layout,
  Row,
  TextField,
  NumberField,
  Listbox,
  NextButton,
  BackButton,
} from "./components";

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

export type Values = [Form<object>, Form<object>, Form<object>, Form<object>];

export type Params = {
  fields: Fields;
  onChange: (fields: Partial<Fields>) => void;
  onSubmit: () => void;
};

export type Fields = {
  name: string;
  surname: string;
  age: number;
  gender: string;
  country: string;
};

const steps: { label: string; check: ZodType; state: State }[] = [
  {
    label: "1",
    check: 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" }),
    }),
    state: {
      points: [{ path: [{ type: "list", slot: 0 }], values: {} }],
      inputs: { type: "list", list: [] },
    },
  },
  {
    label: "2",
    check: z.object({
      age: z
        .number()
        .min(18, { message: "Minimum of 18 years old" })
        .max(99, { message: "Maximum of 99 years old" }),
    }),
    state: {
      points: [
        { path: [{ type: "list", slot: 0 }], values: {} },
        { path: [{ type: "list", slot: 1 }], values: {} },
      ],
      inputs: { type: "list", list: [] },
    },
  },
  {
    label: "3",
    check: z.object({
      gender: z.string().nonempty("Required"),
    }),
    state: {
      points: [
        { path: [{ type: "list", slot: 0 }], values: {} },
        { path: [{ type: "list", slot: 1 }], values: {} },
        { path: [{ type: "list", slot: 2 }], values: {} },
      ],
      inputs: { type: "list", list: [] },
    },
  },
  {
    label: "4",
    check: z.object({
      country: z
        .string()
        .min(1, { message: "Required" })
        .max(20, { message: "Must be at most 20 characters" }),
    }),
    state: {
      points: [
        { path: [{ type: "list", slot: 0 }], values: {} },
        { path: [{ type: "list", slot: 1 }], values: {} },
        { path: [{ type: "list", slot: 2 }], values: {} },
        { path: [{ type: "list", slot: 3 }], values: {} },
      ],
      inputs: { type: "list", list: [] },
    },
  },
];

export const schema: Schema<Values, object, Params> = [
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack, setState }) => (
        <MultiStep onNext={onNext} onBack={onBack} setState={setState}>
          <Step
            key="nameSurname"
            defaultValues={{
              name: params.fields.name,
              surname: params.fields.surname,
            }}
            resolver={zodResolver(steps[0].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={0} fields={params.fields} />
            <Layout
              heading="Tell us what is your name"
              description="We would want to know what is your name"
              fields={[
                <Row
                  key="nameSurname"
                  items={[
                    <TextField key="name" name="name" label="Name" />,
                    <TextField key="surname" name="surname" label="Surname" />,
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack, setState }) => (
        <MultiStep onNext={onNext} onBack={onBack} setState={setState}>
          <Step
            key="age"
            defaultValues={{ age: params.fields.age }}
            resolver={zodResolver(steps[1].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={1} fields={params.fields} />
            <Layout
              heading="Tell us what is your age"
              description="We would want to know what is your age"
              fields={[<NumberField key="age" name="age" label="Age" />]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onNext, onBack, setState }) => (
        <MultiStep onNext={onNext} onBack={onBack} setState={setState}>
          <Step
            key="gender"
            defaultValues={{ gender: params.fields.gender }}
            resolver={zodResolver(steps[2].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={2} fields={params.fields} />
            <Layout
              heading="Tell us what is your gender"
              description="We would want to know what is your gender"
              fields={[
                <Listbox
                  key="gender"
                  name="gender"
                  label="Gender"
                  options={[
                    { value: "", label: "Select a gender" },
                    { value: "man", label: "Man" },
                    { value: "woman", label: "Woman" },
                    { value: "other", label: "Other" },
                  ]}
                />,
              ]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({}),
      render: ({ params, onBack, setState }) => (
        <MultiStep onNext={params.onSubmit} onBack={onBack} setState={setState}>
          <Step
            key="country"
            defaultValues={{ country: params.fields.country }}
            resolver={zodResolver(steps[3].check)}
            onChange={params.onChange}
          >
            <Steps steps={steps} selected={3} fields={params.fields} />
            <Layout
              heading="Tell us what is your country"
              description="We would want to know what is your country"
              fields={[
                <TextField key="country" name="country" label="Country" />,
              ]}
              button={
                <NextButton
                  disabled={steps.some((step) => {
                    return !step.check.safeParse(params.fields).success;
                  })}
                >
                  Finish
                </NextButton>
              }
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
];