Single

Schema-driven single-record forms with multi-step wizard support.

FormBuilder.Single renders a complete form from a typed definition array (formDefs). It wires up TanStack Form, supports Zod validation, conditional visibility, multi-step wizards, and group layouts.

Need bulk ingestion?

For CSV import and multi-row editing, see FormBuilder.Bulk instead.

Quick Start

Define the schema

Model your payload with Zod and export it for reuse across form definitions, validation, and server handlers.

import {
  z } from "zod";

export const memberSchema = z.object({
  firstName: z.string().min(1,
  "Required"),
  lastName: z.string().min(1,
  "Required"),
  email: z.string().email("Invalid email"),
  role: z.enum(["admin",
  "member"]).default("member"),
  });

export type Member = z.infer<typeof memberSchema>;

Compose formDefs

Build a formDefs array describing your form's structure. See Form Definitions for the complete reference.

import type { FormDefs } from "@tilt-legal/cubitt-components/form-builder";
import type {
  Member } from "./schema";

export const formDefs = [
  { kind: "field",
  name: "firstName",
  label: "First name",
  component: "text",
  size: "half" },
  { kind: "field",
  name: "lastName",
  label: "Last name",
  component: "text",
  size: "half" },
  { kind: "field",
  name: "email",
  label: "Email",
  component: "email",
  size: "full" },
  {
    kind: "field",
  name: "role",
  label: "Role",
  component: "select",
  options: [
      { value: "admin",
  label: "Admin" },
  { value: "member",
  label: "Member" },
  ],
  size: "full",
  },
  ] as const satisfies FormDefs<Member>;

Render and handle submission

Render FormBuilder.Single with your schema and definitions. Return a ZodError from onSubmit when the server reports validation issues.

import { FormBuilder } from "@tilt-legal/cubitt-components/form-builder";
import { memberSchema } from "./schema";
import { formDefs } from "./form-defs";

export function MemberForm() {
  return (
    <FormBuilder.Single
      schema={memberSchema}
      formDefs={formDefs}
      defaultValues={{ firstName: "", lastName: "", email: "", role: "member" }}
      onSubmit={async (values) => {
        const res = await createMember(values);
        if (res.error) return res.error; // Return ZodError for inline display
      }}
    />
  );
}

Examples

Basic Form

<FormBuilder.Single
  schema={schema}
  formDefs={formDefs}
  defaultValues={{ email: "", password: "" }}
  title="Sign in"
  onSubmit={async (values) => {
    await signIn(values);
  }}
/>

External Submit Button

Use the id prop to connect an external submit button:

const formId = "member-form";

<FormBuilder.Single
  id={formId}
  schema={schema}
  formDefs={formDefs}
  onSubmit={handleSubmit}
/>

<Button type="submit" form={formId}>
  Save Member
</Button>

Or use submitRef for imperative control:

const submitRef = useRef<() => void>(null);

<FormBuilder.Single
  submitRef={submitRef}
  schema={schema}
  formDefs={formDefs}
  onSubmit={handleSubmit}
/>

<Button onClick={() => submitRef.current?.()}>
  Save
</Button>

Multi-Step Wizard

Wrap fields in step nodes to create a wizard flow:

const formDefs = [
  {
    kind: "step",
    id: "contact",
    title: "Contact Info",
    children: [
      { kind: "field", name: "firstName", label: "First name", component: "text", size: "half" },
      { kind: "field", name: "lastName", label: "Last name", component: "text", size: "half" },
    ],
  },
  {
    kind: "step",
    id: "account",
    title: "Account",
    children: [
      { kind: "field", name: "email", label: "Email", component: "email", size: "full" },
      { kind: "field", name: "password", label: "Password", component: "text", size: "full" },
    ],
  },
] as const satisfies FormDefs;

Custom Stepper UI

Use FormBuilder.Single.useStepper() to build custom navigation:

function CustomWizard() {
  const stepper = FormBuilder.Single.useStepper();

  return (
    <div className="space-y-4">
      {/* Progress indicator */}
      <div className="flex gap-2">
        {Array.from({ length: stepper.state.total }, (_, i) => (
          <div
            key={i}
            className={cn(
              "h-2 flex-1 rounded",
              i <= stepper.state.index ? "bg-primary" : "bg-muted"
            )}
          />
        ))}
      </div>

      {/* Form */}
      <FormBuilder.Single
        schema={schema}
        formDefs={formDefs}
        stepper={stepper.render}
        onSubmit={handleSubmit}
      />

      {/* Custom navigation */}
      <div className="flex justify-between">
        <Button
          variant="outline"
          disabled={stepper.state.disabled || stepper.state.isFirst}
          onClick={stepper.actions.previous}
        >
          Back
        </Button>
        {stepper.state.isLast ? (
          <Button disabled={stepper.state.disabled} onClick={stepper.actions.submit}>
            Submit
          </Button>
        ) : (
          <Button disabled={stepper.state.disabled} onClick={stepper.actions.next}>
            Next
          </Button>
        )}
      </div>
    </div>
  );
}

Customising Layout

Pass groupClassName and itemClassName to style the form structure:

<FormBuilder.Single
  schema={schema}
  formDefs={formDefs}
  groupClassName="bg-muted/40 p-6 rounded-xl"
  itemClassName="min-h-[88px]"
  footer={<InlineHelp />}
  onSubmit={handleSubmit}
/>

For object-level validation that should appear once under a fieldset, add name to the relevant group node and raise the Zod issue at that same object path. See Group Nodes and Validation.

API Reference

PropTypeDefaultDescription
schemaz.ZodTypeAnyZod schema used for validation and type inference (required)
formDefsFormDefs<T>Array of step, group, and field nodes describing the form structure (required)
onSubmit(values: T) => FormSubmitResultAsync handler. Return nothing on success or a ZodError for validation failures (required)
defaultValuesPartial<z.infer<typeof schema>>Initial values merged into TanStack Form state
defaultStepnumber | stringInitial step (index or step.id) when using step nodes
validateOnBackbooleanfalseWhen true, validates the current step before moving backwards
titlestringHeading rendered above the form
descriptionstringDescription rendered under the heading
disabledbooleanfalseDisables built-in field interactions, stepper actions, and the default submit button
idstringForwarded to the underlying <form>; pair with external <button form="…">
submitLabelstring | null"Submit"Label for the built-in submit button. Pass null to hide
footerReactNodeReact node rendered after all fields inside the form
stepperfalse | StepperRender<T>Control the wizard UI: omit for default, set false to hide, or pass a custom render function
groupClassNamestringCSS classes applied to group wrappers
itemClassNamestringCSS classes applied to individual field containers
classNamestringCSS classes applied to the form element
submitRefRef<() => void>Imperative ref exposing () => void to trigger submission
formRefRef<AnyReactFormApi>Ref to access the underlying TanStack Form instance

Stepper Hook

FormBuilder.Single.useStepper() returns a handle for external control:

PropertyTypeDescription
state.indexnumberCurrent step index (0-based)
state.totalnumberTotal number of visible steps
state.isFirstbooleanWhether on the first step
state.isLastbooleanWhether on the last step
state.disabledbooleanMirrors the disabled prop so custom steppers can lock their own controls
state.stepStep | nullCurrent step definition
actions.next() => Promise<boolean>Advance to next step (validates first)
actions.previous() => Promise<boolean>Go to previous step
actions.goTo(target) => Promise<boolean>Jump to step by index or id
actions.submit() => Promise<boolean>Trigger form submission
actions.reset() => voidReset to first step
render(slot) => ReactNodePass to stepper prop

On this page