Validation
Client, server, row, and group-level validation patterns for Form Builder.
Form Builder validates through Zod and TanStack Form. Keep the schema shared between client and server, then return structured errors from submit handlers so Cubitt can place messages on the right fields, groups, rows, or form root.
Shared Schema
import { z } from "zod";
export const memberSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Enter a valid email"),
});
export type Member = z.infer<typeof memberSchema>;Single Form Server Errors
Return a ZodError from FormBuilder.Single when a server mutation reports validation issues.
import { ZodError } from "zod";
import { FormBuilder } from "@tilt-legal/cubitt-components/form-builder";
async function handleSubmit(values: Member) {
const response = await fetch("/api/members", {
method: "POST",
body: JSON.stringify(values),
});
const json = await response.json();
if (json.status === "error" && json.error?.issues) {
return new ZodError(json.error.issues);
}
}
<FormBuilder.Single
formDefs={memberDefs}
onSubmit={handleSubmit}
schema={memberSchema}
/>;Group-Level Errors
For object-level validation, put the Zod issue on the object path and give the corresponding group node the same name.
const schema = z
.object({
practiceAreas: z.object({
corporate: z.boolean(),
employment: z.boolean(),
litigation: z.boolean(),
}),
})
.superRefine((values, ctx) => {
if (!Object.values(values.practiceAreas).some(Boolean)) {
ctx.addIssue({
code: "custom",
message: "Select at least one practice area",
path: ["practiceAreas"],
});
}
});
const formDefs = [
{
kind: "group",
name: "practiceAreas",
title: "Practice areas",
children: [
{
kind: "field",
name: "practiceAreas.corporate",
label: "Corporate",
component: "checkbox",
},
{
kind: "field",
name: "practiceAreas.employment",
label: "Employment",
component: "checkbox",
},
{
kind: "field",
name: "practiceAreas.litigation",
label: "Litigation",
component: "checkbox",
},
],
},
] as const satisfies FormDefs;Bulk Row Errors
Return BulkSubmitFailure from FormBuilder.Bulk when row import fails on the server.
import { ZodError } from "zod";
async function handleImport({ rows }: { rows: Member[] }) {
const response = await fetch("/api/members/import", {
method: "POST",
body: JSON.stringify({ rows }),
});
const json = await response.json();
if (json.status === "error" && json.error?.issues) {
return { error: new ZodError(json.error.issues) };
}
if (json.status === "duplicates") {
return {
rows: json.duplicates.map((rowIndex: number) => ({
rowIndex,
fieldErrors: {
email: ["Email already exists"],
},
rowErrors: [],
})),
};
}
}Utilities
import {
injectFormError,
injectZodErrors,
mapZodError,
} from "@tilt-legal/cubitt-components/form-builder";| Utility | Description |
|---|---|
injectZodErrors | Inject a ZodError into field meta |
injectFormError | Add one or more form-level errors |
mapZodError | Convert Zod issues to a field-path error map |
Validation Modes
Use the low-level hooks only when building a custom form UI. FormBuilder.Single and FormBuilder.Bulk choose their validation modes internally.
const form = useFormBuilder({
defaultValues,
modeAfterSubmission: "change",
onSubmit,
schema,
validationMode: "submit",
});| Mode | Description |
|---|---|
"submit" | Validate only on submit |
"change" | Validate as fields change |
"blur" | Validate when fields blur |