

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 [#shared-schema]

```tsx
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 [#single-form-server-errors]

Return a `ZodError` from `FormBuilder.Single` when a server mutation reports validation issues.

```tsx
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 [#group-level-errors]

For object-level validation, put the Zod issue on the object path and give the corresponding group node the same `name`.

```tsx
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 [#bulk-row-errors]

Return `BulkSubmitFailure` from `FormBuilder.Bulk` when row import fails on the server.

```tsx
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 [#utilities]

```tsx
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 [#validation-modes]

Use the low-level hooks only when building a custom form UI. `FormBuilder.Single` and `FormBuilder.Bulk` choose their validation modes internally.

```tsx
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 |
