Guides
Conditional fields
Learn how to add fields that appear when a condition is met.
Initial steps
We'll show you how to add fields that appear when a condition is met. To follow along, start by cloning the repository below, which contains the code we'll use as the starting point.
git clone https://github.com/martiserra99/formity-react-hook-form
Then install the dependencies.
npm install
Create components
To create conditional fields in our form we first need to create the components that will be used. Specifically, we need to write the code shown below.
components/form/item/condition.tsx:
// components/form/item/condition.tsx
import { Fragment } from "react";
import { useWatch } from "react-hook-form";
import { ItemView, type Item } from ".";
export interface Condition<T extends Record<string, unknown>> {
type: "condition";
if: (values: T) => boolean;
then: Item<T>[];
else: Item<T>[];
}
export function ConditionView<T extends Record<string, unknown>>(
item: Condition<T>,
) {
const values = useWatch() as T;
const items = item.if(values) ? item.then : item.else;
return (
<>
{items.map((item, i) => (
<Fragment key={i}>
<ItemView {...item} />
</Fragment>
))}
</>
);
}
components/form/item/switch.tsx:
// components/form/item/switch.tsx
import { Fragment } from "react";
import { useWatch } from "react-hook-form";
import { ItemView, type Item } from ".";
export interface Switch<T extends Record<string, unknown>> {
type: "switch";
branches: {
case: (values: T) => boolean;
then: Item<T>[];
}[];
default: Item<T>[];
}
export function SwitchView<T extends Record<string, unknown>>(item: Switch<T>) {
const values = useWatch() as T;
const branch = item.branches.find((branch) => branch.case(values));
const items = branch ? branch.then : item.default;
return (
<>
{items.map((item, i) => (
<Fragment key={i}>
<ItemView {...item} />
</Fragment>
))}
</>
);
}
components/form/item/index.tsx:
// components/form/item/index.tsx
import { ConditionView, type Condition } from "./condition";
import { SwitchView, type Switch } from "./switch";
import { ColumnsView, type Columns } from "./columns";
import { InputView, type Input } from "./input";
import { NumberView, type Number } from "./number";
import { SelectView, type Select } from "./select";
import { TextareaView, type Textarea } from "./textarea";
import { MultiSelectView, type MultiSelect } from "./multi-select";
export type Item<T extends Record<string, unknown>> =
| Condition<T>
| Switch<T>
| Columns
| Input
| Number
| Select
| Textarea
| MultiSelect;
export function ItemView<T extends Record<string, unknown>>(item: Item<T>) {
switch (item.type) {
case "condition": {
return <ConditionView {...item} />;
}
case "switch": {
return <SwitchView {...item} />;
}
case "columns": {
return <ColumnsView {...item} />;
}
case "input": {
return <InputView {...item} />;
}
case "number": {
return <NumberView {...item} />;
}
case "select": {
return <SelectView {...item} />;
}
case "textarea": {
return <TextareaView {...item} />;
}
case "multi-select": {
return <MultiSelectView {...item} />;
}
}
}
components/form/index.tsx:
// components/form/index.tsx
import type { DefaultValues, Resolver } from "react-hook-form";
import type { Back, Next } from "@formity/react";
import { useForm, FormProvider } from "react-hook-form";
import type { FormStatus } from "@/types/status";
import { ItemView, type Item } from "./item";
import { Button } from "../button";
interface FormProps<T extends Record<string, unknown>> {
defaultValues: DefaultValues<T>;
resolver: Resolver<T>;
heading: string;
content: Item<T>[];
buttons: {
back: string | null;
next: string;
};
back: Back<T>;
next: Next<T>;
status: FormStatus;
}
export function Form<T extends Record<string, unknown>>({
defaultValues,
resolver,
heading,
content,
buttons,
back,
next,
status,
}: FormProps<T>) {
const form = useForm({ defaultValues, resolver });
return (
<form
onSubmit={form.handleSubmit(next)}
className="flex h-screen w-full items-center justify-center px-4 py-8"
autoComplete="off"
>
<FormProvider {...form}>
<div className="w-full max-w-md">
<h2 className="mb-6 text-center text-4xl font-bold text-gray-950">
{heading}
</h2>
<div className="mb-6 flex flex-col gap-4">
{content.map((field, index) => (
<ItemView key={index} {...field} />
))}
</div>
<div className="flex gap-4">
{buttons.back && (
<Button
type="button"
variant="secondary"
disabled={status.submitting}
onClick={() => back(form.getValues())}
>
{buttons.back}
</Button>
)}
<Button
type="submit"
variant="primary"
disabled={status.submitting}
>
{status.submitting ? "Submitting..." : buttons.next}
</Button>
</div>
</div>
</FormProvider>
</form>
);
}
Create forms with conditions
After that, create the form with all the fields, validate them with a Zod discriminated union, and use the condition component to display the relevant fields.
After the form, use the variables element to create the final value.
// app.tsx
import { useCallback, useState } from "react";
import {
Formity,
type s,
type Flow,
type OnReturn,
type ReturnOutput,
} from "@formity/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import type { Status, FormStatus } from "./types/status";
import { Form } from "./components/form";
import { Done } from "./components/done";
type Values = WorkingValues | NotWorkingValues;
type WorkingValues = {
working: "yes";
company: string;
};
type NotWorkingValues = {
working: "no";
};
type Schema = {
render: React.ReactNode;
struct: [
s.Form<{ working: "yes" | "no"; company: string }>,
s.Variables<{ values: Values }>,
s.Return<Values>,
];
inputs: Record<never, never>;
params: {
status: FormStatus;
};
};
const flow: Flow<Schema> = [
{
form: {
fields: () => ({
working: ["no", []],
company: ["", []],
}),
render: ({ fields, params, back, next }) => (
<Form
key="yourself"
defaultValues={fields}
resolver={zodResolver(
z.discriminatedUnion("working", [
z.object({
working: z.literal("yes"),
company: z.string().min(1, "Required"),
}),
z.object({
working: z.literal("no"),
company: z.string(),
}),
]),
)}
heading="Tell us about yourself"
content={[
{
type: "select",
name: "working",
label: "Are you working?",
placeholder: "Select an option",
options: [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
],
},
{
type: "condition",
if: ({ working }) => working === "yes",
then: [
{
type: "input",
name: "company",
label: "At what company?",
placeholder: "Company name",
},
],
else: [],
},
]}
buttons={{
back: null,
next: "Submit",
}}
back={back}
next={next}
status={params.status}
/>
),
},
},
{
variables: ({ working, company }) => {
if (working === "yes") {
return {
values: {
working: "yes",
company,
},
};
} else {
return {
values: {
working: "no",
},
};
}
},
},
{
return: ({ values }) => values,
},
];
export default function App() {
const [status, setStatus] = useState<Status<ReturnOutput<Schema>>>({
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, 1000));
setStatus({ type: "done", output });
}, []);
if (status.type === "done") {
return (
<Done
output={status.output}
onStartOver={() => setStatus({ type: "form", submitting: false })}
/>
);
}
return (
<Formity<Schema> flow={flow} params={{ status }} onReturn={onReturn} />
);
}
We can also use the switch component we created before if we want more than two branches.
// app.tsx
import { useCallback, useState } from "react";
import {
Formity,
type s,
type Flow,
type OnReturn,
type ReturnOutput,
} from "@formity/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import type { Status, FormStatus } from "./types/status";
import { Form } from "./components/form";
import { Done } from "./components/done";
type Values = WebsiteValues | MobileValues | OtherValues;
type WebsiteValues = {
projectType: "website";
websiteName: string;
};
type MobileValues = {
projectType: "mobile";
appName: string;
};
type OtherValues = {
projectType: "other";
projectName: string;
};
type Schema = {
render: React.ReactNode;
struct: [
s.Form<{
projectType: "website" | "mobile" | "other";
websiteName: string;
appName: string;
projectName: string;
}>,
s.Variables<{ values: Values }>,
s.Return<Values>,
];
inputs: Record<never, never>;
params: {
status: FormStatus;
};
};
const flow: Flow<Schema> = [
{
form: {
fields: () => ({
projectType: ["website", []],
websiteName: ["", []],
appName: ["", []],
projectName: ["", []],
}),
render: ({ fields, params, back, next }) => (
<Form
key="project"
defaultValues={fields}
resolver={zodResolver(
z.discriminatedUnion("projectType", [
z.object({
projectType: z.literal("website"),
websiteName: z.string().min(1, "Required"),
appName: z.string(),
projectName: z.string(),
}),
z.object({
projectType: z.literal("mobile"),
websiteName: z.string(),
appName: z.string().min(1, "Required"),
projectName: z.string(),
}),
z.object({
projectType: z.literal("other"),
websiteName: z.string(),
appName: z.string(),
projectName: z.string().min(1, "Required"),
}),
]),
)}
heading="Tell us about your project"
content={[
{
type: "select",
name: "projectType",
label: "What type of project are you building?",
placeholder: "Select a project type",
options: [
{ value: "website", label: "Website" },
{ value: "mobile", label: "Mobile app" },
{ value: "other", label: "Other" },
],
},
{
type: "switch",
branches: [
{
case: ({ projectType }) => projectType === "website",
then: [
{
type: "input",
name: "websiteName",
label: "What is the website called?",
placeholder: "Website name",
},
],
},
{
case: ({ projectType }) => projectType === "mobile",
then: [
{
type: "input",
name: "appName",
label: "What is the app called?",
placeholder: "App name",
},
],
},
],
default: [
{
type: "input",
name: "projectName",
label: "What is the project called?",
placeholder: "Project name",
},
],
},
]}
buttons={{
back: null,
next: "Submit",
}}
back={back}
next={next}
status={params.status}
/>
),
},
},
{
variables: ({ projectType, websiteName, appName, projectName }) => {
if (projectType === "website") {
return {
values: {
projectType: "website",
websiteName,
},
};
}
if (projectType === "mobile") {
return {
values: {
projectType: "mobile",
appName,
},
};
}
return {
values: {
projectType: "other",
projectName,
},
};
},
},
{
return: ({ values }) => values,
},
];
export default function App() {
const [status, setStatus] = useState<Status<ReturnOutput<Schema>>>({
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, 1000));
setStatus({ type: "done", output });
}, []);
if (status.type === "done") {
return (
<Done
output={status.output}
onStartOver={() => setStatus({ type: "form", submitting: false })}
/>
);
}
return (
<Formity<Schema> flow={flow} params={{ status }} onReturn={onReturn} />
);
}