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
- You write a React component that receives
CustomFieldProps. - A plugin registers it under a type string via
registerFields(). - 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. customValidatorsfrom 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, andCustomFieldConfigare exported.fieldConfigis typed asAnyFieldConfig— cast to your own shape for bespoke props (seeStepperConfigabove).- Custom types stay out of the
FieldConfigdiscriminated union on purpose;Fieldsaccepts 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
FieldBuildermethods don’t exist for your props; use.prop(key, value)or raw config.