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 ZodError to 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 BulkSubmitFailure object 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 fields

injectFormError

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) => {
    // ...
  },
});
ModeDescription
"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.

On this page