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.tsx
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;
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>
),
},
},
];