Custom Fields

Register your own field types via plugins — full access to the DI registry, conditional logic, and the FieldWrapper scaffolding (label, errors, helper text) that built-in fields use.

Custom Fields

When the 34 built-in field types aren’t enough — a numeric stepper, a signature pad, a rating widget — you can register your own field types through the plugin system. Custom fields are first-class citizens: they live in the same config, participate in validation and conditional logic, and receive the same rendering context as built-in fields.

How it works

  1. You write a React component that receives CustomFieldProps.
  2. A plugin registers it under a type string via registerFields().
  3. Any field config using that type string renders your component.
import { definePlugin } from '@saastro/forms';
import { StepperField } from './StepperField';

export const stepperPlugin = definePlugin({
  name: 'stepper-field',
  version: '1.0.0',
  registerFields: () => ({
    stepper: StepperField, // type string → component
  }),
});

What your component receives

Your component gets the full renderer context — the same one built-in fields use:

import type { CustomFieldProps } from '@saastro/forms';

function MyField(props: CustomFieldProps) {
  props.name; //            field name (react-hook-form path)
  props.fieldConfig; //     your config, with any bespoke props you added
  props.control; //         react-hook-form control
  props.colSpanItem; //     pre-computed wrapper class (responsive col-span)
  props.components; //      the DI component registry (Button, Input, ...)
  props.shouldDisable; //   `disabled` already evaluated (boolean | fn | ConditionGroup)
  props.shouldReadOnly; //  `readOnly` already evaluated
  props.renderLabel; //     renders the label (HTML + tooltip support)
  props.renderFieldIcon; // renders the configured icon
}

Conditional hidden is handled for you before your component renders — a hidden custom field is simply not mounted.

Complete example: a stepper field

A quantity stepper with −/+ buttons, composed with the exported FieldWrapper so label, helper text, and error messages work exactly like built-in fields:

import { FieldWrapper, type CustomFieldProps } from '@saastro/forms';

type StepperConfig = {
  min?: number;
  max?: number;
  step?: number;
};

export function StepperField(props: CustomFieldProps) {
  const { name, fieldConfig, control, colSpanItem } = props;
  const { components, shouldDisable, renderLabel, renderFieldIcon } = props;

  const { Button } = components;
  const { min = 0, max = Infinity, step = 1 } = fieldConfig as StepperConfig;

  return (
    <FieldWrapper
      control={control}
      name={name}
      fieldConfig={fieldConfig}
      formItemClassName={colSpanItem}
      renderLabel={renderLabel}
      renderFieldIcon={renderFieldIcon}
    >
      {({ field }) => {
        const value = typeof field.value === 'number' ? field.value : min;
        return (
          <div className="flex items-center gap-3">
            <Button
              type="button"
              variant="outline"
              size="icon"
              disabled={shouldDisable || value <= min}
              onClick={() => field.onChange(value - step)}
              aria-label="Decrease"
            >

            </Button>
            <span className="min-w-8 text-center font-medium tabular-nums">{value}</span>
            <Button
              type="button"
              variant="outline"
              size="icon"
              disabled={shouldDisable || value >= max}
              onClick={() => field.onChange(value + step)}
              aria-label="Increase"
            >
              +
            </Button>
          </div>
        );
      }}
    </FieldWrapper>
  );
}

FieldWrapper gives you the standard field chrome: label (with tooltip support), FormControl wiring, helperText, and validation errors — all through the user’s injected components.

Using a custom field

Register the plugin and use the type string. FieldBuilder.type() accepts any string, and .prop() sets your bespoke config props:

import { Form, FormBuilder, PluginManager } from '@saastro/forms';
import { stepperPlugin } from './stepperPlugin';
import { z } from 'zod';

const pm = new PluginManager();
pm.register(stepperPlugin);

const config = FormBuilder.create('order')
  .usePlugins(pm)
  .addField('guests', (f) =>
    f
      .type('stepper')
      .label('Guests')
      .prop('min', 1)
      .prop('max', 10)
      .defaultValue(2)
      .schema(z.number().min(1)),
  )
  .addStep('main', ['guests'])
  .buttons({ submit: { type: 'submit', label: 'Send' } })
  .build();

<Form config={config} components={uiComponents} onSubmit={console.log} />;

Raw JSON config works too — bespoke props travel verbatim:

{
  "guests": {
    "type": "stepper",
    "label": "Guests",
    "min": 1,
    "max": 10,
    "defaultValue": 2
  }
}

Validation and defaults

  • Schema is optional for custom types: build() can’t know your value shape, so validation is opt-in. Pass a Zod schema (.schema(z.number().min(1))) or serializable rules when you want it.
  • Default values: the form seeds custom fields from value ?? defaultValue ?? ''. For non-string values (like the stepper’s number), set one of them explicitly.
  • customValidators from plugins work on custom fields as long as the field has a schema to chain onto.

Conditional logic

Everything from the Conditional Logic guide applies:

f.type('stepper')
  .label('Children')
  .prop('min', 0)
  .hidden({ operator: 'AND', conditions: [{ field: 'hasChildren', operator: 'equals', value: false }] })
  .schema(z.number())

hidden unmounts the field before your component runs; disabled/readOnly arrive pre-evaluated as shouldDisable/shouldReadOnly.

TypeScript notes

  • CustomFieldProps, FieldWrapperProps, and CustomFieldConfig are exported.
  • fieldConfig is typed as AnyFieldConfig — cast to your own shape for bespoke props (see StepperConfig above).
  • Custom types stay out of the FieldConfig discriminated union on purpose; Fields accepts both built-in and custom configs (AnyFieldConfig).

Limitations

  • The visual form builder doesn’t know about your custom types — custom fields are a code-level feature.
  • Type-specific FieldBuilder methods don’t exist for your props; use .prop(key, value) or raw config.