Skip to Content
Prism UI
Forms

Custom Fields

Writing and registering a field component that isn't in Prism's own catalog.

A field component's only real obligation

A field component accepts Prism's normalized FieldComponentProps<TValue, TExtraProps> and forwards field.value/field.onChange/field.onBlur/field.error to whatever it renders. It never imports react-hook-form, and it can wrap any UI library, not only Prism's own Layer 1 primitives:

import type { FieldComponentProps } from '@prismrepo/forms';

// A field component wrapping a plain native <input>, no Prism primitive at all.
export function PlainTextField({
  field,
  label,
}: FieldComponentProps<string, { readonly label: string }>) {
  return (
    <label>
      {label}
      <input
        value={field.value}
        onChange={(e) => field.onChange(e.target.value)}
        onBlur={field.onBlur}
        aria-invalid={field.error !== undefined}
      />
      {field.error ? <span role="alert">{field.error}</span> : null}
    </label>
  );
}

A field component with required extra props

A custom field can declare whatever extra props it needs, and defineForm requires them at the configuration site:

export function RatingField({
  field,
  max = 5,
}: FieldComponentProps<number, { readonly max?: number }>) {
  return (
    <div role="radiogroup">
      {Array.from({ length: max }, (_, i) => i + 1).map((n) => (
        <button
          key={n}
          type="button"
          aria-pressed={field.value === n}
          onClick={() => field.onChange(n)}
        >
          {n <= field.value ? '★' : '☆'}
        </button>
      ))}
      {field.error ? <span role="alert">{field.error}</span> : null}
    </div>
  );
}

Field registration

Registering a custom field is identical to registering a catalog field, there's no distinction the type system enforces between "Prism's" and "the consumer's":

import { defineRegistry } from '@prismrepo/forms';
import { TextField } from './text-field'; // CLI-installed
import { RatingField } from './rating-field'; // consumer-authored

export const registry = defineRegistry({
  text: TextField,
  rating: RatingField,
});
const definition = createFormBuilder(registry).defineForm<ReviewValues>({
  comment: { type: 'text', label: 'Comment' },
  rating: { type: 'rating', max: 5 },
});

One inference pitfall to know about

Annotate the parameter with FieldComponentProps<...> written out inline, as in both examples above, not through a separately-declared type Props = FieldComponentProps<...> used as the annotation. Doing so silently breaks createFormBuilder's ability to infer that component's extra props, widening them to include field itself and defeating the type-checked configuration described in Form Engine Overview.

If you want to export a named Props type anyway, derive it from the function afterward:

export function RatingField({ field, max = 5 }: FieldComponentProps<number, { max?: number }>) {
  /* ... */
}

/** Derived from RatingField itself, not a separate declaration. */
export type RatingFieldProps = Parameters<typeof RatingField>[0];

Every field template the CLI installs (text-field.tsx, number-field.tsx, and the rest) follows exactly this pattern.

On this page