@mackehansson/schemaform

Rendering a Form

Render a Form Definition with the controlled useFormRenderer hook or the drop-in SchemaForm component.

Rendering starts from a Form Definition. The Core interprets the JSON Schema, UI Schema, and Rules with pure functions. A Renderer owns the mutable form state, calls Core as data changes, and turns the resolved form model into concrete components.

That division matters: Core is not a hidden form store. The consuming app or Renderer owns the Working State.

Drop-in rendering

Use schemaform when you want the first-party Renderer to wire the form state, widgets, validation reveal behavior, and submit flow for you.

Live form

Contact

Source

import type { FormDefinition } from "@mackehansson/schemaform";import { SchemaForm } from "@mackehansson/schemaform-shadcn";const contactForm = {  formatVersion: 1,  schema: {    type: "object",    required: ["name", "email"],    properties: {      name: { type: "string", title: "Name", minLength: 2 },      email: { type: "string", title: "Email", format: "email" },      message: { type: "string", title: "Message" },    },  },  uiSchema: {    pages: [      {        type: "Page",        title: "Contact",        children: [          { type: "Control", scope: "#/properties/name" },          { type: "Control", scope: "#/properties/email" },          {            type: "Control",            scope: "#/properties/message",            widget: "textarea",          },        ],      },    ],  },  rules: [],} satisfies FormDefinition;export function App() {  return (    <SchemaForm      definition={contactForm}      onSubmit={(data) => console.log(data)}    />  );}

In this path, <SchemaForm> owns the React Hook Form wiring. On a valid submit, onSubmit receives the submitted data. Hidden fields are stripped from the Submission Payload unless the matching Control opts into retainWhenHidden.

The example is written for a plain Vite + React app. The same runtime packages work outside Next because the Form Definition and Renderer contract are framework-level code, not route-level code.

Controlled rendering with useFormRenderer

Use useFormRenderer when you are building your own Renderer or need to integrate Schemaform into an existing form stack. The hook is controlled: you pass the current Working State in, and your form library or component state updates it.

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

export function CustomRenderer({
  definition,
}: {
  definition: FormDefinition;
}) {
  const [data, setData] = useState<WorkingState>({});
  const form = useFormRenderer({ definition, data });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        form.revealAllErrors();
        if (form.isValid) {
          console.log(data);
        }
      }}
    >
      {form.currentPage.controls.map((control) => (
        <label key={control.scope}>
          {control.label}
          <input
            name={control.path}
            value={String(data[control.path] ?? "")}
            onChange={(event) =>
              setData((current) => ({
                ...current,
                [control.path]: event.target.value,
              }))
            }
            onBlur={() => form.onFieldBlur(control.pointer)}
            disabled={control.disabled}
          />
          {form.errorsFor(control.pointer).map((error) => (
            <span key={error.keyword}>{error.message}</span>
          ))}
        </label>
      ))}

      <button type="submit">Submit</button>
    </form>
  );
}

The hook gives you the resolved Page, visible Controls, validation errors, page navigation state, and helpers such as errorsFor, onFieldBlur, goNext, and revealAllErrors. It does not store field values. That keeps Core pure and lets each Renderer use its own form library idiomatically.

The ShadCN Renderer is the reference implementation of that controlled model: React Hook Form owns values, useFormRenderer resolves the visible form and validation state, and the Registry maps each Control to a concrete Widget.

On this page