GuidesConditional fields

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.

Terminal
git clone https://github.com/martiserra99/formity-react-hook-form

Then install the dependencies.

Terminal
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:

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:

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:

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:

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.

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

TSX
// 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} />
  );
}