Tutorial

In this tutorial we will be making a conference registration form using ZodForm.

The form has the following requirements:

  1. A number of attendees can be added to the form
    • Each attendee will have a first name, last name and email address, all required
    • The first and last name inputs should be in one row, while the email address should be in another
    • A minimum of one attendee is required
  2. A checkbox for acceptance of the conference rules, this must be checked to submit the form
  3. There should be three products that may be selected for purchase
    • A T-Shirt
    • A USB Stick
    • A backpack
  4. A payment method field should appear when at least one product is selected
    • The payment method should be presented as two radio buttons
    • The payment method should have two options: Debit or PayPal
    • If debit is selected, a card number field should appear and be required
  5. Finally, there should be a submit button to submit the form

Installing the dependencies

CodeSandbox

If you want to follow along in a CodeSandbox, you can fork this template (opens in a new tab) with all the dependencies already installed.

Custom

First, let's install the ZodForm core dependencies:

pnpm i @zodform/core zod

And to make the form look a little nicer, we will install the ZodForm Mantine theme and it's dependencies:

pnpm i @zodform/mantine @mantine/core @mantine/hooks @emotion/react @babel/core

Creating the schema

Let's start from the data model. We'll need to create a schema for the form:

import { z } from "zod"
 
// The schema must be an object
const schema = z
  .object({
    // The attendees field is an array of objects
    attendees: z
      .array(
        z.object({
          // Each attendee has a first name, last name and email address
          firstName: z.string(),
          lastName: z.string(),
          email: z.string().email()
        })
      )
      // There must be at least one attendee.
      // This will also ensure that the array has one element by default.
      .min(1),
 
    // This will create a checkbox which must be checked
    hasAcceptedRules: z.boolean(),
 
    /**
    * An array of enums will render as a checkbox group.
    * The checkbox group is referred to as "multiChoice" in the library.
    * */
    products: z.array(z.enum(["tshirt", "usb", "backpack"] as const)),
 
    // The payment method offers two options, debit or PayPal
    // Note that it needs to be optional, because it will only appear when a product is selected
    paymentMethod: z.enum(["paypal", "debit"] as const).optional(),
    // The card number must also be optional, because it will only appear when the debit option is selected
    debitNumber: z.string().optional()
  });

Refining the schema

There are two requirements that our schema doesn't yet implement:

  1. The payment method should be required when at least one product is selected
  2. The card number should be required when the debit option is selected

The way to implement this in Zod is using a refine (opens in a new tab) function on the schema.

import { z } from "zod"
 
const schema = z
  .object({
    attendees: z
      .array(
        z.object({
          firstName: z.string(),
          lastName: z.string(),
          email: z.string().email()
        })
      )
      .min(1),
 
    hasAcceptedRules: z.boolean().optional(),
 
    products: z.array(z.enum(["tshirt", "usb", "backpack"] as const)),
 
    paymentMethod: z.enum(["paypal", "debit"] as const),
    debitNumber: z.string().optional()
  })
  .refine(
    (data) => {
      if (data.paymentMethod === "debit") {
        return data.debitNumber != null;
      }
      return true;
    },
    {
      message: "Please enter your card number",
      path: ["debitNumber"]
    }
  )
  .refine(
    (data) => {
      if (data.products.length > 0) {
        return data.paymentMethod != null;
      }
      return true;
    },
    {
      message: "Please select a payment method",
      path: ["paymentMethod"]
    }
  );

Creating the form

Now that we have the schema completed, we can render it using the Form component.

import { Form } from "@zodform/core";
import { z } from "zod"
 
const schema = z.object({
  // ...
});
 
export function App() {
  return (
    <Form
      schema={schema}
    />
  );
}

We now have a form on the screen! Let's add a theme to make it look nicer.

Adding the Mantine theme

A theme is a set of components that the library will use to render the different types of fields (string, number, boolean, etc).

A theme is defined using the components prop, and the @zodform/mantine package comes with a pre-defined components object that we need to pass into the Form.

import { Form } from "@zodform/core";
import { components } from "@zodform/mantine";
import { z } from "zod"
 
const schema = z.object({
  // ...
});
 
export function App() {
  return (
    <Form
      schema={schema}
      components={components}
    />
  );
}

Adding a UI schema

The responsibility of the Zod schema is to define the data model. It defines which properties exist, what type they are, and what constraints they have. The Zod schema is not concerned with how those properties are displayed to the user, for that we need a UI schema.

Let's start simple, with a UI schema that will specify the labels for the fields.

import { Form, FormUiSchema } from "@zodform/core";
import { components } from "@zodform/mantine";
import { z } from "zod"
 
const schema = z.object({
  // ...
});
 
const uiSchema: FormUiSchema<typeof schema> = {
  /**
    * The attendees field is an array, and array fields render a list of elements.
    * Arrays are constrained to render one type of element, and we can customize
    * the UI for that element using the `element` property on the array's UI schema.
    * */
  attendees: {
    /**
      * The attendees' element is an object. The UI schema for an object allows
      * specifying the UI schema for each of its properties.
      * */
    element: {
      email: {
        label: "Email"
      },
      firstName: {
        label: "First name"
      },
      lastName: {
        label: "Last name"
      }
    }
  },
 
  hasAcceptedRules: {
    label: "I accept the rules of the conference"
  },
 
  products: {
    label: "Products",
    // The `optionLabels` allows us to customize the labels for the enums
    optionLabels: {
      backpack: "Backpack - $10",
      usb: "Usb - $2",
      tshirt: "T-Shirt - $4"
    }
  },
 
  paymentMethod: {
    label: "Payment method",
    optionLabels: {
      debit: "Debit Card",
      paypal: "PayPal"
    }
  },
 
  debitNumber: {
    label: "Card number"
  }
};
 
export function App() {
  return (
    <Form
      schema={schema}
      components={components}
      uiSchema={uiSchema}
    />
  );
}

In the requirements, it is specified that the payment method shouldn't be visible until a product is selected. Right now the payment method is always visible, let's fix that!

Conditionally rendering fields

The UI schema for every field has a cond property. This property is a function that takes in the current form data and returns a boolean. If the boolean is true, the field will be displayed, otherwise it will be hidden.

import { Form, FormUiSchema } from "@zodform/core";
import { components } from "@zodform/mantine";
import { z } from "zod"
 
const schema = z.object({
  // ...
});
 
const uiSchema: FormUiSchema<typeof schema> = {
  attendees: {
    element: {
      email: {
        label: "Email"
      },
      firstName: {
        label: "First name"
      },
      lastName: {
        label: "Last name"
      }
    }
  },
 
  hasAcceptedRules: {
    label: "I accept the rules of the conference"
  },
 
  products: {
    label: "Products",
    optionLabels: {
      backpack: "Backpack - $10",
      usb: "Usb - $2",
      tshirt: "T-Shirt - $4"
    }
  },
 
  paymentMethod: {
    cond: (data) => {
      return (data.products?.length ?? 0) > 0;
    },
    label: "Payment method",
    optionLabels: {
      debit: "Debit Card",
      paypal: "PayPal"
    }
  },
 
  debitNumber: {
    cond: (data) => data.paymentMethod === "debit",
    label: "Card number"
  }
};
 
export function App() {
  return (
    <Form
      schema={schema}
      components={components}
      uiSchema={uiSchema}
    />
  );
}

Keep in mind that the data argument passed to cond expects that all data could potentially be undefined. This is because cond is called during the form-filling process, and some data may not be available at the time it is called.

Try it out - select a product, and you'll see that the payment method field appears! And, if you select a debit card, the card number field will pop up as well.

Customizing the component for a field

The payment method field currently renders as a select component. This is because the paymentMethod is an enum, and by default, the Mantine theme will render enums as selects. The requirements state that the payment method should be rendered as a radio group, so let's change that.

import { Form, FormUiSchema } from "@zodform/core";
import { components, EnumMantineRadio } from "@zodform/mantine";
import { z } from "zod"
 
const schema = z.object({
  // ...
});
 
const uiSchema: FormUiSchema<typeof schema> = {
  attendees: {
    element: {
      email: {
        label: "Email"
      },
      firstName: {
        label: "First name"
      },
      lastName: {
        label: "Last name"
      }
    }
  },
 
  hasAcceptedRules: {
    label: "I accept the rules of the conference"
  },
 
  products: {
    label: "Products",
    optionLabels: {
      backpack: "Backpack - $10",
      usb: "Usb - $2",
      tshirt: "T-Shirt - $4"
    }
  },
 
  paymentMethod: {
    cond: (data) => {
      return (data.products?.length ?? 0) > 0;
    },
    Component: EnumMantineRadio,
    label: "Payment method",
    optionLabels: {
      debit: "Debit Card",
      paypal: "PayPal"
    }
  },
 
  debitNumber: {
    cond: (data) => data.paymentMethod === "debit",
    label: "Card number"
  }
};
 
export function App() {
  return (
    <Form
      schema={schema}
      components={components}
      uiSchema={uiSchema}
    />
  );
}

Each field has a Component property, which allows us to override the component that is used to render the field. In this case, we're using the EnumMantineRadio component, which renders enums as radio groups.

Customizing the layout of object fields

Since object fields are made up of multiple fields, we may need to define how those fields are laid out inside the object field. For this reason, the object's UI schema has a Layout property, which receives a children prop that is a record where the keys are the names of the fields, and the values are the React nodes to render.

import { Form, FormUiSchema } from "@zodform/core";
import { components, EnumMantineRadio } from "@zodform/mantine";
import { z } from "zod"
import React from "react";
import { Box } from "@mantine/core";
 
const schema = z.object({
  // ...
});
 
const uiSchema: FormUiSchema<typeof schema> = {
  attendees: {
    element: {
      /**
        * To define the UI schema for the object itself, we use the `ui` property.
        * The `ui` property exists to differentiate between the object's UI schema,
        * and the UI schemas for the object's children.
        * */
      ui: {
        Layout: ({ children }) => (
          <React.Fragment>
            <Box
              sx={{
                display: "grid",
                gridTemplateColumns: "1fr 1fr",
                gap: 16,
                marginBottom: 16
              }}
            >
              {children.firstName} {children.lastName}
            </Box>
 
            {children.email}
          </React.Fragment>
        )
      },
      email: {
        label: "Email"
      },
      firstName: {
        label: "First name"
      },
      lastName: {
        label: "Last name"
      }
    }
  },
 
  hasAcceptedRules: {
    label: "I accept the rules of the conference"
  },
 
  products: {
    label: "Products",
    optionLabels: {
      backpack: "Backpack - $10",
      usb: "Usb - $2",
      tshirt: "T-Shirt - $4"
    }
  },
 
  paymentMethod: {
    cond: (data) => {
      return (data.products?.length ?? 0) > 0;
    },
    Component: EnumMantineRadio,
    label: "Payment method",
    optionLabels: {
      debit: "Debit Card",
      paypal: "PayPal"
    }
  },
 
  debitNumber: {
    cond: (data) => data.paymentMethod === "debit",
    label: "Card number"
  }
};
 
export function App() {
  return (
    <Form
      schema={schema}
      components={components}
      uiSchema={uiSchema}
    />
  );
}

Adding a submit button

The form will by default render a submit button. This button will be the default browser button, so we will probably want to override it with our own button.

import { Form, FormUiSchema } from "@zodform/core";
import { components, EnumMantineRadio } from "@zodform/mantine";
import { z } from "zod"
import React from "react";
import { Box, Button } from "@mantine/core";
 
const schema = z.object({
  // ...
});
 
const uiSchema: FormUiSchema<typeof schema> = {
  // ...
};
 
export function App() {
  return (
    <Form
      schema={schema}
      components={components}
      uiSchema={uiSchema}
    >
      {() => <Button type="submit">Submit</Button>}
    </Form>
  );
}