This article is published in English.
Schema-Driven React Forms: Rendering and Validating From JSON Schema
How to render validated React forms directly from JSON Schema, handle $ref, oneOf and if/then branches, plug in custom widgets and avoid common validation traps.
A hand-written React form usually duplicates a contract that already exists. The API's request schema knows that email is required and must look like an email address, that age is a non-negative integer, and that role is one of three values. Retyping those rules in JSX, again in a validation library and again in error messages creates three sources of truth for one data shape, and they drift apart as soon as the backend changes.
This guide treats the form as derived from JSON Schema instead, using the open-source react-simple-schema-form package as the concrete implementation. You will see how $ref, allOf, oneOf and if/then become dynamic fields, how to plug in custom widgets, and which validation behaviours make a generated form feel hand-built.
Why the schema should own the form
Drift is predictable: a new backend field never reaches the form, and a request like "show billing address only for invoice payments" becomes a useState flag, a conditional render and a validation branch that fall out of sync months later.
JSON Schema can already express every one of those rules: types, constraints, required fields and conditional logic. It is frequently the same document your backend validates requests against and your OpenAPI specification embeds. If the form is generated from it, a schema change updates the UI and its validation in one move.
A minimal generated form
react-simple-schema-form accepts a JSON Schema written against draft-07 and renders a validated form. According to its documentation it has no runtime dependencies apart from React 18, ships its own TypeScript types, and provides an optional stylesheet. Installation is a single package:
npm install react-simple-schema-form
The example below describes a small user object: a name, an email with format: 'email', an integer age with a minimum of zero, and a role restricted by enum. name and email are listed as required. The component receives the schema and an onSubmit callback, and nothing else.
import { SchemaForm } from 'react-simple-schema-form';
import 'react-simple-schema-form/styles.css';
const schema = {
type: 'object',
properties: {
name: { type: 'string', title: 'Name' },
email: { type: 'string', format: 'email', title: 'Email' },
age: { type: 'integer', minimum: 0, title: 'Age' },
role: { type: 'string', enum: ['Admin', 'Editor', 'Viewer'], title: 'Role' },
},
required: ['name', 'email'],
};
<SchemaForm schema={schema} onSubmit={(data) => save(data)} />
From that schema the library renders a text input, an email input, a number input and a select for the enum, marks required fields, shows inline errors, and calls onSubmit only once the data is valid. The component works in both React modes: pass value and onChange to control it, or defaultValue to let it manage its own state.
Any generator handles a flat object like this; the real test is nested and branching schemas.
Handling schemas that are not flat
Production schemas reuse definitions, compose fragments and branch on data. The library resolves all of that against the current form data before each render, so each field only sees a flattened schema.
Reusing definitions with $ref and allOf
A shared definition such as address can be referenced in two places and will render as two independent sections. Keywords placed next to a $ref override the referenced definition, so { "$ref": "#/definitions/address", "title": "Shipping address" } produces an address block labelled "Shipping address". With allOf, the parts are deep-merged: nested properties are merged recursively and the required arrays are combined into their union.
Treating oneOf as a discriminated union
Many generators struggle with oneOf. The pattern that works well is a discriminated union: every branch pins a shared field to a fixed value with const, and the form uses that field to pick the active branch.
In the payment schema below, method is an enum of card or bank. The first branch sets method to card and requires a number; the second sets it to bank and requires an iban.
{
"type": "object",
"properties": { "method": { "type": "string", "enum": ["card", "bank"] } },
"required": ["method"],
"oneOf": [
{ "title": "Card", "properties": { "method": { "const": "card" }, "number": { "type": "string" } }, "required": ["number"] },
{ "title": "Bank", "properties": { "method": { "const": "bank" }, "iban": { "type": "string" } }, "required": ["iban"] }
]
}
Switching method from card to bank swaps the card number field for the IBAN field. There is no component state and no conditional JSX in your code; the schema alone drives the change. A oneOf whose branches contain nothing but a const is rendered as a labelled select.
Conditional sections with if/then/else and dependencies
Conditional keywords are re-evaluated whenever the data changes, including on every keystroke. A useful recipe is an optional section that is only validated once the user switches it on. The fragment below defines a schedule object with a boolean enabled flag (defaulting to false) and two day fields. The if clause matches when enabled is true, and the then clause makes monday and tuesday required in that case.
"schedule": {
"type": "object",
"properties": {
"enabled": { "type": "boolean", "title": "Enable schedule", "default": false },
"monday": { "type": "string", "title": "Monday" },
"tuesday": { "type": "string", "title": "Tuesday" }
},
"if": { "properties": { "enabled": { "const": true } }, "required": ["enabled"] },
"then": { "required": ["monday", "tuesday"] }
}
With the toggle off, nothing inside the section is required and submission is not blocked. With it on, both day fields gain required markers and the form cannot be submitted until they are filled. Because the toggle is part of the data rather than local UI state, the server can validate the same payload with the same schema and reach the same verdict.
The "required": ["enabled"] line inside if is easy to drop and essential to keep. In JSON Schema, properties only constrains keys that are present. An object with no enabled key therefore satisfies { "properties": { "enabled": { "const": true } } }, the then branch kicks in, and the days become required even though the section was never enabled. Requiring the key inside the condition closes that hole.
Choosing and customising widgets
A generated form is only practical if you can control which input each field uses. The library keeps a registry of built-in widgets, including text, email, number, select, radio, checkboxes, textarea and date, and offers three ways to assign them:
- A
uiSchemaprop keyed by path, with glob support.tags.*targets every item in an array, and**.postalCodetargets every postal code at any depth, even inside a$refused in two places. When several keys match, the most specific one wins. - Schema-embedded hints. A node can carry its own
ui:*keywords, and a parent can hold a nesteduiSchemaaddressed by child names, so whoever references a shared definition can restyle its children. - A
resolveWidgetfunction for rule-based choices, such as "every integer withformat: epochuses the epoch widget". It receives the fully resolved schema and can return either a widget name or a component.
The order of precedence is fixed: the application's uiSchema beats hints embedded in the schema, which beat resolveWidget rules, which beat the defaults. That predictability matters when another team serves the schema: the client can always override its hints.
Writing a custom widget
A widget is a component that receives the current value and an onChange callback, plus props such as id, required, disabled and onBlur. The example below stores a timestamp as Unix seconds but shows the user a native datetime-local picker. It converts seconds to a date string for display, and on change parses the input back, dividing milliseconds by 1000 and passing undefined when the input is empty or invalid. The widget is registered under the name epoch and assigned to the startsAt field via uiSchema.
import type { Widget } from 'react-simple-schema-form';
const EpochWidget: Widget<number | undefined> = ({ id, value, onChange, onBlur, required, disabled }) => (
<input
type="datetime-local"
id={id}
required={required}
disabled={disabled}
value={value === undefined ? '' : new Date(value * 1000).toISOString().slice(0, 16)}
onBlur={onBlur}
onChange={(e) => {
const ms = new Date(e.target.value).getTime();
onChange(Number.isNaN(ms) ? undefined : Math.floor(ms / 1000));
}}
/>
);
<SchemaForm schema={schema} widgets={{ epoch: EpochWidget }} uiSchema={{ startsAt: { widget: 'epoch' } }} />
The schema says integer, the user sees a picker, and the data holds Unix seconds. One caveat: toISOString() produces UTC, while a datetime-local input and new Date(e.target.value) both work in the user's local time zone. Outside UTC, the displayed time is shifted by the zone offset and each edit moves the stored value. Format the display value from local date parts instead so both directions agree.
A widget can also own an entire object or array, receiving the whole value plus every nested error and rendering children through the exported <Field> component. That is how the schedule section gets its toggle-and-hide behaviour without the library knowing about schedules.
If a schema refers to a widget that was never registered, the library logs a single warning and falls back to the default input. A typo in a schema served by another team should degrade gracefully rather than crash the page.
Validation that matches user expectations
The built-in validator is small and dependency-free, and most of its design is about when to report errors rather than whether they exist.
Show errors at the right moment
Errors appear after a user leaves a field, or all together after a submit attempt, and never on the first render. When a submission fails, focus moves to the first invalid field.
Treat untouched optional objects as absent
To render inputs for nested objects, the form seeds them with {}. A naive validator would then demand street and city for an optional address the user never touched. The fix is to treat an optional object whose values are all empty as absent, so it produces no errors. A required object is always validated, and its error lists which children are missing instead of a vague "Address is required".
Attribute oneOf errors to the active branch
When no oneOf branch validates, a generic "data must match exactly one schema" message is useless to a person. Instead, the validator determines which branch the data belongs to, matching on discriminators and types but ignoring required, and reports that branch's field-level errors. For a payment with method: card and no card number, the error appears on the card number field, where the user will look.
Never let hidden fields block submission
Leftover, half-typed values in a section that has been switched off should not fail a pattern check the user cannot see. The rule lives in the schema, but the fix lives in the widget: it clears the section when it is disabled, and the errors prop tells the widget what errors exist inside the part it hides.
Reuse the rules outside React
The validator is also exported on its own. validate(schema, data) returns a list of { path, keyword, message } entries, so the same rules can run in a Node.js service, in a unit test, or before anything renders. For a TypeScript-first alternative, see sharing one Zod schema between React and Node.
Documentation aimed at coding assistants
Forms are often scaffolded with an AI coding assistant, so the package ships documentation aimed at machines as well as people:
- An Agent Skill file at
skills/react-simple-schema-form/SKILL.mdinside the npm package, which tools that support the Agent Skills format can load fromnode_modules. It covers the API, widget precedence, the recipes above and known pitfalls, in roughly 7 kB at the time of writing. llms.txtandllms-full.txton the demo site, bundling the README, the skill and every example schema into a single file that can be pasted into a chat or indexed by a documentation MCP server.- JSDoc with examples on every export, so editor hovers over the type declarations explain usage.
- A
context7.jsonfile so the repository indexes cleanly in Context7.
This will not make a model choose the library, but it improves the odds that an assistant's first attempt works, a practice worth copying for internal libraries too.
Trying it out
The live demo puts a schema editor next to the generated form, with live data and errors underneath. It includes examples for $ref, allOf, oneOf, if/then/else, dependencies and widget selection. The package is published on npm, and the source and issue tracker are on GitHub. It is a young project, so test it against your own schemas before relying on it.
Key takeaways
- If an API already publishes JSON Schema, generating the form from it removes duplicated rules and keeps UI and server validation in lockstep.
- Resolve
$ref,allOf,oneOfand conditionals against live data so each field sees a flat schema. - Model variant forms as discriminated unions with
const, and always addrequiredinsideifclauses. - Keep widget selection overridable with a clear precedence order, especially for schemas owned by another team.
- Good generated forms depend on validation timing: report on blur or submit, ignore untouched optional objects, and point
oneOferrors at the active branch.