

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

Define your schema in a shared module for reuse across client and server:

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

### Client Workflow [#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:

```tsx
import { ZodError } from "zod";
import { FormBuilder } from "@tilt-legal/cubitt-components/composites";
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 [#server-response]

Validate incoming payloads with the shared schema and return structured errors:

```tsx
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" });
};
```

<Callout type="info">
  Using oRPC or tRPC? You can throw or return the real `ZodError` directly,
  and the client will use it without extra mapping.
</Callout>

### Group-Level Object Errors [#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`.

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

### Client Workflow [#client-workflow-1]

`FormBuilder.Bulk` validates every row against the schema before submission. Return a `BulkSubmitFailure` when the server reports issues:

```tsx
import { ZodError } from "zod";
import { FormBuilder } from "@tilt-legal/cubitt-components/composites";
import { injectFormError, mapZodError } from "@tilt-legal/cubitt-components/composites";
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 [#server-response-1]

Wrap the array and validate with the same schema:

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

### FormSubmitResult (Single Form) [#formsubmitresult-single-form]

The `onSubmit` handler for `FormBuilder.Single` uses the `FormSubmitResult` type:

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

```tsx
// 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) [#bulksubmitresult-bulk-form]

The `onSubmit` handler for `FormBuilder.Bulk` uses the `BulkSubmitResult` type:

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

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

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

The form builder exports utilities for advanced error handling:

```tsx
import { injectZodErrors } from "@tilt-legal/cubitt-components/composites";
```

### injectZodErrors [#injectzoderrors]

Inject ZodError issues into a form's field errors:

```tsx
import { injectZodErrors } from "@tilt-legal/cubitt-components/composites";

// 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 [#injectformerror]

Inject form-level error messages (not tied to a specific field):

```tsx
import { injectFormError } from "@tilt-legal/cubitt-components/composites";

injectFormError(formApi, "Server error. Please try again.");
// or multiple messages
injectFormError(formApi, ["Error 1", "Error 2"]);
```

### mapZodError [#mapzoderror]

Map a ZodError to field paths for custom display:

```tsx
import { mapZodError } from "@tilt-legal/cubitt-components/composites";

const fieldErrors = mapZodError(zodError);
// { "email": ["Invalid email"], "firstName": ["Required"] }
```

## Validation Modes [#validation-modes]

Configure when validation runs using TanStack Form's `revalidateLogic`:

```tsx
import { useFormBuilder } from "@tilt-legal/cubitt-components/composites";

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  |

<Callout type="info">
  `FormBuilder.Single` and `FormBuilder.Bulk` handle validation modes automatically. Use `useFormBuilder` or `useBulkFormBuilder` when building custom form UIs.
</Callout>
