Validation
Shared validation patterns for FormBuilder.Single and FormBuilder.Bulk.
Every Form Builder flow starts with a shared Zod schema. TanStack Form uses the schema in the browser, while your onSubmit handlers return server-side feedback. Store the schema once, reuse it in both single and bulk experiences, and return structured errors so the UI surfaces issues consistently.
Shared Schema
Define your schema in a shared module for reuse across client and server:
// schema/member.ts
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>;FormBuilder.Single Validation
Client Workflow
FormBuilder.Single blocks submission until the schema validates locally. In onSubmit,
call your API and return the ZodError if the server responds with validation issues:
import { ZodError } from "zod";
import { FormBuilder } from "@tilt-legal/cubitt-components/form-builder";
import {
memberSchema,
type Member } from "./schema/member";
import { formDefs } from "./form-defs";
async function handleSubmit(values: Member) {
const res = await fetch("/api/members",
{
method: "POST",
body: JSON.stringify(values),
});
const json = await res.json();
if (json.status === "error" && json.error?.issues) {
return new ZodError(json.error.issues);
}
}
export function MemberForm() {
return (
<FormBuilder.Single
schema={memberSchema}
formDefs={formDefs}
onSubmit={handleSubmit}
/>
);
}Server Response
Validate incoming payloads with the shared schema and return structured errors:
import { ZodError } from "zod";
import { memberSchema } from "./schema/member";
export const POST = async (req: Request) => {
const body = await req.json();
const result = memberSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ status: "error",
error: result.error.toJSON() },
{ status: 422 }
);
}
await saveMember(result.data);
return Response.json({ status: "ok" });
};Using oRPC or tRPC? You can throw or return the real ZodError directly,
and the client will use it without extra mapping.
Group-Level Object Errors
When a validation rule applies to an object-shaped group rather than a single
field, point the issue at the object path and give the corresponding group node
the same name.
const schema = z
.object({
practiceAreas: z.object({
commercialCorporate: 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.commercialCorporate",
label: "Commercial / 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;That renders the message once under the fieldset and wires
aria-describedby to the group, while leaving individual checkbox fields free
to show their own field-level errors separately.
FormBuilder.Bulk Validation
Client Workflow
FormBuilder.Bulk validates every row against the schema before submission. Return a BulkSubmitFailure when the server reports issues:
import { ZodError } from "zod";
import { FormBuilder } from "@tilt-legal/cubitt-components/form-builder";
import {
injectFormError,
mapZodError,
} from "@tilt-legal/cubitt-components/form-builder";
import {
memberSchema,
type Member } from "./schema/member";
import { formDefs } from "./form-defs";
async function handleSubmit({ rows }: { rows: Member[] }) {
const res = await fetch("/api/members/import",
{
method: "POST",
body: JSON.stringify({ rows }),
});
const json = await res.json();
if (json.status === "error" && json.error?.issues) {
return { error: new ZodError(json.error.issues) };
}
}
export function MemberImport() {
return (
<FormBuilder.Bulk
schema={memberSchema}
formDefs={formDefs}
onSubmit={handleSubmit}
/>
);
}Server Response
Wrap the array and validate with the same schema:
import { ZodError,
z } from "zod";
import { memberSchema } from "./schema/member";
const bulkSchema = z.object({
rows: z.array(memberSchema),
});
export const POST = async (req: Request) => {
const body = await req.json();
const result = bulkSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ status: "error",
error: result.error.toJSON() },
{ status: 422 }
);
}
await importMembers(result.data.rows);
return Response.json({ status: "ok" });
};Error Types
FormSubmitResult (Single Form)
The onSubmit handler for FormBuilder.Single uses the FormSubmitResult type:
type FormSubmitResult = Promise<ZodError | undefined> | Promise<void>;This means you can either:
- Return nothing (just await your mutation) for success
- Return a
ZodErrorto display field-level validation errors
// Simple handler - just await and return nothing
onSubmit={async (values) => {
await saveData(values);
}}
// Handler with error return
onSubmit={async (values) => {
const res = await saveData(values);
if (res.error) {
return res.error; // ZodError
}
}}BulkSubmitResult (Bulk Form)
The onSubmit handler for FormBuilder.Bulk uses the BulkSubmitResult type:
type BulkSubmitResult<T> = Promise<BulkSubmitFailure<T> | undefined> | Promise<void>;Similar to single forms, you can either:
- Return nothing (just await your mutation) for success
- Return a
BulkSubmitFailureobject with one or more error types
type BulkSubmitFailure = {
// ZodError - automatically maps to rows and fields
error?: ZodError;
// Form-level error messages
formErrors?: string[];
// Explicit per-row errors
rows?: Array<{
rowIndex: number;
fieldErrors: Record<string,
string[]>;
rowErrors: string[];
}>;
};Example with explicit row errors:
onSubmit={async ({ rows }) => {
const res = await importData(rows);
if (res.duplicates) {
return {
rows: res.duplicates.map((idx) => ({
rowIndex: idx,
fieldErrors: { email: ["Email already exists"] },
rowErrors: [],
})),
};
}
}}Helper Utilities
The form builder exports utilities for advanced error handling:
import { injectZodErrors } from "@tilt-legal/cubitt-components/form-builder";injectZodErrors
Inject ZodError issues into a form's field errors:
import { injectZodErrors } from "@tilt-legal/cubitt-components/form-builder";
// In a custom form setup
const result = injectZodErrors(formApi, zodError, {
prefix: "rows[0]", // Optional: strip path prefix
});
console.log(result.hasErrors); // boolean
console.log(result.fieldCount); // number of affected fieldsinjectFormError
Inject form-level error messages (not tied to a specific field):
import { injectFormError } from "@tilt-legal/cubitt-components/form-builder";
injectFormError(formApi, "Server error. Please try again.");
// or multiple messages
injectFormError(formApi, ["Error 1", "Error 2"]);mapZodError
Map a ZodError to field paths for custom display:
import { mapZodError } from "@tilt-legal/cubitt-components/form-builder";
const fieldErrors = mapZodError(zodError);
// { "email": ["Invalid email"], "firstName": ["Required"] }Validation Modes
Configure when validation runs using TanStack Form's revalidateLogic:
import { useFormBuilder } from "@tilt-legal/cubitt-components/form-builder";
const form = useFormBuilder({
schema: memberSchema,
defaultValues: { firstName: "", lastName: "", email: "" },
validationMode: "submit", // Initial validation trigger
modeAfterSubmission: "change", // After first submit, validate on change
onSubmit: async (values) => {
// ...
},
});| Mode | Description |
|---|---|
"submit" | Validate only on form submission |
"change" | Validate on every field change |
"blur" | Validate when fields lose focus |
FormBuilder.Single and FormBuilder.Bulk handle validation modes automatically. Use useFormBuilder or useBulkFormBuilder when building custom form UIs.