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.

This guide covers building custom UI components for BuzzForm's existing field types (text, email, select, etc.). The set of supported field types is defined by @buildnbuzz/form-core and cannot be extended at this time.

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-core";

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 registry={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)
labelstring | undefinedResolved label (supports $data/$context)
descriptionstring | 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
labelstring | undefinedResolved label
descriptionstring | 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-core";

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-core";

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,
};

On this page