Build a Form Renderer
A step-by-step guide to building your own form renderer using useFormRenderer.
Build your own form renderer using the useFormRenderer hook. By the end you will have a working component that renders Controls, Groups, Columns, and Repeaters; validates on blur and submit; navigates multi-page forms; and submits a clean payload with hidden fields stripped.
Each step adds one capability. Read the whole guide before copying code — later steps replace parts of earlier ones.
Prerequisites
Install schemaform:
npm install schemaform1. Wire up the hook
useFormRenderer is the entire rendering API. Pass it a FormDefinition and the current working state, and it returns the resolved page, validation helpers, and navigation state. It does not store field values — you do.
import { useState } from "react";
import type { FormDefinition, WorkingState } from "@mackehansson/schemaform";
import { useFormRenderer } from "@mackehansson/schemaform";
export function MyRenderer({
definition,
onSubmit,
}: {
definition: FormDefinition;
onSubmit: (data: WorkingState) => void;
}) {
const [data, setData] = useState<WorkingState>({});
const form = useFormRenderer({ definition, data });
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.revealAllErrors();
if (form.isValid) onSubmit(data);
}}
>
{/* page — next step */}
<button type="submit">Submit</button>
</form>
);
}form.currentPage is the resolved page for the current page index. form.isValid is true only when no visible field has an error and no async validator is in flight. form.revealAllErrors() reveals all validation messages — call it before checking isValid on submit.
Passing options
Two options let you override the error-reveal and navigation modes declared in the UI Schema. The prop beats the document:
const form = useFormRenderer({
definition,
data,
revealErrors: "onSubmit", // "onBlur" | "onSubmit"
navigationMode: "gated", // "free" | "gated"
});2. Build a widget registry
A registry maps widget names to concrete field components. useFormRenderer gives you a ResolvedControl for each visible field; your registry picks the right component.
Define the props shape your widgets receive, then build the registry:
import type { ResolvedControl } 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>>;Implement two starter widgets:
function TextField({
control,
value,
errors,
disabled,
required,
onBlur,
onChange,
}: WidgetProps) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
marginBottom: 12,
}}
>
<label htmlFor={control.path} style={{ fontSize: 13, fontWeight: 500 }}>
{control.label}
{required && <span style={{ color: "red", marginLeft: 2 }}>*</span>}
</label>
<input
id={control.path}
type="text"
value={String(value ?? "")}
disabled={disabled}
aria-invalid={errors.length > 0}
onBlur={onBlur}
onChange={(e) => onChange(e.target.value)}
style={{
border: errors.length > 0 ? "1px solid red" : "1px solid #d1d5db",
padding: "6px 10px",
borderRadius: 4,
}}
/>
{errors.map((msg, i) => (
<span key={i} style={{ color: "red", fontSize: 12 }}>
{msg}
</span>
))}
</div>
);
}
function SelectField({
control,
value,
errors,
disabled,
required,
onBlur,
onChange,
}: WidgetProps) {
const options = (control.schema.enum as string[] | undefined) ?? [];
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
marginBottom: 12,
}}
>
<label htmlFor={control.path} style={{ fontSize: 13, fontWeight: 500 }}>
{control.label}
{required && <span style={{ color: "red", marginLeft: 2 }}>*</span>}
</label>
<select
id={control.path}
value={String(value ?? "")}
disabled={disabled}
onBlur={onBlur}
onChange={(e) => onChange(e.target.value)}
style={{
border: errors.length > 0 ? "1px solid red" : "1px solid #d1d5db",
padding: "6px 10px",
borderRadius: 4,
}}
>
<option value="">— select —</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
{errors.map((msg, i) => (
<span key={i} style={{ color: "red", fontSize: 12 }}>
{msg}
</span>
))}
</div>
);
}
const registry: WidgetRegistry = {
text: TextField,
select: SelectField,
};Wire a renderControl helper into the renderer:
function renderControl(
control: ResolvedControl,
data: WorkingState,
form: ReturnType<typeof useFormRenderer>,
setData: React.Dispatch<React.SetStateAction<WorkingState>>,
) {
const Widget = registry[control.widget];
if (!Widget) throw new Error(`No widget for "${control.widget}"`);
return (
<Widget
key={control.pointer}
control={control}
value={data[control.path] ?? ""}
errors={form.errorsFor(control.pointer).map((e) => e.message)}
disabled={control.disabled}
required={control.required}
onBlur={() => form.onFieldBlur(control.pointer)}
onChange={(value) =>
setData((prev) => ({ ...prev, [control.path]: value }))
}
/>
);
}Use control.path for the field value key — it is a dot-path string like "address.street". Use control.pointer for errorsFor and onFieldBlur — it is a JSON Pointer like "/address/street".
form.errorsFor(pointer) returns the errors that are currently revealed for this field. The hook handles reveal timing (onBlur vs onSubmit) so your widgets never need to track touched state.
3. Render a flat page
Render the flat list of controls for the current page as a first pass. Layout containers are added in the next step.
export function MyRenderer({ definition, onSubmit }) {
const [data, setData] = useState<WorkingState>({});
const form = useFormRenderer({ definition, data });
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.revealAllErrors();
if (form.isValid) onSubmit(data);
}}
>
{form.currentPage.controls.map((control) =>
renderControl(control, data, form, setData),
)}
<button type="submit">Submit</button>
</form>
);
}form.currentPage.controls is the flat list of visible leaf Controls on the current page. Hidden controls are already excluded — the hook has applied rules before handing you the list.
4. Render the layout tree
Replace the flat controls loop with currentPage.children. The children tree preserves the UI Schema presentation order including Groups, Columns, and Arrays.
import type { ResolvedNode } from "@mackehansson/schemaform";
function RenderNode({
node,
data,
form,
setData,
}: {
node: ResolvedNode;
data: WorkingState;
form: ReturnType<typeof useFormRenderer>;
setData: React.Dispatch<React.SetStateAction<WorkingState>>;
}) {
if (node.hidden) return null;
if (node.type === "Control") {
return renderControl(node, data, form, setData);
}
if (node.type === "Group") {
return (
<fieldset
key={node.id ?? node.type}
style={{
border: "1px solid #e5e7eb",
padding: 16,
borderRadius: 6,
marginBottom: 12,
}}
>
{node.title && (
<legend style={{ fontWeight: 600, padding: "0 4px" }}>
{node.title}
</legend>
)}
{node.children.map((child, i) => (
<RenderNode
key={i}
node={child}
data={data}
form={form}
setData={setData}
/>
))}
</fieldset>
);
}
if (node.type === "Columns") {
return (
<div
key={node.id ?? node.type}
style={{ display: "flex", gap: 16, marginBottom: 12 }}
>
{node.children.map((child, i) => (
<div key={i} style={{ flex: 1 }}>
<RenderNode
node={child}
data={data}
form={form}
setData={setData}
/>
</div>
))}
</div>
);
}
// Arrays — covered in Step 6
return null;
}Update the renderer to walk children:
{
form.currentPage.children.map((node, i) => (
<RenderNode key={i} node={node} data={data} form={form} setData={setData} />
));
}Never render directly from definition.uiSchema. Rules are already applied to the resolved nodes — a hidden node has hidden: true and should be skipped. A disabled Control has disabled: true. Rendering from the raw definition bypasses those decisions.
5. Add page navigation
Multi-page forms need Previous / Next / Submit buttons. The hook tracks pageIndex, pageCount, isFirstPage, and isLastPage:
function PageNav({
form,
onFinalSubmit,
}: {
form: ReturnType<typeof useFormRenderer>;
onFinalSubmit: () => void;
}) {
function handleNext() {
const invalidPointer = form.goNext();
if (invalidPointer !== null) {
// Gated mode: navigation was blocked; focus the first invalid field.
document
.querySelector<HTMLElement>(`[id="${invalidPointer.replace("/", "")}"]`)
?.focus();
}
}
return (
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
{!form.isFirstPage && (
<button type="button" onClick={form.goBack}>
← Back
</button>
)}
{!form.isLastPage && (
<button type="button" onClick={handleNext}>
Next →
</button>
)}
{form.isLastPage && (
<button type="button" onClick={onFinalSubmit}>
Submit
</button>
)}
</div>
);
}goNext() advances the page and returns null on success. In "gated" navigation mode it returns the JSON Pointer of the first invalid field instead of navigating, so you can focus it. In "free" mode it always advances.
Add a page stepper to show which page the user is on:
function PageStepper({ form }: { form: ReturnType<typeof useFormRenderer> }) {
return (
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
{Array.from({ length: form.pageCount }, (_, i) => {
const errorCount = form.pageErrorCount(i);
return (
<button
key={i}
type="button"
onClick={() => form.goToPage(i)}
style={{
fontWeight: form.pageIndex === i ? "bold" : "normal",
position: "relative",
}}
>
{form.currentPage.title ?? `Page ${i + 1}`}
{errorCount > 0 && (
<span style={{ color: "red", fontSize: 11, marginLeft: 4 }}>
({errorCount})
</span>
)}
</button>
);
})}
</div>
);
}form.pageErrorCount(index) returns the count of currently-revealed errors on the given page — useful for badging tabs or steps.
Update the renderer to use both:
export function MyRenderer({ definition, onSubmit }) {
const [data, setData] = useState<WorkingState>({});
const form = useFormRenderer({ definition, data });
function handleSubmit() {
form.revealAllErrors();
if (form.isValid) onSubmit(data);
}
return (
<div>
{form.pageCount > 1 && <PageStepper form={form} />}
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
{form.currentPage.children.map((node, i) => (
<RenderNode
key={i}
node={node}
data={data}
form={form}
setData={setData}
/>
))}
<PageNav form={form} onFinalSubmit={handleSubmit} />
</form>
</div>
);
}6. Render repeaters
Array nodes appear in currentPage.children as type: "Array". Each Array has a rowTemplate — a list of nodes that describe one row. Use resolveRowControl from schemaform to turn a row-template Control into an indexed ResolvedControl with the right scope, path, and pointer for that row:
import { resolveRowControl, type ResolvedArray } from "@mackehansson/schemaform";
function ArrayNode({
array,
data,
form,
setData,
}: {
array: ResolvedArray;
data: WorkingState;
form: ReturnType<typeof useFormRenderer>;
setData: React.Dispatch<React.SetStateAction<WorkingState>>;
}) {
// Derive the current row count from working state.
const rows = (data[array.path] as unknown[] | undefined) ?? [];
function addRow() {
setData((prev) => ({
...prev,
[array.path]: [...((prev[array.path] as unknown[]) ?? []), {}],
}));
}
function removeRow(rowIndex: number) {
setData((prev) => {
const next = [...((prev[array.path] as unknown[]) ?? [])];
next.splice(rowIndex, 1);
return { ...prev, [array.path]: next };
});
}
return (
<div style={{ marginBottom: 16 }}>
<p style={{ fontWeight: 600, marginBottom: 8 }}>{array.label}</p>
{rows.map((_, rowIndex) => (
<div
key={rowIndex}
style={{
border: "1px solid #e5e7eb",
borderRadius: 6,
padding: 12,
marginBottom: 8,
position: "relative",
}}
>
<span
style={{
fontSize: 12,
color: "#6b7280",
marginBottom: 8,
display: "block",
}}
>
Row {rowIndex + 1}
</span>
{array.rowTemplate.map((templateNode, i) => {
if (templateNode.type !== "Control") return null;
const control = resolveRowControl(array, rowIndex, templateNode);
return renderControl(control, data, form, setData);
})}
<button
type="button"
onClick={() => removeRow(rowIndex)}
style={{ position: "absolute", top: 8, right: 8, fontSize: 12 }}
>
Remove
</button>
</div>
))}
<button type="button" onClick={addRow} style={{ fontSize: 13 }}>
+ Add row
</button>
</div>
);
}Add the Array case to RenderNode:
if (node.type === "Array") {
return (
<ArrayNode
key={node.scope}
array={node}
data={data}
form={form}
setData={setData}
/>
);
}resolveRowControl(array, rowIndex, templateNode) rewrites the scope, path, and pointer to be row-indexed. The resolved control.path is something like "contacts.0.name" and the control.pointer is "/contacts/0/name" — pass both to renderControl unchanged. Validation errors on array items are keyed by those indexed pointers and flow through errorsFor the same way leaf controls do.
7. Submit the correct payload
data (Working State) includes values for currently hidden fields. On submit, use toSubmissionPayload to strip them — unless the matching Control or Array opted into retainWhenHidden:
import { toSubmissionPayload } from "@mackehansson/schemaform";
function handleSubmit() {
form.revealAllErrors();
if (!form.isValid) return;
const payload = toSubmissionPayload(data, form.viewModel);
onSubmit(payload);
}Pass form.viewModel — it reflects the current visibility state including which arrays and controls are hidden by Rules.
8. Show an error summary
After a blocked submit, form.errorSummary gives a cross-page list of revealed errors. Render it above the form so the user can see all problems at once:
{
form.errorSummary.length > 0 && (
<div
role="alert"
style={{
border: "1px solid red",
borderRadius: 6,
padding: 12,
marginBottom: 16,
background: "#fff5f5",
}}
>
<p style={{ fontWeight: 600, marginBottom: 8 }}>
Please fix these errors:
</p>
<ul style={{ margin: 0, paddingLeft: 20 }}>
{form.errorSummary.map((entry, i) => (
<li key={i} style={{ fontSize: 13, marginBottom: 4 }}>
<button
type="button"
style={{
color: "#dc2626",
textDecoration: "underline",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
onClick={() => form.goToPage(entry.pageIndex)}
>
{entry.pageTitle ? `${entry.pageTitle}: ` : ""}
{entry.label}
</button>
{" — "}
{entry.message}
</li>
))}
</ul>
</div>
);
}errorSummary is only populated after revealAllErrors() is called. Each entry has pageIndex, pageTitle, label, pointer, and message. Clicking the label navigates to the right page.
9. Add custom validators
Pass validators to the hook to add server-side or app-specific validation. Keys are JSON Pointers. Validators can be sync or async:
import type { Validators } from "@mackehansson/schemaform";
const validators: Validators = {
"/username": async (value) => {
if (typeof value !== "string" || value.length === 0) return null;
const available = await checkUsernameAvailable(value);
return available ? null : "Username is already taken";
},
};
const form = useFormRenderer({ definition, data, validators });Define validators outside the component (or with useMemo) so the reference is stable. Async validators are race-safe — stale results from superseded data values are discarded automatically.
Disable the submit button while async validators are in flight:
<button type="submit" disabled={form.hasPendingValidators}>
{form.hasPendingValidators ? "Checking…" : "Submit"}
</button>form.hasPendingValidators is true while at least one visible field has an in-flight async validator. form.pendingPointers is the set of individual pointers in flight, useful for per-field pending indicators.
Custom validator errors flow through errorsFor the same way schema errors do. The widget does not need to know the error source.
What's next
You now have a working renderer. To take it further:
- Translations — pass a
translatefunction to resolve labels and error messages by locale; see Internationalization - Custom widgets — add entries to the registry for
textarea,checkbox,date, and any product-specific field types; see Widgets & Registry - Drop-in renderer — use
schemaformas the reference implementation and copy the pieces you want to replace - Connect a form library — replace
useStatewith React Hook Form, Formik, or Final Form by wiring their values and change handlers through the samerenderControlshape
Next: Build a Form Builder — wire up useFormBuilder to let admins create the forms your renderer displays.