schema-components
v3.7.1
Published
React components that render UI from Zod schemas, JSON Schema, and OpenAPI documents
Maintainers
Readme
schema-components
React components that render UI from Zod schemas, JSON Schema, and OpenAPI documents.
Install
npm install schema-componentsPeer dependencies: zod@^4.0.0, react@^18.0.0 || ^19.0.0. preact@>=10.0.0 is also accepted as an optional peer for the Preact entry point — see Preact support.
Zod version requirement
schema-components requires Zod 4. If you are on Zod 3, see the Zod 4 migration guide. Zod 3 schemas are detected structurally — any object exposing _def without the Zod 4 _zod marker is classified as Zod 3, with or without the historical _def.typeName field. (Some third-party Zod-3-style libraries omit typeName; the detector keys on the presence of _def alone.) A descriptive SchemaNormalisationError is raised pointing at the Zod 4 migration guide.
Schemas from other libraries that conform to the Standard Schema spec (valibot, arktype, ...) are also detected and rejected. When the input advertises a ~standard.vendor field, the error message includes the vendor name so consumers know which library produced the input.
Preact support
Preact is supported as an optional peer dependency via preact/compat aliasing. The Preact entry point (schema-components/preact/*) re-exports every public symbol from the React entry point (SchemaComponent, SchemaProvider, SchemaField, SchemaView, SchemaErrorBoundary, registerWidget, headlessResolver). The renderer tree is identical — preact/compat translates React-style onChange to onInput at render time, matching the "fires on every keystroke" semantics the controlled inputs rely on.
Import from schema-components/preact/* instead of schema-components/react/*, then configure your bundler to alias react and react-dom to preact/compat.
import { SchemaComponent } from "schema-components/preact/SchemaComponent";Vite
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
alias: {
"react-dom/test-utils": "preact/test-utils",
"react-dom/client": "preact/compat/client",
"react-dom/server": "preact/compat/server",
"react-dom": "preact/compat",
"react/jsx-runtime": "preact/jsx-runtime",
"react/jsx-dev-runtime": "preact/jsx-dev-runtime",
react: "preact/compat",
},
},
});The order matters: longer, path-specific entries (react-dom/client, react/jsx-runtime) must come before the bare entries (react, react-dom) so they get a chance to claim the request first.
Next.js
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
webpack(webpackConfig) {
webpackConfig.resolve.alias = {
...webpackConfig.resolve.alias,
"react-dom/test-utils": "preact/test-utils",
"react-dom/client": "preact/compat/client",
"react-dom/server": "preact/compat/server",
"react-dom": "preact/compat",
"react/jsx-runtime": "preact/jsx-runtime",
"react/jsx-dev-runtime": "preact/jsx-dev-runtime",
react: "preact/compat",
};
return webpackConfig;
},
};
export default config;Node (without a bundler)
Use the package manager's overrides field to swap the resolved package at install time. For npm:
// package.json
{
"overrides": {
"react": "npm:@preact/compat@*",
"react-dom": "npm:@preact/compat@*"
}
}For pnpm:
// package.json
{
"pnpm": {
"overrides": {
"react": "npm:@preact/compat@*",
"react-dom": "npm:@preact/compat@*"
}
}
}For yarn, use the resolutions field with the same value.
Known limitations
- React Server Components is React-only.
<SchemaView>runs as a client component under Preact, losing the zero-client-JS deployment story documented in Server Components. The component still renders correctly — it just executes in the browser rather than at request time on the server. onChangesemantics requirespreact/compat. Rawpreact(withoutcompat) firesonChangeon commit / blur, matching the DOM standard.preact/compataliasesonChangetoonInputso the controlled inputs fire on every keystroke, matching React's behaviour. Importing fromschema-components/preact/*without aliasingreacttopreact/compatwill leave events misrouted.- HTML serialization differs slightly. Preact emits HTML5 boolean-attribute shorthand (
valuefor an empty string) where React emitsvalue="". The semantics are identical; only string snapshots that match the exact React form will need adjusting under Preact. - Vnode prop shape differs. Preact-compat rewrites React-style event prop names (
onChange,onBlur) to lowercase DOM event names (oninput,onfocusout) on the vnode prop bag during rendering. Tests that inspect a React element's.propsdirectly (rather than the rendered DOM) need to expect the lowercase form when running under Preact.
The test suite is parametrised with a unit-preact Vitest project that runs the same files under preact/compat aliasing. Run it via pnpm test:preact from the repo root, or directly with pnpm exec vitest run --project=unit-preact from packages/core. A small set of tests (the ones bound to the three known limitations above) fail intentionally; the remaining ~99% pass under both runtimes and establish the cross-runtime regression boundary.
Vue support
Vue 3.5+ is supported as an optional peer dependency. The Vue adapter ships as source under schema-components/vue/* — the .vue Single File Components are not pre-compiled into the published tarball. Consumers need a Vite or webpack toolchain with @vitejs/plugin-vue (or equivalent) to compile the SFCs at their own build step.
// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});<script setup lang="ts">
import { ref } from "vue";
import SchemaComponent from "schema-components/vue/SchemaComponent.vue";
import { z } from "zod";
const userSchema = z.object({
name: z.string(),
email: z.email(),
});
const user = ref({ name: "Ada", email: "[email protected]" });
</script>
<template>
<SchemaComponent :schema="userSchema" v-model="user" />
</template>The ./vue/* export subpath resolves to the source tree (src/vue/*.ts) and ./vue/*.vue resolves to the .vue SFCs under src/vue/. The same pattern is used by the Svelte adapter — both rely on the consumer's bundler to handle the framework-specific compilation step.
SchemaComponent
The single entry point. Accepts Zod schemas, JSON Schema objects, or OpenAPI documents:
import { SchemaComponent } from "schema-components/react/SchemaComponent";
// Zod schema
<SchemaComponent schema={z.object({ name: z.string() })} value={data} />
// JSON Schema object
<SchemaComponent schema={{ type: "object", properties: { name: { type: "string" } } }} value={data} />
// OpenAPI document + schemaRef
<SchemaComponent schema={openApiSpec} schemaRef="#/components/schemas/User" value={data} />Props
| Prop | Type | Description |
|---|---|---|
| schema | ZodType \| JSONObject \| OpenAPIDocument | The schema to render |
| value | unknown | Current value for the schema |
| onChange | (value: unknown) => void | Callback when value changes |
| readOnly | boolean | Force read-only presentation |
| writeOnly | boolean | Force write-only (blank inputs) |
| schemaRef | string | JSON Pointer into OpenAPI document |
| fields | InferFields<T> | Type-safe per-field overrides |
| widgets | WidgetMap | Instance-scoped widget overrides |
| validate | boolean | Enable Zod validation on change |
| onValidationError | (error: unknown) => void | Callback for validation errors |
| onError | (error: SchemaError) => ReactNode \| void | Per-component error handler |
| resolver | ComponentResolver | Theme adapter override |
| meta | SchemaMeta | Schema-level metadata override |
Component editability
Fields render in one of three states, controlled by readOnly and writeOnly from three sources:
| State | Rendering | |---|---| | Presentation | Read-only display. Formatted text, links, badges. No inputs. | | Input | Empty field. Blank inputs, "Select…" dropdowns, unchecked toggles. | | Editable | Pre-populated input the user can change. |
Three sources, priority order
- Schema property (
.meta({ readOnly: true })) — always wins - Component props (
readOnly/writeOnlyon<SchemaComponent>) — rendering context - Schema root (
.meta({ readOnly: true })on root schema) — fallback default - Neither → Editable
Overriding with readOnly: false
A field override can explicitly opt out of a higher-level readOnly:
<SchemaComponent
schema={userSchema}
value={user}
readOnly // everything presentation
fields={{
address: {
readOnly: false, // address subtree: editable
city: { readOnly: true }, // city: still presentation
},
}}
/>Type-safe field overrides
The fields prop type is inferred from the schema:
// Zod — full autocomplete
<SchemaComponent
schema={userSchema}
fields={{
name: { readOnly: true }, // ✓ type-safe
address: {
city: { description: "City" }, // ✓ nested, type-safe
},
// nme: { readOnly: true }, // ✗ TypeScript error: unknown key
}}
/>
// JSON Schema as const — full autocomplete
const jsonSchema = {
type: "object" as const,
properties: {
name: { type: "string" as const },
email: { type: "string" as const, format: "email" },
},
required: ["name"],
} as const;
<SchemaComponent
schema={jsonSchema}
fields={{
name: { readOnly: true }, // ✓ inferred from as const
// nme: { readOnly: true }, // ✗ TypeScript error
}}
/>
// OpenAPI as const + schemaRef — full autocomplete
const spec = {
openapi: "3.1.0",
components: {
schemas: {
User: {
type: "object" as const,
properties: {
id: { type: "string" as const },
name: { type: "string" as const },
},
required: ["id", "name"],
},
},
},
} as const;
<SchemaComponent
schema={spec}
schemaRef="#/components/schemas/User"
fields={{
id: { readOnly: true }, // ✓ inferred through schemaRef
}}
/>FieldOverride
Each field override accepts:
| Property | Type | Description |
|---|---|---|
| readOnly | boolean | Override editability for this field |
| writeOnly | boolean | Override write-only state |
| visible | boolean | Hide the field entirely when false |
| order | number | Sort order within parent object |
| onValidationError | (error: unknown) => void | Per-field validation callback |
| description | string | Override label / description |
| default | unknown | Override default value |
| component | string | Widget name for custom rendering |
Plus any standard JSON Schema meta properties (title, format, pattern, etc.).
Individual fields
import { SchemaField } from "schema-components/react/SchemaComponent";
// Type-safe path — only valid dot-paths accepted
<SchemaField
schema={userSchema}
path="address.city" // ✓ type-safe
// path="address.cty" // ✗ TypeScript error
value={user}
onChange={setUser}
/>When the schema is a Zod schema or typed as const, only valid dot-paths like "address.city" are accepted. Invalid paths trigger TypeScript errors. Runtime schemas accept any string.
Spec support
The walker reads canonical Draft 2020-12 JSON Schema. Older drafts and OpenAPI documents are normalised to that form transparently.
| Spec | Detection | Normalisation | Notes |
|---|---|---|---|
| JSON Schema Draft 04 | http://json-schema.org/draft-04/schema# | exclusiveMinimum/Maximum boolean → number; id left in place | |
| JSON Schema Draft 06 | http://json-schema.org/draft-06/schema# | Pass-through (canonical for const, examples[], $id, propertyNames, contains) | |
| JSON Schema Draft 07 | http://json-schema.org/draft-07/schema# | Pass-through (adds if/then/else, contentEncoding, contentMediaType) | |
| JSON Schema Draft 2019-09 | https://json-schema.org/draft/2019-09/schema | $recursiveRef → $ref, $recursiveAnchor → $anchor | Adds unevaluatedProperties/Items, dependentSchemas/Required |
| JSON Schema Draft 2020-12 | https://json-schema.org/draft/2020-12/schema (default) | $dynamicRef → $ref, $dynamicAnchor → $anchor; tuple form via prefixItems | |
| OpenAPI 2.0 (Swagger) | swagger: "2.0" | Full document restructure → OpenAPI 3.1; definitions → components/schemas; body params → requestBody; formData → multipart/form-data; collectionFormat → style/explode | |
| OpenAPI 3.0.x | openapi: "3.0.x" | nullable → anyOf [T, null]; discriminator mapping → injected const; example → examples[] | Callbacks, links, security schemes preserved |
| OpenAPI 3.1.x | openapi: "3.1.x" | Direct (already Draft 2020-12) | Webhooks, components/pathItems, JSON Schema type arrays, examples[] |
Documented type-level fallbacks
A few JSON Schema keywords can't be expressed in TypeScript's type system. They are handled at runtime by the walker, but FromJSONSchema<S> falls back as follows (each pinned by tests/type-inference-advanced.test.ts):
| Keyword | Type-level result | Why |
|---|---|---|
| not | unknown | TypeScript has no type negation |
| if / then / else | Base schema (conditions ignored) | Requires value-dependent conditional evaluation |
| propertyNames | Ignored | Cannot constrain object key shape |
| dependentSchemas / dependentRequired | Ignored | Cross-field runtime conditionals |
| unevaluatedProperties / unevaluatedItems | Ignored | Requires whole-tree evaluation context |
| contains / minContains / maxContains | Element type unchanged | Constraint metadata only |
| $recursiveRef | unknown | Recursive types not expressible |
Runtime rendering and validation handle each of these correctly; only the static type widens.
OpenAPI components
Render API operations with type-safe field overrides:
import {
ApiOperation,
ApiParameters,
ApiRequestBody,
ApiResponse,
} from "schema-components/openapi/components";
const petStore = {
openapi: "3.1.0",
paths: {
"/pets": {
post: {
requestBody: {
content: {
"application/json": {
schema: {
type: "object" as const,
properties: {
name: { type: "string" as const },
tag: { type: "string" as const },
},
required: ["name"],
},
},
},
},
responses: { "201": { description: "Created" } },
},
},
},
} as const;
// Full operation — parameters, request body, responses
<ApiOperation schema={petStore} path="/pets" method="post" />
// Just the request body with type-safe fields
<ApiRequestBody
schema={petStore}
path="/pets"
method="post"
fields={{
name: { description: "Pet name" }, // ✓ inferred from as const
}}
/>
// Just parameters with type-safe overrides
<ApiParameters
schema={petStore}
path="/pets"
method="get"
overrides={{
limit: { description: "Max results" },
}}
/>
// Response schema
<ApiResponse schema={petStore} path="/pets" method="get" status="200" />Theme adapters
Headless by default (plain HTML). Wrap with a theme adapter for styled components:
shadcn/ui
import { SchemaProvider } from "schema-components/react/SchemaComponent";
import { shadcnResolver } from "schema-components/themes/shadcn";
<SchemaProvider resolver={shadcnResolver}>
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
</SchemaProvider>MUI
import { SchemaProvider } from "schema-components/react/SchemaComponent";
import { createMuiResolver } from "schema-components/themes/mui";
import TextField from "@mui/material/TextField";
import Checkbox from "@mui/material/Checkbox";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import MenuItem from "@mui/material/MenuItem";
import FormControlLabel from "@mui/material/FormControlLabel";
const muiResolver = createMuiResolver({
TextField, Checkbox, Typography, Box, MenuItem, FormControlLabel,
});
<SchemaProvider resolver={muiResolver}>
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
</SchemaProvider>Mantine
import { SchemaProvider } from "schema-components/react/SchemaComponent";
import { createMantineResolver } from "schema-components/themes/mantine";
import {
TextInput, NumberInput, Switch, Select, Fieldset, Text,
} from "@mantine/core";
const mantineResolver = createMantineResolver({
TextInput, NumberInput, Switch, Select, Fieldset, Text,
});
<SchemaProvider resolver={mantineResolver}>
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
</SchemaProvider>Radix Themes
import { SchemaProvider } from "schema-components/react/SchemaComponent";
import { createRadixResolver } from "schema-components/themes/radix";
import { Box, Checkbox, Flex, Select, Text, TextField } from "@radix-ui/themes";
const radixResolver = createRadixResolver({
Box,
Checkbox,
Flex,
SelectRoot: Select.Root,
SelectTrigger: Select.Trigger,
SelectContent: Select.Content,
SelectItem: Select.Item,
Text,
TextField: TextField.Root,
});
<SchemaProvider resolver={radixResolver}>
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
</SchemaProvider>Custom adapter
import type { RenderProps, ComponentResolver } from "schema-components/core/renderer";
const myResolver: ComponentResolver = {
string: (props: RenderProps) => {
if (props.readOnly) return <span>{props.value}</span>;
return <input value={props.value} onChange={(e) => props.onChange(e.target.value)} />;
},
object: (props: RenderProps) => {
return (
<div>
{props.fields && Object.entries(props.fields).map(([key, field]) => (
<div key={key}>
<label>{field.meta.description}</label>
{props.renderChild(field, (props.value as Record<string, unknown>)?.[key], (v) => {
props.onChange({ ...(props.value as object), [key]: v });
})}
</div>
))}
</div>
);
},
};Every render function receives props.renderChild for recursive rendering — no need to know about the resolver or rendering context.
Custom widgets
Widgets let you override rendering for specific fields using .meta({ component: name }). Three scopes are available, checked in order:
- Instance —
widgetsprop on<SchemaComponent> - Context —
widgetsprop on<SchemaProvider> - Global —
registerWidget()for app-wide defaults
Global registration
import { registerWidget } from "schema-components/react/SchemaComponent";
registerWidget("richtext", ({ value, onChange }) => (
<RichTextEditor value={value} onChange={onChange} />
));
const schema = z.object({
bio: z.string().meta({ component: "richtext" }),
});Context-scoped widgets
import { SchemaProvider } from "schema-components/react/SchemaComponent";
import type { WidgetMap } from "schema-components/react/SchemaComponent";
const adminWidgets: WidgetMap = new Map([
["richtext", ({ value, onChange }) => <RichTextEditor value={value} onChange={onChange} />],
["avatar", ({ value, onChange }) => <AvatarUploader value={value} onChange={onChange} />],
]);
<SchemaProvider resolver={shadcnResolver} widgets={adminWidgets}>
<SchemaComponent schema={userSchema} value={user} onChange={setUser} />
<SchemaComponent schema={profileSchema} value={profile} onChange={setProfile} />
</SchemaProvider>Instance-scoped widgets
const formWidgets: WidgetMap = new Map([
["richtext", ({ value, onChange }) => <SimpleTextarea value={value} onChange={onChange} />],
]);
<SchemaComponent schema={formSchema} value={form} widgets={formWidgets} />Resolution order
.meta({ component }) hint → instance widgets → context widgets → global registerWidget() → theme adapter → headless defaultWidgetMap type
import type { WidgetMap } from "schema-components/react/SchemaComponent";
// ReadonlyMap<string, (props: RenderProps) => unknown>
const widgets: WidgetMap = new Map([
["name", (props) => <MyInput {...props} />],
]);Server Components: <SchemaView> accepts a widgets prop directly (no React context available):
<SchemaView schema={schema} value={data} widgets={serverWidgets} />Validation
<SchemaComponent
schema={userSchema}
value={user}
onChange={setUser}
validate
onValidationError={(error) => console.error(error)}
/>Validation uses the original Zod schema (if input was Zod) or z.fromJSONSchema() (if input was JSON Schema / OpenAPI).
Per-field validation errors
Add onValidationError to individual field overrides to receive errors for specific fields:
<SchemaComponent
schema={userSchema}
value={user}
onChange={setUser}
validate
fields={{
email: { onValidationError: (err) => setEmailError(err) },
name: { onValidationError: (err) => setNameError(err) },
}}
/>Errors are dispatched based on Zod error paths. The root-level onValidationError still receives all errors.
Field visibility
Hide fields conditionally using the visible override:
<SchemaComponent
schema={paymentSchema}
value={payment}
fields={{
cardNumber: { visible: payment.method === "card" },
sortCode: { visible: payment.method === "bank" },
}}
/>When visible: false, the field is completely removed — no label, no empty placeholder, no hidden input.
Field ordering
Control the order fields appear in rendered objects using order:
<SchemaComponent
schema={userSchema}
value={user}
fields={{
email: { order: 1 },
name: { order: 2 },
role: { order: 3 },
}}
/>Lower order values render first. Fields without order keep their insertion order and appear after ordered fields. Can also be set in schema metadata:
const schema = z.object({
summary: z.string().meta({ order: 1 }),
title: z.string().meta({ order: 2 }),
});Discriminated unions
Discriminated unions (z.discriminatedUnion or JSON Schema oneOf with const properties) render as tabbed panels. Each tab is labelled by the discriminator's const value. Clicking a tab resets the value with the new discriminator.
const payment = z.discriminatedUnion("method", [
z.object({
method: z.literal("card"),
cardNumber: z.string(),
expiry: z.string(),
}),
z.object({
method: z.literal("bank"),
accountNumber: z.string(),
sortCode: z.string(),
}),
]);
<SchemaComponent schema={payment} value={{ method: "card", cardNumber: "4111...", expiry: "12/28" }} />In read-only mode, only the active variant is rendered (no tabs).
Date and time inputs
String schemas with format: "date", format: "time", or format: "date-time" render as the corresponding HTML5 input types:
const eventSchema = z.object({
date: z.string().meta({ format: "date" }),
startTime: z.string().meta({ format: "time" }),
createdAt: z.string().meta({ format: "date-time" }),
});This produces <input type="date">, <input type="time">, and <input type="datetime-local"> respectively. In read-only mode, dates are formatted using toLocaleDateString() / toLocaleString().
Schema defaults
Default values from z.string().default("hello") or JSON Schema "default": "hello" are used when the value prop is undefined:
const schema = z.object({
name: z.string().default("World"),
count: z.number().default(0),
});
// Renders with "World" and 0 pre-filled
<SchemaComponent schema={schema} />Defaults propagate through nested objects — each field uses its own default independently.
File uploads
String schemas with format: "binary" render as <input type="file">. Use contentMediaType to restrict accepted MIME types:
const schema = z.object({
avatar: z.string().meta({ format: "binary" }),
resume: z.string().meta({ format: "binary", contentMediaType: "application/pdf" }),
});In read-only mode, file fields display a static label ("File field") since there is no value to show. The onChange callback receives the File object from the browser.
Server Components
For read-only rendering in a React Server Component, use <SchemaView>. It has zero hooks — no useContext, no useMemo, no useCallback — so it works without the "use client" directive.
import { SchemaView } from "schema-components/react/SchemaView";
export default async function Page() {
const user = await getUser();
return <SchemaView schema={userSchema} value={user} />;
}SchemaView always renders read-only. For editable forms, use <SchemaComponent> (which requires "use client").
Pass the resolver explicitly since React context is unavailable in Server Components:
<SchemaView schema={schema} value={data} resolver={shadcnResolver} />SchemaView produces identical output to <SchemaComponent readOnly> — verified by parity tests.
HTML rendering
Render schemas to HTML strings — no React needed. Useful for server-side rendering, email templates, static sites, and non-React environments.
import { renderToHtml } from "schema-components/html/renderToHtml";
const html = renderToHtml(userSchema, {
value: { name: "Ada Lovelace", email: "[email protected]", role: "admin" },
readOnly: true,
});All HTML output uses sc- prefixed classes for styling hooks. HTML is properly escaped by the serialiser.
A default stylesheet is included:
<link rel="stylesheet" href="node_modules/schema-components/dist/html/styles.css">Or import in JS:
import "schema-components/styles.css";Streaming HTML
Three output formats for incremental rendering:
import { renderToHtmlChunks } from "schema-components/html/renderToHtmlStream";
import { renderToHtmlStream } from "schema-components/html/renderToHtmlStream";
import { renderToHtmlReadable } from "schema-components/html/renderToHtmlStream";
// Sync iterable — chunks yielded at field/item/entry boundaries
const chunks: string[] = [...renderToHtmlChunks(schema, { value })];
// Async iterable — yields control to event loop between chunks
for await (const chunk of renderToHtmlStream(schema, { value })) {
res.write(chunk);
}
// Web ReadableStream — for Response, TransformStream, etc.
return new Response(renderToHtmlReadable(schema, { value }), {
headers: { "Content-Type": "text/html" },
});Structured HTML construction
The HTML renderer uses a typed h() builder instead of string templates:
import { h, serialize, raw } from "schema-components/html/html";
const input = h("input", { type: "text", id: "name", value: userValue });
serialize(input); // → <input type="text" id="name" value="Ada">The builder handles void elements, boolean attributes, fragments, and nested children.
Accessibility
The HTML renderer produces WAI-ARIA-compliant markup:
| Attribute | When |
|---|---|
| id="<key>" | All editable inputs |
| aria-required="true" | Required fields |
| aria-describedby="<id>-hint" | Fields with constraints |
| aria-readonly="true" | Read-only presentation spans |
| aria-label="<description>" | Checkboxes |
| role="group" | Record containers |
Error handling
Typed errors with onError callback for graceful degradation:
import { SchemaErrorBoundary } from "schema-components/react/SchemaErrorBoundary";
// Error boundary catches render errors from theme adapters
<SchemaErrorBoundary fallback={(error, reset) => <p>Error: {error.message}</p>}>
<SchemaComponent schema={schema} value={data} />
</SchemaErrorBoundary>
// Per-component error callback
<SchemaComponent
schema={schema}
value={data}
onError={(error) => {
console.error(error);
return null; // graceful degradation
}}
/>Without onError, errors re-throw. Error hierarchy: SchemaError → SchemaNormalisationError | SchemaRenderError | SchemaFieldError.
