@mackehansson/schemaform

Architecture

How the Form Builder, Form Definition, Core, hooks, Renderer, Registry, and app code connect.

Schemaform is organized around one persisted boundary: the Form Definition. Everything else either creates that definition, interprets it, renders it, or submits data from it.

System map
Design time
Admin user
Designs or edits a form in your product.
Form Builder
Edits JSON Schema, UI Schema, and Rules.
Form Definition
Versioned envelope saved by your application.
Runtime
End user
Fills out the rendered form.
Form Renderer
Uses Core and the Registry to render UI.
Submission Payload
Projected data sent to your app submit handler.
Core
Pure engine: schema, UI Schema, Rules, validation, resolution.
React hooks
State boundary for renderers and builders.
Your app
Storage, routes, permissions, APIs, analytics, and workflows.
Admin user
  -> Form Builder
  -> Form Definition
       { formatVersion, schema, uiSchema, rules }
  -> your storage

End user
  -> Form Renderer
       definition + Working State
  -> Core
       resolved view model + validation + submission projection
  -> Registry
       widget name -> concrete component
  -> Submission Payload
  -> your application

The important split is ownership. Schemaform owns the shared form language and the mechanics for resolving it. Your product owns persistence, routes, permissions, data fetching, analytics, side effects, and final submission handling.

Design-time flow

At design time, an admin-facing Form Builder edits a Form Definition.

Builder UI
  -> builder hook
  -> Core mutation functions
  -> updated Form Definition
  -> your save action

The Form Builder edits three documents together:

DocumentOwnsExample
JSON Schemadata shape and validationtype, required, enum, minLength
UI Schemapresentation and layoutPages, Groups, Columns, Controls, widgets
Rulesconditional behaviorshow, hide, enable, disable, require

The builder does not submit end-user data. Its output is configuration: a versioned Form Definition that your application stores.

Runtime flow

At runtime, a Form Renderer receives a Form Definition and renders it for an end user.

Runtime loop
Working State
Live values while the end user fills out the form.
useFormRenderer
React boundary for navigation, errors, validators, and resolver calls.
Core
Evaluates rules, resolves layout, derives widgets, validates data.
Renderer
Renders pages, controls, arrays, errors, and submit UX.
Field changes update Working State, then the loop runs again. Submit uses the resolved model to create the Submission Payload.
Form Definition
  + initial values
  + current Working State
  -> useFormRenderer
  -> Core resolver
  -> FormViewModel
  -> Renderer
  -> widgets

The Working State is the live data while the user fills out the form. It may include values for hidden fields, disabled fields, or fields on another Page.

Core evaluates Rules against the Working State, resolves the layout, derives widget names, and reports validation state. The Renderer turns that resolved model into concrete UI.

Submit flow

On submit, the Renderer should not send Working State directly. It should project a Submission Payload from the resolved view model.

Working State
  + FormViewModel
  -> toSubmissionPayload
  -> hidden fields stripped unless retained
  -> final app submit handler

This is where hidden-field behavior matters. A hidden field can remain in Working State so the user does not lose input while interacting with the form, but still be removed from the Submission Payload unless the Control or Array opts into retainWhenHidden.

Your app receives the payload through onSubmit and decides what happens next: call an API, save a draft, start a workflow, show a confirmation, or map the payload to another domain model.

Package responsibilities

The installed package exposes one default API, with advanced subpath entrypoints that mirror the internal layers.

LayerResponsibilityInputsOutputs
schemaform/corePure form engineForm Definition, Working State, rule directivesresolved form model, validation results, submission payload
schemaform/reactReact state boundarydefinition, data, validators, navigation optionshooks for renderers and builders
schemaform/shadcnFirst-party UI implementationhooks, registry, ShadCN/RHF components<SchemaForm />, <FormBuilder />, widgets
Your appProduct integrationstored definitions, auth, routes, APIssaved definitions, submitted data, product behavior

Core is intentionally framework-agnostic. It does not render components, own input state, know about React Hook Form, or call your backend.

React hooks sit at the boundary between pure Core and stateful UI. They resolve forms, manage navigation, reveal errors, run custom validators, and expose stable helpers for renderers and builders.

The ShadCN package is a working renderer and builder implementation. It is both usable as-is and useful as source to copy when you want your own design-system-native implementation.

Renderer boundary

A Renderer owns mutable form state and concrete components.

Renderer owns:
  - form library integration
  - field registration
  - focus and blur behavior
  - concrete widgets
  - layout markup
  - submit button UX

Core owns:
  - schema interpretation
  - rule evaluation
  - default widget derivation
  - resolved Controls and Pages
  - validation
  - submission projection

The Renderer should render from the resolved view model, not directly from raw JSON Schema. That resolved model is where Rules, UI Schema layout, required state, disabled state, default widgets, and hidden-field behavior have already been interpreted.

Registry boundary

A Registry maps widget names to concrete components.

Control
  { scope: "#/properties/startDate", widget: "date" }
  -> resolved Control
  -> registry.date
  -> DateWidget

Core can decide that a string with format: "date" wants the date widget. The Renderer decides what date means visually and behaviorally in your application.

The same Registry also powers the Form Builder's widget palette. Adding a custom widget to the Registry makes it available to render and, in the ShadCN builder, available for admins to choose.

Trust boundary

A Form Definition is configuration, not arbitrary trusted code. It may come from your database, an admin interface, a seed file, or an import flow.

Treat it as a trust boundary:

  • validate imported definitions before storing or rendering them
  • keep custom behavior in app code, validators, widgets, renderers, and submit handlers
  • avoid storing executable code inside definitions
  • version definitions with the formatVersion envelope field

Definitions should describe data, presentation, and rules. Your application should own code execution.

Mental model

Use this rule when deciding where code belongs:

QuestionPut it in
What data can be submitted?JSON Schema
How should the form be arranged?UI Schema
What changes based on answers?Rules
What component renders a field?Registry
How does a component behave?Widget code
How do values live in React?Renderer or form library
How does validation call my backend?Custom validators
Where is the form saved?Your app
What happens after submit?Your app

Read The Three Layers for the modeling rules inside a Form Definition, then Extension Points for where to plug in custom product code.

On this page