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:
| Field | Use |
|---|---|
pages | Render visible Pages and navigation |
pages[].children | Render the layout tree in UI Schema order |
pages[].controls | Find visible leaf Controls on a Page |
pages[].arrays | Find visible Array repeater nodes on a Page |
allControls | Project the Submission Payload, including hidden retained fields |
allArrays | Project 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:
| Responsibility | Renderer owns |
|---|---|
| State wiring | Store field values in RHF, Formik, Final Form, local state, or another form library |
| Registry | Map widget names such as text, textarea, select, and custom names to components |
| Layout rendering | Walk resolved Pages, Groups, Columns, Controls, and Arrays in UI Schema order |
| Behavior | Honor 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:
Controlrenders one JSON Schema property through the RegistryGrouprenders a titled section and recursively renders its childrenColumnsrenders column layout and recursively renders its childrenArrayrenders a repeater and itsrowTemplate
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
useFormRendererresolves 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
toSubmissionPayloadbefore invokingonSubmit
That architecture is the contract to copy, even when every component and form-library call changes.