Advanced concepts
Jump to steps
Learn how to jump to specific steps in a multi-step form.
Initial steps
We'll show you how to jump to specific steps in a multi-step form. Specifically, we'll recreate the multi-step form you can see here.
We'll start by cloning the following GitHub repository so we don't have to start from scratch.
git clone https://github.com/martiserra99/formity-jump-to-steps
Make sure you run the following command to install all the dependencies.
npm install
The project includes multiple form steps and a review step. We'll implement the logic to track completed steps, highlight the current step in the sidebar, and jump between steps.
Sidebar state
First, we'll create the logic to determine the current step and how many steps are completed. This data will be shown in the sidebar and used to control which steps can be jumped to.
We'll get the current step by making each form element return its position, and the number of completed steps by checking which steps pass validation.
To implement this logic we'll need to update the following files.
app/index.tsx:
// app/index.tsx
import type { OnReturn } from "@formity/react";
import { useFormity } from "@formity/react";
import { useState, useCallback, useMemo } from "react";
import type { Status, FormStatus } from "@/types/status";
import type { FormStep } from "@/types/steps";
import { Sidebar } from "../components/sidebar";
import { Submitted } from "../components/submitted";
import { steps, flow, inputs, type Schema } from "./flow";
export default function App() {
const [status, setStatus] = useState<Status>({
type: "form",
submitting: false,
});
const onReturn = useCallback<OnReturn<Schema>>(async (output) => {
setStatus({ type: "form", submitting: true });
// Show output in the console
console.log(output);
// Simulate a network request
await new Promise((resolve) => setTimeout(resolve, 2000));
setStatus({ type: "submitted" });
}, []);
if (status.type === "submitted") {
return (
<Submitted
onStart={() => setStatus({ type: "form", submitting: false })}
/>
);
}
return <Formity status={status} onReturn={onReturn} />;
}
interface FormityProps {
status: FormStatus;
onReturn: OnReturn<Schema>;
}
function Formity({ status, onReturn }: FormityProps) {
const [values, setValues] = useState(inputs);
const { step: currentStep, form } = useFormity({
flow,
inputs,
params: { status, setValues },
history: false,
onReturn,
});
const completedSteps = useMemo(() => {
for (let i = 0; i < steps.length - 1; i++) {
const step = steps[i] as FormStep;
if (!step.zod.safeParse(values).success) {
return i;
}
}
return steps.length - 1;
}, [values]);
return (
<div className="color-scheme-dark flex min-h-svh">
<Sidebar
steps={steps}
currentStep={currentStep}
completedSteps={completedSteps}
onJump={() => {}}
/>
<main className="flex flex-1 items-start justify-center overflow-y-auto px-16 py-20">
{form}
</main>
</div>
);
}
app/flow.tsx:
// app/flow.tsx
import type { UnionToIntersection } from "type-fest";
import type { s, Flow } from "@formity/react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Steps, FormStep, ReviewStep } from "@/types/steps";
import type { FormStatus } from "@/types/status";
import { Form } from "@/components/form";
import { Review } from "@/components/review";
import * as constants from "@/constants";
import * as format from "@/utils/format";
type Values = UnionToIntersection<Fields[keyof Fields]>;
type Fields = {
personal: {
name: string;
surname: string;
gender: string;
bio: string;
};
location: {
streetAddress: string;
apartment: string;
city: string;
state: string;
postalCode: string;
country: string;
};
security: {
username: string;
password: string;
confirmPassword: string;
};
preferences: {
language: string;
timezone: string;
emailNotifications: boolean;
marketingEmails: boolean;
weeklyNewsletter: boolean;
};
};
const personal: FormStep<Fields["personal"]> = {
id: "personal",
label: "Personal",
subtitle: "Basic information",
zod: z.object({
name: z.string().nonempty("Required"),
surname: z.string().nonempty("Required"),
gender: z.string().nonempty("Required"),
bio: z.string(),
}),
};
const location: FormStep<Fields["location"]> = {
id: "location",
label: "Location",
subtitle: "Where you are",
zod: z.object({
streetAddress: z.string().nonempty("Required"),
apartment: z.string(),
city: z.string().nonempty("Required"),
state: z.string().nonempty("Required"),
postalCode: z.string().nonempty("Required"),
country: z.string().nonempty("Required"),
}),
};
const security: FormStep<Fields["security"]> = {
id: "security",
label: "Security",
subtitle: "Login credentials",
zod: z
.object({
username: z
.string()
.nonempty("Required")
.regex(
/^[a-zA-Z0-9_.]+$/,
"Letters, numbers, underscores and dots only",
),
password: z.string().nonempty("Required").min(8, "Min. 8 characters"),
confirmPassword: z.string().nonempty("Required"),
})
.superRefine(({ password, confirmPassword }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
}),
};
const preferences: FormStep<Fields["preferences"]> = {
id: "preferences",
label: "Preferences",
subtitle: "Customize your setup",
zod: z.object({
language: z.string().nonempty("Required"),
timezone: z.string().nonempty("Required"),
emailNotifications: z.boolean(),
marketingEmails: z.boolean(),
weeklyNewsletter: z.boolean(),
}),
};
const review: ReviewStep = {
id: "review",
label: "Review",
subtitle: "Confirm & submit",
};
export const steps: Steps = [personal, location, security, preferences, review];
export type Schema = {
render: { step: number; form: React.ReactNode };
struct: [
s.Form<Fields["personal"]>,
s.Form<Fields["location"]>,
s.Form<Fields["security"]>,
s.Form<Fields["preferences"]>,
s.Form<Record<never, never>>,
s.Return<Values>,
];
inputs: Values;
params: {
status: FormStatus;
setValues: (values: Values) => void;
};
};
export const flow: Flow<Schema> = [
{
form: {
fields: (values) => ({
name: [values.name, []],
surname: [values.surname, []],
gender: [values.gender, []],
bio: [values.bio, []],
}),
render: ({ fields, values, params, onNext }) => ({
step: 0,
form: (
<Form
key={personal.id}
defaultValues={fields}
resolver={zodResolver(personal.zod)}
position="01"
heading="Personal Info"
message="Let's start with the basics. Your information is kept private."
content={[
{
type: "columns",
columns: [
{
type: "input",
name: "name",
label: "Name",
placeholder: "Jane",
},
{
type: "input",
name: "surname",
label: "Surname",
placeholder: "Smith",
},
],
},
{
type: "select",
name: "gender",
label: "Gender",
placeholder: "Select your gender",
options: constants.genders,
},
{
type: "textarea",
name: "bio",
label: "Short Bio",
placeholder: "Tell us a little about yourself...",
optional: true,
},
]}
buttons={{ next: "Continue", back: null }}
onNext={onNext}
values={values}
setValues={params.setValues}
/>
),
}),
},
},
{
form: {
fields: (values) => ({
streetAddress: [values.streetAddress, []],
apartment: [values.apartment, []],
city: [values.city, []],
state: [values.state, []],
postalCode: [values.postalCode, []],
country: [values.country, []],
}),
render: ({ fields, values, params, onNext }) => ({
step: 1,
form: (
<Form
key={location.id}
defaultValues={fields}
resolver={zodResolver(location.zod)}
position="02"
heading="Location"
message="Where are you based? Used to personalise your experience."
content={[
{
type: "input",
name: "streetAddress",
label: "Street Address",
placeholder: "123 Main Street",
},
{
type: "input",
name: "apartment",
label: "Apartment / Suite",
placeholder: "Apt 4B",
optional: true,
},
{
type: "columns",
columns: [
{
type: "input",
name: "city",
label: "City",
placeholder: "New York",
},
{
type: "input",
name: "state",
label: "State / Province",
placeholder: "NY",
},
],
},
{
type: "columns",
columns: [
{
type: "input",
name: "postalCode",
label: "Postal Code",
placeholder: "10001",
},
{
type: "select",
name: "country",
label: "Country",
placeholder: "Select a country",
options: constants.countries,
},
],
},
]}
buttons={{ next: "Continue", back: "Back" }}
onNext={onNext}
values={values}
setValues={params.setValues}
/>
),
}),
},
},
{
form: {
fields: (values) => ({
username: [values.username, []],
password: [values.password, []],
confirmPassword: [values.confirmPassword, []],
}),
render: ({ fields, values, params, onNext }) => ({
step: 2,
form: (
<Form
key={security.id}
defaultValues={fields}
resolver={zodResolver(security.zod)}
position="03"
heading="Security"
message="Create your login credentials. Use a strong and unique password."
content={[
{
type: "input",
name: "username",
label: "Username",
placeholder: "jane_smith",
},
{
type: "divider",
text: "Credentials",
},
{
type: "password",
name: "password",
label: "Password",
placeholder: "Min. 8 characters",
},
{
type: "password",
name: "confirmPassword",
label: "Confirm Password",
placeholder: "Repeat your password",
},
]}
buttons={{ next: "Continue", back: "Back" }}
onNext={onNext}
values={values}
setValues={params.setValues}
/>
),
}),
},
},
{
form: {
fields: (values) => ({
language: [values.language, []],
timezone: [values.timezone, []],
emailNotifications: [values.emailNotifications, []],
marketingEmails: [values.marketingEmails, []],
weeklyNewsletter: [values.weeklyNewsletter, []],
}),
render: ({ fields, values, params, onNext }) => ({
step: 3,
form: (
<Form
key={preferences.id}
defaultValues={fields}
resolver={zodResolver(preferences.zod)}
position="04"
heading="Preferences"
message="Customise your experience. All of these can be changed later."
content={[
{
type: "select",
name: "language",
label: "Language",
placeholder: "Select language",
options: constants.languages,
},
{
type: "select",
name: "timezone",
label: "Timezone",
placeholder: "Select timezone",
options: constants.timezones,
},
{
type: "switch",
name: "emailNotifications",
label: "Email Notifications",
description: "Security alerts and account updates",
},
{
type: "switch",
name: "marketingEmails",
label: "Marketing Emails",
description: "Product announcements and special offers",
},
{
type: "switch",
name: "weeklyNewsletter",
label: "Weekly Newsletter",
description: "Tips, guides and community highlights",
},
]}
buttons={{ next: "Continue", back: "Back" }}
onNext={onNext}
values={values}
setValues={params.setValues}
/>
),
}),
},
},
{
form: {
fields: () => ({}),
render: ({ values, params, onNext }) => ({
step: 4,
form: (
<Review
key={review.id}
position="05"
heading="Review"
message="Almost there. Double-check your details before submitting."
content={[
{
text: "Personal",
rows: [
{
label: "Full Name",
value: format.fullName(values.name, values.surname),
},
{
label: "Gender",
value: format.gender(values.gender),
},
{
label: "Bio",
value: format.bio(values.bio),
},
],
},
{
text: "Location",
rows: [
{
label: "Street",
value: format.street(
values.streetAddress,
values.apartment,
),
},
{
label: "City",
value: format.city(
values.city,
values.state,
values.postalCode,
),
},
{
label: "Country",
value: format.country(values.country),
},
],
},
{
text: "Security",
rows: [
{
label: "Username",
value: format.username(values.username),
},
{
label: "Password",
value: format.password(values.password),
},
],
},
{
text: "Preferences",
rows: [
{
label: "Language",
value: format.language(values.language),
},
{
label: "Timezone",
value: format.timezone(values.timezone),
},
{
label: "Email Notifications",
value: format.toggle(values.emailNotifications),
},
{
label: "Marketing Emails",
value: format.toggle(values.marketingEmails),
},
{
label: "Newsletter",
value: format.toggle(values.weeklyNewsletter),
},
],
},
]}
buttons={{
next: "Submit",
back: "Back",
}}
onNext={onNext}
status={params.status}
/>
),
}),
},
},
{
return: (values) => values,
},
];
export const inputs: Values = {
name: "",
surname: "",
gender: "",
bio: "",
streetAddress: "",
apartment: "",
city: "",
state: "",
postalCode: "",
country: "",
username: "",
password: "",
confirmPassword: "",
language: "",
timezone: "",
emailNotifications: false,
marketingEmails: false,
weeklyNewsletter: false,
};
components/form/index.tsx:
// components/form/index.tsx
import type { DefaultValues, Resolver } from "react-hook-form";
import type { OnNext } from "@formity/react";
import { useEffect, useEffectEvent } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { ItemView, type Item } from "./item";
interface FormProps<T extends Record<string, unknown>, U extends T> {
defaultValues: DefaultValues<T>;
resolver: Resolver<T>;
position: string;
heading: string;
message: string;
content: Item[];
buttons: {
next: string;
back: string | null;
};
onNext: OnNext<T>;
values: U;
setValues: (values: U) => void;
}
export function Form<T extends Record<string, unknown>, U extends T>({
defaultValues,
resolver,
position,
heading,
message,
content,
buttons,
onNext,
values,
setValues,
}: FormProps<T, U>) {
const form = useForm({ defaultValues, resolver });
const onValuesChange = useEffectEvent(({ values: fields }: { values: T }) => {
setValues({ ...values, ...fields });
});
useEffect(() => {
return form.subscribe({
formState: { values: true },
callback: onValuesChange,
});
}, [form]);
return (
<form
autoComplete="off"
onSubmit={form.handleSubmit(onNext)}
className="block w-full max-w-lg"
>
<FormProvider {...form}>
<div className="mb-10">
<div className="mb-2.5 inline-flex select-none items-center rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 font-sans text-xs font-bold text-blue-500">
{position}
</div>
<h2 className="mb-2.5 font-sans text-3xl font-bold leading-tight text-white">
{heading}
</h2>
<p className="mb-6 text-base leading-normal text-neutral-400">
{message}
</p>
<div className="mb-8 flex flex-col gap-5">
{content.map((item, i) => (
<ItemView key={i} {...item} />
))}
</div>
<div className="flex items-center justify-between gap-3">
{buttons.back && (
<button
type="button"
onClick={() => {}}
className="not-disabled:hover:bg-neutral-500/30 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-neutral-500/20 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
>
{buttons.back}
</button>
)}
<button
type="submit"
className="not-disabled:hover:bg-blue-600 ml-auto inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-500 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
>
{buttons.next}
</button>
</div>
</div>
</FormProvider>
</form>
);
}
Jump to steps
We'll implement jump functionality using the jump element. For this, we'll update these files.
app/index.tsx:
// app/index.tsx
import type { OnReturn } from "@formity/react";
import { useFormity } from "@formity/react";
import { useState, useCallback, useMemo, useRef } from "react";
import type { Status, FormStatus } from "@/types/status";
import type { FormStep } from "@/types/steps";
import { Sidebar } from "../components/sidebar";
import { Submitted } from "../components/submitted";
import { steps, flow, inputs, type Schema } from "./flow";
export default function App() {
const [status, setStatus] = useState<Status>({
type: "form",
submitting: false,
});
const onReturn = useCallback<OnReturn<Schema>>(async (output) => {
setStatus({ type: "form", submitting: true });
// Show output in the console
console.log(output);
// Simulate a network request
await new Promise((resolve) => setTimeout(resolve, 2000));
setStatus({ type: "submitted" });
}, []);
if (status.type === "submitted") {
return (
<Submitted
onStart={() => setStatus({ type: "form", submitting: false })}
/>
);
}
return <Formity status={status} onReturn={onReturn} />;
}
interface FormityProps {
status: FormStatus;
onReturn: OnReturn<Schema>;
}
function Formity({ status, onReturn }: FormityProps) {
const [values, setValues] = useState(inputs);
const ref = useRef<{ jump: (id: string) => void }>(null);
const { step: currentStep, form } = useFormity({
flow,
inputs,
params: { status, setValues, ref },
history: false,
onReturn,
});
const completedSteps = useMemo(() => {
for (let i = 0; i < steps.length - 1; i++) {
const step = steps[i] as FormStep;
if (!step.zod.safeParse(values).success) {
return i;
}
}
return steps.length - 1;
}, [values]);
return (
<div className="color-scheme-dark flex min-h-svh">
<Sidebar
steps={steps}
currentStep={currentStep}
completedSteps={completedSteps}
onJump={(index) => ref.current?.jump(steps[index].id)}
/>
<main className="flex flex-1 items-start justify-center overflow-y-auto px-16 py-20">
{form}
</main>
</div>
);
}
app/flow.tsx:
// app/flow.tsx
import type { UnionToIntersection } from "type-fest";
import type { s, Flow } from "@formity/react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Steps, FormStep, ReviewStep } from "@/types/steps";
import type { FormStatus } from "@/types/status";
import { Form } from "@/components/form";
import { Review } from "@/components/review";
import * as constants from "@/constants";
import * as format from "@/utils/format";
type Values = UnionToIntersection<Fields[keyof Fields]>;
type Fields = {
personal: {
name: string;
surname: string;
gender: string;
bio: string;
};
location: {
streetAddress: string;
apartment: string;
city: string;
state: string;
postalCode: string;
country: string;
};
security: {
username: string;
password: string;
confirmPassword: string;
};
preferences: {
language: string;
timezone: string;
emailNotifications: boolean;
marketingEmails: boolean;
weeklyNewsletter: boolean;
};
};
const personal: FormStep<Fields["personal"]> = {
id: "personal",
label: "Personal",
subtitle: "Basic information",
zod: z.object({
name: z.string().nonempty("Required"),
surname: z.string().nonempty("Required"),
gender: z.string().nonempty("Required"),
bio: z.string(),
}),
};
const location: FormStep<Fields["location"]> = {
id: "location",
label: "Location",
subtitle: "Where you are",
zod: z.object({
streetAddress: z.string().nonempty("Required"),
apartment: z.string(),
city: z.string().nonempty("Required"),
state: z.string().nonempty("Required"),
postalCode: z.string().nonempty("Required"),
country: z.string().nonempty("Required"),
}),
};
const security: FormStep<Fields["security"]> = {
id: "security",
label: "Security",
subtitle: "Login credentials",
zod: z
.object({
username: z
.string()
.nonempty("Required")
.regex(
/^[a-zA-Z0-9_.]+$/,
"Letters, numbers, underscores and dots only",
),
password: z.string().nonempty("Required").min(8, "Min. 8 characters"),
confirmPassword: z.string().nonempty("Required"),
})
.superRefine(({ password, confirmPassword }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
}),
};
const preferences: FormStep<Fields["preferences"]> = {
id: "preferences",
label: "Preferences",
subtitle: "Customize your setup",
zod: z.object({
language: z.string().nonempty("Required"),
timezone: z.string().nonempty("Required"),
emailNotifications: z.boolean(),
marketingEmails: z.boolean(),
weeklyNewsletter: z.boolean(),
}),
};
const review: ReviewStep = {
id: "review",
label: "Review",
subtitle: "Confirm & submit",
};
export const steps: Steps = [personal, location, security, preferences, review];
export type Schema = {
render: { step: number; form: React.ReactNode };
struct: [
s.Jump<s.Form<Fields["personal"]>>,
s.Jump<s.Form<Fields["location"]>>,
s.Jump<s.Form<Fields["security"]>>,
s.Jump<s.Form<Fields["preferences"]>>,
s.Jump<s.Form<Record<never, never>>>,
s.Return<Values>,
];
inputs: Values;
params: {
status: FormStatus;
setValues: (values: Values) => void;
ref: React.Ref<{ jump: (id: string) => void }>;
};
};
export const flow: Flow<Schema> = [
{
jump: {
id: personal.id,
at: {
form: {
fields: (values) => ({
name: [values.name, []],
surname: [values.surname, []],
gender: [values.gender, []],
bio: [values.bio, []],
}),
render: ({ fields, values, params, onNext, onJump }) => ({
step: 0,
form: (
<Form
key={personal.id}
defaultValues={fields}
resolver={zodResolver(personal.zod)}
position="01"
heading="Personal Info"
message="Let's start with the basics. Your information is kept private."
content={[
{
type: "columns",
columns: [
{
type: "input",
name: "name",
label: "Name",
placeholder: "Jane",
},
{
type: "input",
name: "surname",
label: "Surname",
placeholder: "Smith",
},
],
},
{
type: "select",
name: "gender",
label: "Gender",
placeholder: "Select your gender",
options: constants.genders,
},
{
type: "textarea",
name: "bio",
label: "Short Bio",
placeholder: "Tell us a little about yourself...",
optional: true,
},
]}
buttons={{ next: "Continue", back: null }}
onNext={onNext}
onJump={onJump}
prevId={null}
values={values}
setValues={params.setValues}
ref={params.ref}
/>
),
}),
},
},
},
},
{
jump: {
id: location.id,
at: {
form: {
fields: (values) => ({
streetAddress: [values.streetAddress, []],
apartment: [values.apartment, []],
city: [values.city, []],
state: [values.state, []],
postalCode: [values.postalCode, []],
country: [values.country, []],
}),
render: ({ fields, values, params, onNext, onJump }) => ({
step: 1,
form: (
<Form
key={location.id}
defaultValues={fields}
resolver={zodResolver(location.zod)}
position="02"
heading="Location"
message="Where are you based? Used to personalise your experience."
content={[
{
type: "input",
name: "streetAddress",
label: "Street Address",
placeholder: "123 Main Street",
},
{
type: "input",
name: "apartment",
label: "Apartment / Suite",
placeholder: "Apt 4B",
optional: true,
},
{
type: "columns",
columns: [
{
type: "input",
name: "city",
label: "City",
placeholder: "New York",
},
{
type: "input",
name: "state",
label: "State / Province",
placeholder: "NY",
},
],
},
{
type: "columns",
columns: [
{
type: "input",
name: "postalCode",
label: "Postal Code",
placeholder: "10001",
},
{
type: "select",
name: "country",
label: "Country",
placeholder: "Select a country",
options: constants.countries,
},
],
},
]}
buttons={{ next: "Continue", back: "Back" }}
onNext={onNext}
onJump={onJump}
prevId={personal.id}
values={values}
setValues={params.setValues}
ref={params.ref}
/>
),
}),
},
},
},
},
{
jump: {
id: security.id,
at: {
form: {
fields: (values) => ({
username: [values.username, []],
password: [values.password, []],
confirmPassword: [values.confirmPassword, []],
}),
render: ({ fields, values, params, onNext, onJump }) => ({
step: 2,
form: (
<Form
key={security.id}
defaultValues={fields}
resolver={zodResolver(security.zod)}
position="03"
heading="Security"
message="Create your login credentials. Use a strong and unique password."
content={[
{
type: "input",
name: "username",
label: "Username",
placeholder: "jane_smith",
},
{
type: "divider",
text: "Credentials",
},
{
type: "password",
name: "password",
label: "Password",
placeholder: "Min. 8 characters",
},
{
type: "password",
name: "confirmPassword",
label: "Confirm Password",
placeholder: "Repeat your password",
},
]}
buttons={{ next: "Continue", back: "Back" }}
onNext={onNext}
onJump={onJump}
prevId={location.id}
values={values}
setValues={params.setValues}
ref={params.ref}
/>
),
}),
},
},
},
},
{
jump: {
id: preferences.id,
at: {
form: {
fields: (values) => ({
language: [values.language, []],
timezone: [values.timezone, []],
emailNotifications: [values.emailNotifications, []],
marketingEmails: [values.marketingEmails, []],
weeklyNewsletter: [values.weeklyNewsletter, []],
}),
render: ({ fields, values, params, onNext, onJump }) => ({
step: 3,
form: (
<Form
key={preferences.id}
defaultValues={fields}
resolver={zodResolver(preferences.zod)}
position="04"
heading="Preferences"
message="Customise your experience. All of these can be changed later."
content={[
{
type: "select",
name: "language",
label: "Language",
placeholder: "Select language",
options: constants.languages,
},
{
type: "select",
name: "timezone",
label: "Timezone",
placeholder: "Select timezone",
options: constants.timezones,
},
{
type: "switch",
name: "emailNotifications",
label: "Email Notifications",
description: "Security alerts and account updates",
},
{
type: "switch",
name: "marketingEmails",
label: "Marketing Emails",
description: "Product announcements and special offers",
},
{
type: "switch",
name: "weeklyNewsletter",
label: "Weekly Newsletter",
description: "Tips, guides and community highlights",
},
]}
buttons={{ next: "Continue", back: "Back" }}
onNext={onNext}
onJump={onJump}
prevId={security.id}
values={values}
setValues={params.setValues}
ref={params.ref}
/>
),
}),
},
},
},
},
{
jump: {
id: review.id,
at: {
form: {
fields: () => ({}),
render: ({ values, params, onNext, onJump }) => ({
step: 4,
form: (
<Review
key={review.id}
position="05"
heading="Review"
message="Almost there. Double-check your details before submitting."
content={[
{
text: "Personal",
rows: [
{
label: "Full Name",
value: format.fullName(values.name, values.surname),
},
{
label: "Gender",
value: format.gender(values.gender),
},
{
label: "Bio",
value: format.bio(values.bio),
},
],
},
{
text: "Location",
rows: [
{
label: "Street",
value: format.street(
values.streetAddress,
values.apartment,
),
},
{
label: "City",
value: format.city(
values.city,
values.state,
values.postalCode,
),
},
{
label: "Country",
value: format.country(values.country),
},
],
},
{
text: "Security",
rows: [
{
label: "Username",
value: format.username(values.username),
},
{
label: "Password",
value: format.password(values.password),
},
],
},
{
text: "Preferences",
rows: [
{
label: "Language",
value: format.language(values.language),
},
{
label: "Timezone",
value: format.timezone(values.timezone),
},
{
label: "Email Notifications",
value: format.toggle(values.emailNotifications),
},
{
label: "Marketing Emails",
value: format.toggle(values.marketingEmails),
},
{
label: "Newsletter",
value: format.toggle(values.weeklyNewsletter),
},
],
},
]}
buttons={{
next: "Submit",
back: "Back",
}}
onNext={onNext}
onJump={onJump}
prevId={preferences.id}
status={params.status}
ref={params.ref}
/>
),
}),
},
},
},
},
{
return: (values) => values,
},
];
export const inputs: Values = {
name: "",
surname: "",
gender: "",
bio: "",
streetAddress: "",
apartment: "",
city: "",
state: "",
postalCode: "",
country: "",
username: "",
password: "",
confirmPassword: "",
language: "",
timezone: "",
emailNotifications: false,
marketingEmails: false,
weeklyNewsletter: false,
};
components/form/index.tsx:
// components/form/index.tsx
import type { DefaultValues, Resolver } from "react-hook-form";
import type { OnNext, OnJump } from "@formity/react";
import { useEffect, useEffectEvent, useImperativeHandle } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { ItemView, type Item } from "./item";
interface FormProps<T extends Record<string, unknown>, U extends T> {
defaultValues: DefaultValues<T>;
resolver: Resolver<T>;
position: string;
heading: string;
message: string;
content: Item[];
buttons: {
next: string;
back: string | null;
};
onNext: OnNext<T>;
onJump: OnJump<T>;
prevId: string | null;
values: U;
setValues: (values: U) => void;
ref: React.Ref<{ jump: (id: string) => void }>;
}
export function Form<T extends Record<string, unknown>, U extends T>({
defaultValues,
resolver,
position,
heading,
message,
content,
buttons,
onNext,
onJump,
prevId,
values,
setValues,
ref,
}: FormProps<T, U>) {
const form = useForm({ defaultValues, resolver });
const onValuesChange = useEffectEvent(({ values: fields }: { values: T }) => {
setValues({ ...values, ...fields });
});
useEffect(() => {
return form.subscribe({
formState: { values: true },
callback: onValuesChange,
});
}, [form]);
useImperativeHandle(
ref,
() => ({
jump: (id) => onJump(id, form.getValues()),
}),
[form, onJump],
);
return (
<form
autoComplete="off"
onSubmit={form.handleSubmit(onNext)}
className="block w-full max-w-lg"
>
<FormProvider {...form}>
<div className="mb-10">
<div className="mb-2.5 inline-flex select-none items-center rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 font-sans text-xs font-bold text-blue-500">
{position}
</div>
<h2 className="mb-2.5 font-sans text-3xl font-bold leading-tight text-white">
{heading}
</h2>
<p className="mb-6 text-base leading-normal text-neutral-400">
{message}
</p>
<div className="mb-8 flex flex-col gap-5">
{content.map((item, i) => (
<ItemView key={i} {...item} />
))}
</div>
<div className="flex items-center justify-between gap-3">
{buttons.back && (
<button
type="button"
onClick={() => onJump(prevId, form.getValues())}
className="not-disabled:hover:bg-neutral-500/30 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-neutral-500/20 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
>
{buttons.back}
</button>
)}
<button
type="submit"
className="not-disabled:hover:bg-blue-600 ml-auto inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-500 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
>
{buttons.next}
</button>
</div>
</div>
</FormProvider>
</form>
);
}
components/review/index.tsx:
// components/review/index.tsx
import type { OnNext, OnJump } from "@formity/react";
import { useImperativeHandle } from "react";
import type { FormStatus } from "@/types/status";
import { ItemView, type Item } from "./item";
interface ReviewProps {
position: string;
heading: string;
message: string;
content: Item[];
buttons: {
next: string;
back: string;
};
onNext: OnNext<Record<never, never>>;
onJump: OnJump<Record<never, never>>;
prevId: string;
status: FormStatus;
ref: React.Ref<{ jump: (id: string) => void }>;
}
export function Review({
position,
heading,
message,
content,
buttons,
onNext,
onJump,
prevId,
status,
ref,
}: ReviewProps) {
useImperativeHandle(
ref,
() => ({
jump: (id) => onJump(id, {}),
}),
[onJump],
);
return (
<div className="w-full max-w-lg">
<div className="mb-10">
<div className="mb-2.5 inline-flex select-none items-center rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 font-sans text-xs font-bold text-blue-500">
{position}
</div>
<h2 className="mb-2.5 font-sans text-3xl font-bold leading-tight text-white">
{heading}
</h2>
<p className="mb-6 text-base leading-normal text-neutral-400">
{message}
</p>
<div className="mb-8 flex flex-col gap-5">
{content.map((item, i) => (
<ItemView key={i} {...item} />
))}
</div>
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => onJump(prevId, {})}
disabled={status.submitting}
className="not-disabled:hover:bg-neutral-500/30 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-neutral-500/20 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
>
{buttons.back}
</button>
<button
type="submit"
onClick={() => onNext({})}
disabled={status.submitting}
className="not-disabled:hover:bg-blue-600 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-500 px-6 py-3 font-sans text-sm font-semibold leading-none text-white transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"
>
{status.submitting ? "Submitting..." : buttons.next}
</button>
</div>
</div>
</div>
);
}