In this tutorial we will be making a conference registration form using ZodForm.
The form has the following requirements:
- 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
- A checkbox for acceptance of the conference rules, this must be checked to submit the form
- There should be three products that may be selected for purchase
- A T-Shirt
- A USB Stick
- A backpack
- 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
- 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:
- The payment method should be required when at least one product is selected
- 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>
);
}