@mackehansson/schemaform

Extension Points

Where to plug in custom widgets, validators, renderers, persistence, submission behavior, and builder UI.

Most Schemaform customization falls into one of two buckets:

  • change the Form Definition when you are modeling data, presentation, or conditional behavior
  • change app code when you need components, side effects, storage, network calls, or product-specific behavior

The Form Definition should stay declarative. Your custom code plugs in around it.

Where custom code plugs in
Registry
Custom widgets and built-in widget overrides.
Custom validators
Async checks, backend lookups, and business rules.
Renderer
Different component libraries, form libraries, and layout shells.
Builder UI
Admin workflows, templates, catalogs, and approval flows.
App persistence
Load, save, import, export, and version Form Definitions.
Submit handler
APIs, redirects, analytics, workflows, and domain object creation.
Keep Form Definitions declarative. Put executable product behavior in these app-owned extension points.

Where do I put custom code?

I want to...Extension pointWhy
Render a field with a custom componentRegistry widgetSame data shape, different presentation
Override the built-in input stylingRegistry widget overrideRenderer owns concrete components
Add server-side validationcustom validatorValidation needs app code or network access
Translate labels and errorstranslation hook/functionText resolution belongs at the renderer boundary
Use MUI, Chakra, Radix, or my design systemcustom RendererComponent stack is renderer-owned
Save forms to my databaseapp persistenceSchemaform does not own storage
Submit data to my APIonSubmit handlerSchemaform produces the payload; your app sends it
Add a new admin workflowcustom builder UI or builder hookProduct workflow sits above Core mutations
Add conditional visibility or required stateRulesBehavior belongs in the persisted definition
Add a field type to the builder paletteRegistry entryThe palette is derived from widget names
Support a JSON Schema feature outside the renderable subsetCore/renderability extensionThe engine must learn how to resolve it
Add a new rule effectCore Rules Engine plus Renderer supportEffects must resolve and render consistently

Custom field UI

Use a custom widget when the submitted data shape stays the same but the field should render differently.

import {
  FieldShell,
  SchemaForm,
  defaultRegistry,
  useRegisteredField,
  type WidgetProps,
  type WidgetRegistry,
} from "@mackehansson/schemaform";

function ColorSwatchWidget(props: WidgetProps) {
  const { invalid, inputProps } = useRegisteredField(props);

  return (
    <FieldShell {...props} htmlFor={props.control.path}>
      <input
        type="color"
        {...inputProps}
        className={invalid ? "border-destructive" : "border-input"}
      />
    </FieldShell>
  );
}

const registry = {
  ...defaultRegistry,
  "color-swatch": ColorSwatchWidget,
} satisfies WidgetRegistry;

<SchemaForm definition={definition} registry={registry} onSubmit={console.log} />;

Then point a Control at the widget:

{
  type: "Control",
  scope: "#/properties/accentColor",
  widget: "color-swatch",
}

Use Widgets & Registry for the full widget contract.

Custom validation

Use JSON Schema for constraints that are local and declarative: required fields, formats, lengths, ranges, enums, and object structure.

Use custom validators when validation needs app code:

  • checking whether a username is available
  • asking an API whether an account number is valid
  • enforcing tenant-specific business rules
  • comparing against data that is not inside the form
<SchemaForm
  definition={definition}
  customValidators={{
    "#/properties/username": async (value) => {
      if (typeof value !== "string") return null;

      const available = await isUsernameAvailable(value);
      return available ? null : "Username is already taken";
    },
  }}
  onSubmit={saveProfile}
/>;

Custom validators run at the hook/renderer boundary. They should return messages, not mutate the Form Definition.

Read Custom Validators for pending states, async behavior, and page navigation.

Translation

Use translation when text changes by locale, tenant, or product context, but the form structure stays the same.

<SchemaForm
  definition={definition}
  translate={(key, fallback) => t(key, { defaultValue: fallback })}
  onSubmit={onSubmit}
/>;

Translation belongs in renderer code because it depends on runtime application context. The Form Definition can hold stable labels, message keys, or fallback text; your app decides how those become localized strings.

Read Internationalization for the translate contract.

Persistence

Schemaform does not choose where Form Definitions live. Store them wherever your product stores configuration:

  • database rows
  • versioned JSON files
  • CMS entries
  • tenant settings
  • imported/exported documents
async function saveDefinition(formId: string, definition: FormDefinition) {
  await api.forms.update(formId, { definition });
}

async function loadDefinition(formId: string) {
  const record = await api.forms.get(formId);
  return record.definition;
}

Persist the whole envelope:

{
  formatVersion: 1,
  schema,
  uiSchema,
  rules,
}

Do not store renderer-only runtime state inside the definition. Working State, touched fields, pending validators, current page, and submit status belong in the runtime UI.

Read Persistence & Versioning for the envelope and versioning model.

Submit behavior

Use onSubmit for product behavior after Schemaform has produced a Submission Payload.

<SchemaForm
  definition={definition}
  onSubmit={async (payload) => {
    await api.applications.create(payload);
    navigate("/thanks");
  }}
/>;

The payload is not the same as raw Working State. Hidden fields are stripped unless retained, and array/control visibility has already been interpreted by the resolved view model.

Keep workflow behavior outside the Form Definition:

  • API calls
  • redirects
  • toast messages
  • analytics
  • email triggers
  • domain object creation

That keeps definitions portable and keeps executable behavior in app-owned code.

Custom renderer

Build a custom Renderer when changing individual widgets is not enough.

Good reasons:

  • your app uses a different component library
  • your form library is not React Hook Form
  • layout must match a product-specific shell
  • accessibility, focus, or error UX needs deep control
  • you are rendering outside React DOM

A Renderer consumes useFormRenderer, walks the resolved view model, and maps resolved Controls to widgets.

definition + data
  -> useFormRenderer
  -> currentPage.children
  -> render nodes
  -> registry[control.widget]

Read Renderer Authoring before building one. The main rule is to render from the resolved model, not directly from raw JSON Schema.

Custom builder UI

Use the ShadCN <FormBuilder /> when its admin workflow fits. Build your own builder UI when your product needs a different authoring experience:

  • a wizard for non-technical users
  • locked templates with only a few editable fields
  • approval workflows
  • product-specific field catalogs
  • custom import or migration screens

The builder UI should call the builder hook and Core mutation functions rather than hand-editing scattered pieces of the definition.

Your builder screen
  -> useFormBuilder
  -> add field / move node / update schema / update rule
  -> updated Form Definition
  -> save

Read Form Builder for the admin surface and builder hook.

Conditional behavior

Use Rules when behavior should be stored with the form and replayed anywhere the form renders.

{
  condition: { "==": [{ var: "applicantType" }, "Company"] },
  effect: "show",
  target: "#/properties/companyName",
}

Use app code when behavior depends on external side effects or product workflow:

BehaviorUse
show this field when another value is "Company"Rules
require a page only in one branchRules
redirect after submitapp code
call an API when a field changeswidget or renderer code
change available options from server datawidget or renderer code

Read Rules Engine for available effects and condition modeling.

Extending Core

Extend Core only when the form engine needs a new shared capability. This is a deeper change than adding a widget.

Examples:

  • a new UI Schema node type
  • a new Rule effect
  • a new renderable JSON Schema construct
  • a new submission projection rule
  • a new definition migration

When extending Core, update the full path:

  1. domain types
  2. resolver or Rules Engine behavior
  3. renderability checks if schema support changes
  4. React hook surface if renderers need new state
  5. ShadCN renderer/builder support
  6. docs and tests

This keeps Form Builder output and Form Renderer behavior aligned.

Choosing the right extension point

Ask this first: does the change describe the form, or does it execute product code?

If the change describes...Put it in...
valid submitted dataJSON Schema
visual arrangementUI Schema
conditional form behaviorRules
concrete field renderingRegistry/widget
runtime validation with side effectscustom validators
storage, APIs, auth, redirects, analyticsyour app
a different component/form stackcustom Renderer
a different admin authoring workflowcustom builder UI

That boundary is the main architectural rule: definitions are portable descriptions; extension points are where application code enters the system.

On this page