BuzzForm
BuzzFormDocs

Custom Field Components

Build your own field component UI using any UI library and wire it into BuzzForm's registry.

Custom Field Components

BuzzForm's registry pattern lets you provide your own component for any supported field type. The built-in shadcn components are just one implementation — you can build your own using any UI library (Radix, Mantine, Chakra, plain HTML, etc.) and register them in the field registry.

New in v0.1: In addition to overriding standard fields, you can now define completely custom unregistered field types (e.g. color-picker, rich-editor, signature) directly in your schemas with automatic type inference!

How It Works

BuzzForm separates logic from UI:

  • Logic layer (@buildnbuzz/form-react) — handles state, validation, visibility, and accessibility via hooks like useDataField and useLayoutField
  • UI layer (your registry) — renders the visual component using whatever UI library you choose

Your custom component calls BuzzForm hooks to get field state and handlers, then renders its own UI.

Step-by-Step: Building a Custom Text Field

Step 1: Create the Component

Use useDataField to get all the state and handlers you need:

components/my-form/fields/text.tsx
"use client";

import { useDataField } from "@buildnbuzz/form-react";
import type { TextField as TextFieldDef } from "@buildnbuzz/form-react";

export function MyTextField() {
  const {
    fieldApi,
    field,
    handleChange,
    handleBlur,
    label,
    placeholder,
    description,
    isDisabled,
    isReadOnly,
    isRequired,
    isInvalid,
    errors,
    ariaDescribedBy,
    descriptionId,
    errorId,
  } = useDataField<TextFieldDef>();

  const value = (fieldApi.state.value as string) ?? "";

  return (
    <div className="space-y-2">
      {label && (
        <label htmlFor={fieldApi.name} className="block font-medium text-sm">
          {label}
          {isRequired && <span className="text-red-500 ml-1">*</span>}
        </label>
      )}

      <input
        id={fieldApi.name}
        name={fieldApi.name}
        type="text"
        value={value}
        onChange={(e) => handleChange(e.target.value)}
        onBlur={handleBlur}
        placeholder={placeholder}
        disabled={isDisabled}
        readOnly={isReadOnly}
        aria-invalid={isInvalid}
        aria-describedby={ariaDescribedBy}
        className="w-full px-3 py-2 border rounded-md"
      />

      {description && !isInvalid && (
        <p id={descriptionId} className="text-sm text-gray-500">
          {description}
        </p>
      )}

      {isInvalid && errors.length > 0 && (
        <p id={errorId} className="text-sm text-red-500">
          {errors[0]}
        </p>
      )}
    </div>
  );
}

Step 2: Register in Your Registry

Add your custom component to the registry:

components/my-form/registry.ts
"use client";

import type { FieldRegistry } from "@buildnbuzz/form-react";
import { MyTextField } from "./fields/text";

export const registry: FieldRegistry = {
  text: MyTextField,
  // ... add more custom field components
};

Step 3: Provide the Registry

Pass your custom registry to FormProvider:

app/layout.tsx
import { FormProvider } from "@buildnbuzz/form-react";
import { registry } from "@/components/my-form/registry";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <FormProvider registries={{ fields: registry }}>{children}</FormProvider>
      </body>
    </html>
  );
}

That's it — your custom text field now renders for every type: "text" field in any schema.

Hooks Reference

useDataField

The primary hook for data field components. Returns everything you need:

PropertyTypeDescription
fieldApiFieldApiTanStack field API (state, value, meta)
fieldFieldThe raw field schema definition
formDataobjectCurrent form values
handleChange(value) => voidChange handler (includes trim, transforms)
handleBlur() => voidBlur handler (triggers validation)
labelReactNode | undefinedResolved label (supports $data/$context)
descriptionReactNode | undefinedResolved description
placeholderstring | undefinedResolved placeholder
isDisabledbooleanWhether field is disabled
isReadOnlybooleanWhether field is read-only
isRequiredbooleanWhether field is required
isHiddenbooleanWhether field is hidden
isInvalidbooleanWhether field has errors
errorsstring[]Array of error messages
ariaDescribedBystringCombined value for aria-describedby
descriptionIdstringID for description element
errorIdstringID for error element
contextDataobjectExternal context data

useLayoutField

For layout/container components (row, group, tabs, collapsible):

PropertyTypeDescription
fieldFieldThe raw field schema definition
formFormApiTanStack form instance
labelReactNode | undefinedResolved label
descriptionReactNode | undefinedResolved description
isHiddenbooleanWhether field is hidden
isDisabledbooleanWhether field is disabled
isConditionMetbooleanWhether visibility condition is met

useFieldOptions

For fields with options (select, radio, checkbox group):

import { useDataField, useFieldOptions } from "@buildnbuzz/form-react";

function MySelectField() {
  const { fieldApi, handleChange, field } = useDataField();
  const { options } = useFieldOptions(field.options);

  return (
    <select
      value={fieldApi.state.value ?? ""}
      onChange={(e) => handleChange(e.target.value)}
    >
      {options.map((opt) => (
        <option key={opt.value} value={opt.value} disabled={opt.disabled}>
          {opt.label}
        </option>
      ))}
    </select>
  );
}

useNestedErrorCount

For container fields that show error badges:

import { useLayoutField, useNestedErrorCount } from "@buildnbuzz/form-react";

function MyGroupField({ children }: { children?: React.ReactNode }) {
  const { field } = useLayoutField();
  const errorCount = useNestedErrorCount(field.fields);

  return (
    <div className="border p-4 rounded">
      <div className="flex items-center gap-2 mb-4">
        <h3>{field.label}</h3>
        {errorCount > 0 && (
          <span className="bg-red-500 text-white text-xs px-2 py-1 rounded">
            {errorCount} errors
          </span>
        )}
      </div>
      {children}
    </div>
  );
}

Best Practices

Always Use handleChange and handleBlur

Use the handlers from useDataField, not the raw fieldApi methods. BuzzForm's handlers include trimming and transforms:

// ✅ Good — uses BuzzForm's handlers
const { handleChange, handleBlur } = useDataField();
<input onChange={(e) => handleChange(e.target.value)} onBlur={handleBlur} />

// ❌ Bad — bypasses trim and future transforms
const { fieldApi } = useDataField();
<input onChange={(e) => fieldApi.handleChange(e.target.value)} onBlur={fieldApi.handleBlur} />

Type Your Field Definitions

Import the specific field type for better autocomplete:

import type { SelectField as SelectFieldDef } from "@buildnbuzz/form-react";

function MySelectField() {
  const { field } = useDataField<SelectFieldDef>();
  // field.options, field.hasMany, etc. are now typed
}

Use the ui Property for Custom Config

Pass UI-specific config via the opaque ui property in your schema:

// In your schema
const schema = defineSchema({
  fields: [
    {
      type: "text",
      name: "username",
      ui: {
        icon: "user",
        variant: "outlined",
      },
    },
  ],
});

// In your renderer
interface TextUi {
  icon?: string;
  variant?: "outlined" | "filled";
}

function MyTextField() {
  const { field } = useDataField();
  const ui = field.ui as TextUi | undefined;
  // Use ui.icon, ui.variant for rendering
}

Example: Complete Select Field with Radix

"use client";

import * as Select from "@radix-ui/react-select";
import { useDataField, useFieldOptions } from "@buildnbuzz/form-react";
import type { SelectField as SelectFieldDef } from "@buildnbuzz/form-react";

export function RadixSelectField() {
  const {
    fieldApi,
    field,
    handleChange,
    handleBlur,
    label,
    isDisabled,
    isRequired,
    isInvalid,
    errors,
    errorId,
  } = useDataField<SelectFieldDef>();

  const { options } = useFieldOptions(field.options);
  const value = (fieldApi.state.value as string) ?? "";

  return (
    <div className="space-y-2">
      {label && (
        <label className="block font-medium text-sm">
          {label}
          {isRequired && <span className="text-red-500 ml-1">*</span>}
        </label>
      )}

      <Select.Root
        value={value}
        onValueChange={handleChange}
        disabled={isDisabled}
      >
        <Select.Trigger
          onBlur={handleBlur}
          aria-invalid={isInvalid}
          className="w-full px-3 py-2 border rounded-md"
        >
          <Select.Value placeholder="Select..." />
        </Select.Trigger>
        <Select.Portal>
          <Select.Content className="bg-white border rounded-md shadow-lg">
            <Select.Viewport>
              {options.map((opt) => (
                <Select.Item
                  key={opt.value}
                  value={opt.value}
                  disabled={opt.disabled}
                  className="px-3 py-2 hover:bg-gray-100 cursor-pointer"
                >
                  <Select.ItemText>{opt.label}</Select.ItemText>
                </Select.Item>
              ))}
            </Select.Viewport>
          </Select.Content>
        </Select.Portal>
      </Select.Root>

      {isInvalid && errors.length > 0 && (
        <p id={errorId} className="text-sm text-red-500">{errors[0]}</p>
      )}
    </div>
  );
}

Register it:

export const registry: FieldRegistry = {
  select: RadixSelectField,
};

Unregistered Custom Field Types

Sometimes, a custom component doesn't fit into the standard built-in data types (like text, select, etc.). BuzzForm allows you to declare arbitrary, unregistered field types directly in your schemas:

1. Declare in the Schema

Any unrecognized type string will be allowed by defineSchema. It will trigger a lightweight schema validation warning instead of a hard crash:

export const themeSchema = defineSchema({
  fields: [
    { type: "text", name: "themeName", label: "Theme Name", required: true },
    {
      type: "color-picker", // Custom/unregistered field type
      name: "primaryColor",
      label: "Primary Color",
      defaultValue: "#000000",
    },
  ],
});

2. Implement the Component

Create a custom component. Since color-picker is unregistered, we do not have a built-in TypeScript interface for it. Simply call useDataField without a generic type parameter (or write your own custom interface extending CustomFieldInput):

components/my-form/fields/color-picker.tsx
"use client";

import { useDataField } from "@buildnbuzz/form-react";

export function ColorPickerField() {
  const { fieldApi, label, handleChange } = useDataField();
  const value = (fieldApi.state.value as string) ?? "#000000";

  return (
    <div className="flex items-center gap-2">
      {label && <label className="text-sm font-medium">{label}</label>}
      <input
        type="color"
        value={value}
        onChange={(e) => handleChange(e.target.value)}
        className="h-8 w-8 rounded cursor-pointer border-0"
      />
    </div>
  );
}

3. Register the Custom Type

Register your component with the matching type string in the component registry:

components/my-form/registry.ts
import { ColorPickerField } from "./fields/color-picker";

export const registry = {
  // ... standard overrides
  "color-picker": ColorPickerField, // Wire the unregistered type
};

On this page