Build a Form Builder
A step-by-step guide to building your own form builder admin UI using useFormBuilder.
Build your own form builder admin using the useFormBuilder hook. By the end you will have a working component that lets admins add Text and Select fields, organise them with Pages, Groups, Columns, and Repeaters, define show/hide Rules, and preview the rendered form live.
Each step adds one capability. Read the whole guide before copying code — later steps replace parts of earlier ones.
Prerequisites
Install schemaform:
npm install schemaformFor the live preview in Step 5, use the bundled <SchemaForm />. If you are building your own renderer first, skip that step and come back after completing Build a Form Renderer.
1. Wire up the hook
useFormBuilder is the entire API for building the admin surface. Pass it an initial FormDefinition and it returns the live definition and a set of mutation operations.
import { useState } from "react";
import type { FormDefinition } from "@mackehansson/schemaform";
import { useFormBuilder } from "@mackehansson/schemaform";
const emptyDefinition: FormDefinition = {
formatVersion: 1,
schema: { type: "object", properties: {}, required: [] },
uiSchema: {
pages: [{ type: "Page", title: "Page 1", children: [] }],
},
rules: [],
};
export function MyFormBuilder() {
const { definition, operations } = useFormBuilder({
initialDefinition: emptyDefinition,
palette: ["text", "select"],
});
return <div>builder goes here</div>;
}definition is the live FormDefinition. Every operation produces a new definition — your component just reads it and renders.
operations is a bag of mutation functions. They mutate all three layers (JSON Schema, UI Schema, Rules) together so you never have to manage the layers by hand.
palette is the list of widget names your builder exposes. It does not affect what operations are available — it is a hint to the component about which field types to show in the palette.
Persisting the definition
The hook holds the definition internally. To propagate changes to a parent component (for saving to a database), sync with a useEffect:
export function MyFormBuilder({
initialDefinition,
onChange,
}: {
initialDefinition: FormDefinition;
onChange: (def: FormDefinition) => void;
}) {
const { definition, operations } = useFormBuilder({ initialDefinition });
// Skip the first render — the parent already has the initial value.
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
onChange(definition);
}, [definition]);
return <div>builder goes here</div>;
}2. Build the palette
The palette is a list of buttons. Each button calls operations.addField with the widget and JSON Schema for that field type.
import type { FormDefinition, UiNode } from "@mackehansson/schemaform";
import { useFormBuilder } from "@mackehansson/schemaform";
// Generate a unique property key for a new field.
function generateKey(def: FormDefinition, base: string): string {
const existing = new Set(Object.keys(def.schema.properties ?? {}));
let key = base;
let n = 2;
while (existing.has(key)) key = `${base}${n++}`;
return key;
}
export function MyFormBuilder() {
const [pageIndex, setPageIndex] = useState(0);
const { definition, operations } = useFormBuilder({
initialDefinition: emptyDefinition,
palette: ["text", "select"],
});
function addTextField() {
const key = generateKey(definition, "text");
operations.addField(pageIndex, {
key,
label: "Text field",
widget: "text",
schema: { type: "string" },
});
}
function addSelectField() {
const key = generateKey(definition, "select");
operations.addField(pageIndex, {
key,
label: "Select field",
widget: "select",
schema: { type: "string", enum: ["Option 1", "Option 2", "Option 3"] },
});
}
return (
<div style={{ display: "flex", gap: 16 }}>
<aside
style={{ width: 160, display: "flex", flexDirection: "column", gap: 8 }}
>
<button type="button" onClick={addTextField}>
+ Text field
</button>
<button type="button" onClick={addSelectField}>
+ Select field
</button>
</aside>
<main style={{ flex: 1 }}>{/* canvas — next step */}</main>
</div>
);
}addField(pageIndex, spec, placement?) mutates two layers simultaneously:
- Adds the JSON Schema property to
schema.properties - Adds a
Controlnode touiSchema.pages[pageIndex].children
The third argument placement lets you insert the field inside a Group or Columns instead of at the page root — covered in Step 7.
3. Build the canvas
The canvas renders definition.uiSchema.pages[pageIndex].children. For now we only handle Control nodes — layout containers are added in later steps.
import type { ControlNode, UiNode } from "@mackehansson/schemaform";
import type { BuilderOperations } from "@mackehansson/schemaform";
function Canvas({
nodes,
selectedScope,
onSelect,
onRemoveField,
}: {
nodes: UiNode[];
selectedScope: string | null;
onSelect: (scope: string | null) => void;
onRemoveField: (scope: string) => void;
}) {
if (nodes.length === 0) {
return (
<p style={{ color: "#888" }}>No fields yet — add one from the palette.</p>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{nodes.map((node) => {
if (node.type !== "Control") return null; // expanded in later steps
const key = node.scope.replace("#/properties/", "");
const isSelected = node.scope === selectedScope;
return (
<div
key={node.scope}
onClick={() => onSelect(isSelected ? null : node.scope)}
style={{
border: isSelected ? "2px solid #3b82f6" : "1px solid #d1d5db",
padding: "8px 12px",
borderRadius: 6,
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "#fff",
}}
>
<div>
<span style={{ fontWeight: 500 }}>{node.label ?? key}</span>
<span style={{ marginLeft: 8, fontSize: 12, color: "#6b7280" }}>
{node.widget ?? "text"}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (isSelected) onSelect(null);
onRemoveField(node.scope);
}}
>
✕
</button>
</div>
);
})}
</div>
);
}Wire it into the builder alongside selection state:
export function MyFormBuilder() {
const [pageIndex, setPageIndex] = useState(0);
const [selectedScope, setSelectedScope] = useState<string | null>(null);
const { definition, operations } = useFormBuilder({ ... });
const currentPage = definition.uiSchema.pages[pageIndex];
return (
<div style={{ display: "flex", gap: 16 }}>
<aside>{/* palette */}</aside>
<main style={{ flex: 1 }}>
<Canvas
nodes={currentPage?.children ?? []}
selectedScope={selectedScope}
onSelect={setSelectedScope}
onRemoveField={(scope) => operations.removeField(scope)}
/>
</main>
</div>
);
}removeField(scope) removes the JSON Schema property and its Control node from every page simultaneously.
4. Add a settings panel
When a field is selected, render a panel to edit its label, required state, and — for Select — its options.
import type { ControlNode, FormDefinition } from "@mackehansson/schemaform";
import type { BuilderOperations } from "@mackehansson/schemaform";
// Walk the UI Schema to find the ControlNode for a scope.
function findControl(def: FormDefinition, scope: string): ControlNode | null {
for (const page of def.uiSchema.pages) {
const found = findControlInNodes(page.children, scope);
if (found) return found;
}
return null;
}
function findControlInNodes(
nodes: UiNode[],
scope: string,
): ControlNode | null {
for (const node of nodes) {
if (node.type === "Control" && node.scope === scope) return node;
if (node.type === "Group" || node.type === "Columns") {
const found = findControlInNodes(node.children, scope);
if (found) return found;
}
}
return null;
}
function SettingsPanel({
def,
scope,
ops,
onClose,
}: {
def: FormDefinition;
scope: string;
ops: BuilderOperations;
onClose: () => void;
}) {
const key = scope.replace("#/properties/", "");
const schema = def.schema.properties?.[key] ?? { type: "string" };
const control = findControl(def, scope);
const isRequired = def.schema.required?.includes(key) ?? false;
const isSelect = Array.isArray(schema.enum);
return (
<aside
style={{
width: 240,
borderLeft: "1px solid #e5e7eb",
padding: 16,
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<strong>Field settings</strong>
<button type="button" onClick={onClose}>
✕
</button>
</div>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13 }}>Label</span>
<input
defaultValue={control?.label ?? ""}
onBlur={(e) => ops.updateField(scope, { label: e.target.value })}
/>
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={isRequired}
onChange={(e) =>
ops.updateField(scope, { required: e.target.checked })
}
/>
<span style={{ fontSize: 13 }}>Required</span>
</label>
{isSelect && (
<OptionsEditor
options={(schema.enum as string[]) ?? []}
onUpdate={(next) =>
ops.updateField(scope, { schema: { ...schema, enum: next } })
}
/>
)}
</aside>
);
}
function OptionsEditor({
options,
onUpdate,
}: {
options: string[];
onUpdate: (next: string[]) => void;
}) {
const [draft, setDraft] = useState("");
function add() {
const v = draft.trim();
if (!v || options.includes(v)) return;
onUpdate([...options, v]);
setDraft("");
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<strong style={{ fontSize: 13 }}>Options</strong>
{options.map((opt, i) => (
<div key={i} style={{ display: "flex", gap: 4 }}>
<span style={{ flex: 1, fontSize: 13 }}>{opt}</span>
<button
type="button"
onClick={() => onUpdate(options.filter((_, idx) => idx !== i))}
>
✕
</button>
</div>
))}
<div style={{ display: "flex", gap: 4 }}>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder="New option…"
style={{ flex: 1 }}
/>
<button type="button" onClick={add}>
Add
</button>
</div>
</div>
);
}updateField(scope, updates) accepts partial updates. Passing { label } only touches the Control node. Passing { required: true } only touches schema.required. Passing { schema } replaces the JSON Schema property. You never manage the three layers separately.
Add SettingsPanel to the builder layout:
<div style={{ display: "flex", gap: 16 }}>
<aside>{/* palette */}</aside>
<main style={{ flex: 1 }}>{/* canvas */}</main>
{selectedScope && (
<SettingsPanel
def={definition}
scope={selectedScope}
ops={operations}
onClose={() => setSelectedScope(null)}
/>
)}
</div>5. Live preview
Pass definition to <SchemaForm> in a panel. Every operation on the builder is reflected in the preview immediately because definition is the shared source of truth.
import { SchemaForm } from "@mackehansson/schemaform-shadcn";
// Inside the builder layout:
<div style={{ flex: 1, borderLeft: "1px solid #e5e7eb", padding: 16 }}>
<p style={{ fontSize: 12, color: "#6b7280", marginBottom: 12 }}>Preview</p>
<SchemaForm
definition={definition}
onSubmit={(data) => console.log("Submitted:", data)}
/>
</div>;After completing Build a Form Renderer you can replace <SchemaForm> with your own renderer. The contract is the same: receive definition, call onSubmit with the working state.
6. Add pages
Multi-page forms need page tabs and three operations: addPage, removePage, and renamePage.
function PageTabs({
pages,
activeIndex,
onSwitch,
onAdd,
onRemove,
onRename,
}: {
pages: { title?: string }[];
activeIndex: number;
onSwitch: (i: number) => void;
onAdd: () => void;
onRemove: (i: number) => void;
onRename: (i: number, title: string) => void;
}) {
return (
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
marginBottom: 12,
}}
>
{pages.map((page, i) => (
<div key={i} style={{ display: "flex", alignItems: "center" }}>
<button
type="button"
onClick={() => onSwitch(i)}
onDoubleClick={() => {
const title = prompt(
"Page title:",
page.title ?? `Page ${i + 1}`,
);
if (title?.trim()) onRename(i, title.trim());
}}
style={{ fontWeight: activeIndex === i ? "bold" : "normal" }}
>
{page.title ?? `Page ${i + 1}`}
</button>
{pages.length > 1 && (
<button type="button" onClick={() => onRemove(i)}>
✕
</button>
)}
</div>
))}
<button type="button" onClick={onAdd}>
+ Page
</button>
</div>
);
}Wire it into the builder. Keep pageIndex clamped when a page is removed:
<PageTabs
pages={definition.uiSchema.pages}
activeIndex={pageIndex}
onSwitch={setPageIndex}
onAdd={() => {
const idx = definition.uiSchema.pages.length;
operations.addPage(`Page ${idx + 1}`);
setPageIndex(idx);
}}
onRemove={(i) => {
operations.removePage(i);
setPageIndex((prev) =>
Math.min(prev, definition.uiSchema.pages.length - 2),
);
}}
onRename={(i, title) => operations.renamePage(i, title)}
/>removePage removes the page and all its Control nodes and the corresponding JSON Schema properties. Fields on other pages are untouched.
7. Groups and Columns
Groups and Columns are layout containers. Add palette items and pass the layout type to addLayout.
// Add to the palette:
function addGroup() {
const len = currentPage?.children.length ?? 0;
operations.addLayout(pageIndex, [], len, "Group");
}
function addColumns() {
const len = currentPage?.children.length ?? 0;
operations.addLayout(pageIndex, [], len, "Columns");
}addLayout(pageIndex, parentPath, insertIndex, type, spec?) inserts a new Group or Columns node. parentPath: [] means the top level of the current page. Pass a spec with title to give a Group a visible heading.
The canvas now needs to render containers recursively. Replace the flat Canvas with a recursive CanvasNode:
function CanvasNodeList({
nodes,
parentPath,
pageIndex,
ops,
selectedScope,
onSelect,
}: {
nodes: UiNode[];
parentPath: number[];
pageIndex: number;
ops: BuilderOperations;
selectedScope: string | null;
onSelect: (scope: string | null) => void;
}) {
if (nodes.length === 0) {
return <p style={{ color: "#888", fontSize: 13 }}>Drop fields here</p>;
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{nodes.map((node, i) => (
<CanvasNode
key={i}
node={node}
path={[...parentPath, i]}
pageIndex={pageIndex}
ops={ops}
selectedScope={selectedScope}
onSelect={onSelect}
/>
))}
</div>
);
}
function CanvasNode({
node,
path,
pageIndex,
ops,
selectedScope,
onSelect,
}: {
node: UiNode;
path: number[];
pageIndex: number;
ops: BuilderOperations;
selectedScope: string | null;
onSelect: (scope: string | null) => void;
}) {
if (node.type === "Control") {
const key = node.scope.replace("#/properties/", "");
const isSelected = node.scope === selectedScope;
return (
<div
onClick={() => onSelect(isSelected ? null : node.scope)}
style={{
border: isSelected ? "2px solid #3b82f6" : "1px solid #d1d5db",
padding: "8px 12px",
borderRadius: 6,
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "#fff",
}}
>
<div>
<span style={{ fontWeight: 500 }}>{node.label ?? key}</span>
<span style={{ marginLeft: 8, fontSize: 12, color: "#6b7280" }}>
{node.widget ?? "text"}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (isSelected) onSelect(null);
ops.removeField(node.scope);
}}
>
✕
</button>
</div>
);
}
if (node.type === "Group" || node.type === "Columns") {
const label =
node.type === "Group"
? `Group${node.title ? `: ${node.title}` : ""}`
: "Columns";
const isColumns = node.type === "Columns";
return (
<div
style={{
border: "2px dashed #d1d5db",
borderRadius: 6,
padding: 8,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
}}
>
<span style={{ fontSize: 12, fontWeight: 600, color: "#6b7280" }}>
{label}
</span>
<button
type="button"
onClick={() => ops.removeLayout(pageIndex, path)}
>
✕
</button>
</div>
<div style={isColumns ? { display: "flex", gap: 8 } : {}}>
<CanvasNodeList
nodes={node.children}
parentPath={path}
pageIndex={pageIndex}
ops={ops}
selectedScope={selectedScope}
onSelect={onSelect}
/>
</div>
</div>
);
}
return null; // Array — next step
}To add a field inside a container, pass the container's path as parentPath to addField:
// If a Group at path [1] is selected, fields go inside it:
operations.addField(
pageIndex,
{ key, label: "Text field", widget: "text", schema: { type: "string" } },
{ parentPath: selectedContainerPath, insertIndex: containerChildCount },
);For a simple click-to-add builder, track which container is selected separately from which field is selected, then use the container path when calling addField.
8. Repeaters
A Repeater renders a dynamic list of rows. Each row follows a rowTemplate — a list of nodes describing one row's layout. Row template fields live inside schema.properties[key].items.properties in the JSON Schema, so use addRepeaterField (not addField) to add them.
Add a palette button:
function addRepeater() {
const key = generateKey(definition, "list");
operations.addRepeater(pageIndex, { key, label: "List" });
}Add fields to a repeater's row template with addRepeaterField:
function addTextFieldToRepeater(repeaterScope: string) {
const repeaterKey = repeaterScope.replace("#/properties/", "");
const key = generateRepeaterKey(definition, repeaterKey, "text");
operations.addRepeaterField(repeaterScope, {
key,
label: "Text field",
widget: "text",
schema: { type: "string" },
});
}
// Generate a unique key within the repeater's items schema.
function generateRepeaterKey(
def: FormDefinition,
repeaterKey: string,
base: string,
): string {
const items = def.schema.properties?.[repeaterKey]?.items as
| { properties?: Record<string, unknown> }
| undefined;
const existing = new Set(Object.keys(items?.properties ?? {}));
let key = base;
let n = 2;
while (existing.has(key)) key = `${base}${n++}`;
return key;
}Add the Array case to CanvasNode:
if (node.type === "Array") {
const key = node.scope.replace("#/properties/", "");
return (
<div
style={{
border: "2px dashed #f59e0b",
borderRadius: 6,
padding: 8,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
}}
>
<span style={{ fontSize: 12, fontWeight: 600, color: "#92400e" }}>
Repeater: {node.label ?? key}
</span>
<button type="button" onClick={() => ops.removeRepeater(node.scope)}>
✕
</button>
</div>
<p style={{ fontSize: 11, color: "#6b7280", marginBottom: 6 }}>
Row template
</p>
<CanvasNodeList
nodes={node.rowTemplate}
parentPath={[]}
pageIndex={pageIndex}
ops={ops}
selectedScope={selectedScope}
onSelect={onSelect}
/>
<button
type="button"
style={{ marginTop: 8, fontSize: 12 }}
onClick={() => addTextFieldToRepeater(node.scope)}
>
+ Text field in row
</button>
</div>
);
}removeRepeaterField(repeaterScope, fieldKey) removes the field from the row template and from items.properties. removeRepeater(scope) removes the entire array including its schema property.
9. Add Rules
Rules link a source field's current value to a show/hide effect on a target field. The rule editor below handles the most common case: "when field A equals a value, show/hide field B."
import type { Rule } from "@mackehansson/schemaform";
function RuleEditor({
def,
ops,
}: {
def: FormDefinition;
ops: BuilderOperations;
}) {
const [source, setSource] = useState("");
const [value, setValue] = useState("");
const [effect, setEffect] = useState<"show" | "hide">("show");
const [target, setTarget] = useState("");
const fieldKeys = Object.keys(def.schema.properties ?? {});
function addRule() {
if (!source || !value || !target) return;
ops.addRule({
condition: { "==": [{ var: source }, value] },
effect,
target: `#/properties/${target}`,
});
setValue("");
}
return (
<div style={{ marginTop: 24 }}>
<strong>Rules</strong>
{def.rules.length === 0 && (
<p style={{ fontSize: 13, color: "#6b7280" }}>No rules yet.</p>
)}
{def.rules.map((rule, i) => (
<div
key={i}
style={{
display: "flex",
gap: 8,
alignItems: "center",
fontSize: 13,
marginTop: 8,
}}
>
<span style={{ flex: 1 }}>
{rule.effect} <code>{String(rule.target)}</code> when{" "}
<code>{JSON.stringify(rule.condition)}</code>
</span>
<button type="button" onClick={() => ops.removeRule(i)}>
✕
</button>
</div>
))}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr auto 1fr auto 1fr auto",
gap: 8,
alignItems: "flex-end",
marginTop: 16,
}}
>
<label
style={{
display: "flex",
flexDirection: "column",
gap: 4,
fontSize: 13,
}}
>
Source field
<select value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">—</option>
{fieldKeys.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
</label>
<label
style={{
display: "flex",
flexDirection: "column",
gap: 4,
fontSize: 13,
}}
>
equals
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="value"
style={{ width: 80 }}
/>
</label>
<label
style={{
display: "flex",
flexDirection: "column",
gap: 4,
fontSize: 13,
}}
>
Effect
<select
value={effect}
onChange={(e) => setEffect(e.target.value as "show" | "hide")}
>
<option value="show">show</option>
<option value="hide">hide</option>
</select>
</label>
<label
style={{
display: "flex",
flexDirection: "column",
gap: 4,
fontSize: 13,
}}
>
Target field
<select value={target} onChange={(e) => setTarget(e.target.value)}>
<option value="">—</option>
{fieldKeys.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
</label>
<button
type="button"
onClick={addRule}
style={{ alignSelf: "flex-end" }}
>
Add rule
</button>
</div>
</div>
);
}A Rule has three fields:
condition— a JSONLogic expression.{ "==": [{ var: "fieldKey" }, "value"] }readsfieldKeyfrom the Working State and checks equality. The renderer evaluates this on every keystroke.effect— what happens when the condition is true."show"makes the target visible while true and hidden otherwise."hide"is the inverse.target— a Control scope like#/properties/city, or a layout nodeidto show/hide an entire Group or Page.
removeRule(index) removes the rule at that position in definition.rules.
To extend this to Group-level targets, populate a second dropdown with the id values from your Group nodes. The same addRule call works — just pass the Group id as target instead of a scope.
What's next
You now have a working form builder with the full API surface covered. To take it further:
- Reordering — wire up drag-and-drop with Add Drag and Drop, or use
operations.moveNodeandoperations.moveRepeaterNodedirectly for up/down buttons - Undo / redo —
useFormBuilderalready tracks history; wire upundo,redo,canUndo, andcanRedofrom the hook return value - More rule effects — extend
RuleEditorwith"enable","disable","require", and"unrequire"; they use the sameaddRulecall with a differenteffect - More widgets — add palette buttons for
textarea,number,date, and any custom widgets in your registry - Repeater layout — use
addRepeaterLayoutto put Groups and Columns inside a Repeater's row template
Next: Build a Form Renderer — wire up useFormRenderer to render the forms your builder produces.