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";
UtilityDescription
injectZodErrorsInject a ZodError into field meta
injectFormErrorAdd one or more form-level errors
mapZodErrorConvert 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",
});
ModeDescription
"submit"Validate only on submit
"change"Validate as fields change
"blur"Validate when fields blur

On this page