@mackehansson/schemaform

Renderer Authoring

Build a custom renderer against Core's resolved view model and state boundary.

Most applications should start with schemaform, the first-party Renderer built on ShadCN and React Hook Form. It is also the reference implementation for third-party renderers: it shows how a concrete component stack consumes Core while keeping form state in the renderer layer.

Build a custom Renderer when you need a different stack such as MUI + Formik, Radix + Final Form, a design-system-native component library, or a non-ShadCN product shell.

The boundary

Core is pure and framework-agnostic. It knows about JSON Schema, UI Schema, Rules, validation, default widgets, resolved layout, and submission projection. It does not store input values, subscribe to fields, focus invalid controls, render components, or know which form library you use.

A Renderer owns the mutable Working State. It passes that state into Core, receives a resolved view model and validation results, and turns them into concrete components.

import {
  evaluateRules,
  resolveForm,
  validate,
  type FormDefinition,
  type WorkingState,
} from "@mackehansson/schemaform";

export function resolveForRender(
  definition: FormDefinition,
  data: WorkingState,
) {
  const directives = evaluateRules(definition.rules, data);
  const viewModel = resolveForm(definition, data, directives);
  const errors = validate(definition.schema, data);

  return { viewModel, errors };
}

Most React renderers should use useFormRenderer from schemaform instead of calling those Core functions directly. The hook wraps the same boundary and adds page navigation, error reveal state, custom validators, and cross-page error summaries while still leaving field values in your form library.

What Core gives you

resolveForm returns a FormViewModel:

FieldUse
pagesRender visible Pages and navigation
pages[].childrenRender the layout tree in UI Schema order
pages[].controlsFind visible leaf Controls on a Page
pages[].arraysFind visible Array repeater nodes on a Page
allControlsProject the Submission Payload, including hidden retained fields
allArraysProject array values in the Submission Payload

Resolved Controls include the renderer-facing details you need:

type ResolvedControl = {
  type: "Control";
  scope: string;
  path: string;
  pointer: string;
  label: string;
  widget: string;
  meta?: SchemaformMeta;
  schema: JsonSchema;
  hidden: boolean;
  retainWhenHidden: boolean;
  disabled: boolean;
  required: boolean;
};

Use path for your form library's field name when dot paths are a fit. Use pointer for validation errors and hook helpers such as errorsFor(pointer). Use widget to pick a concrete component from the Renderer-owned Registry. Use meta for renderer-owned presentation data that your app added to the UI Schema.

Renderer responsibilities

A custom Renderer must implement four pieces:

ResponsibilityRenderer owns
State wiringStore field values in RHF, Formik, Final Form, local state, or another form library
RegistryMap widget names such as text, textarea, select, and custom names to components
Layout renderingWalk resolved Pages, Groups, Columns, Controls, and Arrays in UI Schema order
BehaviorHonor hidden, disabled, required, validation errors, pending async validators, navigation, and Submission Payload projection

The important point: do not copy the JSON Schema directly into inputs and skip the resolved model. Rules and layout decisions are already reflected in the resolved view model.

A minimal React renderer shape

This sketch uses useFormRenderer with local state. A production Renderer would usually plug the same contract into a form library.

import { useState } from "react";
import type { FormDefinition, ResolvedControl, WorkingState } from "@mackehansson/schemaform";
import { toSubmissionPayload } from "@mackehansson/schemaform";
import { useFormRenderer } from "@mackehansson/schemaform";

type WidgetProps = {
  control: ResolvedControl;
  value: unknown;
  errors: string[];
  disabled: boolean;
  required: boolean;
  onBlur: () => void;
  onChange: (value: unknown) => void;
};

type WidgetRegistry = Record<string, React.ComponentType<WidgetProps>>;

export function MyRenderer({
  definition,
  registry,
  onSubmit,
}: {
  definition: FormDefinition;
  registry: WidgetRegistry;
  onSubmit: (payload: WorkingState) => void;
}) {
  const [data, setData] = useState<WorkingState>({});
  const form = useFormRenderer({ definition, data });

  function renderControl(control: ResolvedControl) {
    const Widget = registry[control.widget];
    if (Widget === undefined) {
      throw new Error(`No widget named "${control.widget}"`);
    }

    return (
      <Widget
        key={control.pointer}
        control={control}
        value={getValue(data, control.path)}
        errors={form.errorsFor(control.pointer).map((error) => error.message)}
        disabled={control.disabled}
        required={control.required}
        onBlur={() => form.onFieldBlur(control.pointer)}
        onChange={(value) =>
          setData((current) => setValue(current, control.path, value))
        }
      />
    );
  }

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        form.revealAllErrors();

        if (!form.isValid) return;

        onSubmit(toSubmissionPayload(data, form.viewModel));
      }}
    >
      {form.currentPage.children.map((node) => {
        if (node.hidden) return null;
        if (node.type === "Control") return renderControl(node);
        return null;
      })}

      <button type="submit" disabled={form.hasPendingValidators}>
        Submit
      </button>
    </form>
  );
}

The omitted getValue and setValue helpers are form-library glue. In a Formik renderer they would become values, setFieldValue, and setFieldTouched. In an RHF renderer they become register, Controller, watch, and setValue.

Render the layout tree

Use currentPage.children or viewModel.pages[index].children for rendering, not only the flat controls list. The children tree preserves UI Schema presentation:

  • Control renders one JSON Schema property through the Registry
  • Group renders a titled section and recursively renders its children
  • Columns renders column layout and recursively renders its children
  • Array renders a repeater and its rowTemplate

Array row templates use scopes relative to the item schema. Use resolveRowControl(array, rowIndex, controlNode) from Core to turn a row-template Control into an indexed ResolvedControl.

import { resolveRowControl, type ResolvedArray } from "@mackehansson/schemaform";

function renderArray(array: ResolvedArray, rowCount: number) {
  return Array.from({ length: rowCount }, (_, rowIndex) =>
    array.rowTemplate.map((node) => {
      if (node.type !== "Control") return null;
      const control = resolveRowControl(array, rowIndex, node);
      return renderControl(control);
    }),
  );
}

Rules are already applied to resolved nodes. If a node is hidden, skip it. If a Control is disabled, render an inert input but keep its value in Working State. If a Control is required, mark the component and validation UX accordingly.

Build a registry

A Registry maps widget names to concrete field components. Core derives a default widget name when a Control does not name one; the Renderer resolves that final name through its Registry.

const registry = {
  text: TextField,
  textarea: TextareaField,
  number: NumberField,
  integer: NumberField,
  checkbox: CheckboxField,
  select: SelectField,
  radio: RadioField,
  multiselect: MultiselectField,
  date: DateField,
} satisfies WidgetRegistry;

Your widget components should receive the resolved Control, field value, field errors, blur/change handlers, disabled, required, and any renderer-specific helpers. Custom widgets are just more Registry entries.

If your app uses authoring metadata, read it from the resolved control:

function TextWidget({ control }: { control: ResolvedControl }) {
  return (
    <label>
      {control.label}
      {control.meta?.iconText && <span>{control.meta.iconText}</span>}
    </label>
  );
}

Submit the right payload

The Working State includes values for currently hidden fields. On submit, use toSubmissionPayload(data, viewModel) so hidden fields are stripped unless their Control or Array opted into retainWhenHidden.

import { toSubmissionPayload } from "@mackehansson/schemaform";

const payload = toSubmissionPayload(data, form.viewModel);

Then validate and submit according to your renderer's flow. With useFormRenderer, form.isValid includes visible schema errors, custom validator errors, and pending async validators.

Reference implementation

Use schemaform as the practical reference:

  • React Hook Form owns Working State
  • useFormRenderer resolves Pages, Controls, validation, navigation, and reveal state
  • the ShadCN Registry maps widget names to components
  • widgets render field values, errors, pending state, and translation
  • submit calls toSubmissionPayload before invoking onSubmit

That architecture is the contract to copy, even when every component and form-library call changes.

On this page