Headless, schema-driven React forms — define your schema once, get validated forms with any UI library.
Define a schema, register renderers, create a form, and render fields — all type-safe. The schema and renderers are defined once per app and reused by every form — individual form components only declare which fields to show:
import { object, string, number } from '@cleverbrush/schema';
import {
useSchemaForm, Field, FormSystemProvider
} from '@cleverbrush/react-form';
// 1. Define your schema once — reuse across forms, API validation, mapping, etc.
const ContactSchema = object({
name: string()
.required('Name is required')
.minLength(2, 'Name must be at least 2 characters')
.maxLength(100, 'Name must be at most 100 characters'),
email: string()
.required('Email is required')
.matches(
/^[^\s@]+@[^\s@]+\.[^\s@]+$/,
'Please enter a valid email address'
),
age: number()
.required('Age is required')
.min(18, 'Must be at least 18 years old')
.max(120, 'Must be at most 120')
});
// 2. Register renderers once at the app root — every form inherits them
const renderers = {
string: ({ value, onChange, onBlur, touched, error }) => (
<div>
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
/>
{touched && error && <span className="error">{error}</span>}
</div>
),
number: ({ value, onChange, onBlur, touched, error }) => (
<div>
<input
type="number"
value={value ?? ''}
onChange={(e) => onChange(Number(e.target.value))}
onBlur={onBlur}
/>
{touched && error && <span className="error">{error}</span>}
</div>
)
};
// 3. Each form component only picks which fields to show — no boilerplate
function ContactForm() {
const form = useSchemaForm(ContactSchema);
const handleSubmit = async () => {
const result = await form.submit();
if (result.valid) {
console.log('Submitted:', result.object);
}
};
return (
<div>
<Field forProperty={(t) => t.name} form={form} />
<Field forProperty={(t) => t.email} form={form} />
<Field forProperty={(t) => t.age} form={form} />
<button onClick={handleSubmit}>Submit</button>
</div>
);
}
// 4. Wrap once at the app root — all forms below share the renderers
function App() {
return (
<FormSystemProvider renderers={renderers}>
<ContactForm />
</FormSystemProvider>
);
}This is the exact form described above, rendered with @cleverbrush/react-form. Try submitting with empty fields, a short name, an invalid email, or an out-of-range age to see validation in action:
Every popular React form library — React Hook Form, Formik, React Final Form — requires you to reference fields by string names: register("email"), <Field name="address.city" />. The moment you pass a field name as a string, you lose TypeScript's type safety. Rename a property in your data model and the compiler stays silent — your form just silently breaks at runtime. The larger your codebase, the more of these invisible string references you accumulate, and the more fragile every refactor becomes.
// React Hook Form — field names are plain strings
const { register } = useForm<User>();
<input {...register("name")} /> // ← no compiler error if "name" is renamed
<input {...register("emial")} /> // ← typo: silently fails at runtime
// Formik — same problem
<Field name="address.city" /> // ← rename "city" → "town" and nothing warns you@cleverbrush/react-form binds fields via PropertyDescriptor selectors — actual TypeScript expressions like (t) => t.address.city — instead of strings. The compiler knows the exact shape of your schema, so a renamed or mistyped property is a compile-time error, not a runtime surprise. On top of that the schema IS the validation, the type definition, AND the form field configuration. One source of truth. Change the schema and everything updates — types, validation rules, and form field behavior.
// @cleverbrush/react-form — fully type-safe selectors
<Field forProperty={(t) => t.name} form={form} /> // ✓ checked at compile time
<Field forProperty={(t) => t.address.city} form={form} /> // ✓ rename "city" → compiler error
<Field forProperty={(t) => t.emial} form={form} /> // ✗ compile error: "emial" doesn't existThe library doesn't render anything. You provide renderer functions — plain HTML, Material UI, Ant Design, or any custom components — via FormSystemProvider. Swap UI libraries by changing one provider and all forms in your app update. This means you can share form logic across different parts of your application that use different UI frameworks.
Fields are bound via type-safe selectors like (t) => t.address.city, not string paths like "address.city". This means refactoring is safe — rename a property and the compiler tells you everywhere that needs updating.
Every form in cleverbrush.com/editor is rendered through @cleverbrush/react-form. It handles dozens of complex forms with nested objects, async validation, and dynamic field visibility in production every day.
@cleverbrush/schema — this is your single source of truth for types, validation rules, and field metadataFormSystemProvider — plain functions that map schema types ("string", "number") to your UI components (plain HTML, MUI, Ant Design, etc.)useSchemaForm(schema) — returns state management, validation, and submit/reset lifecycle<Field forProperty={(t) => t.name}form={form}/> — the component looks up the registered renderer for the field's schema type automaticallyform.submit() runs the schema's full validation and returns a typed result with per-field errors| Concept | What It Does | When to Use |
|---|---|---|
FormSystemProvider | Registers renderers globally (maps schema types like "string", "number" to React components). | Once at the app root. All forms underneath inherit the renderers. |
useSchemaForm | Creates a form instance from a schema. Returns state, validation, submit, reset, and field accessors. | In every component that needs a form. |
useField | Accesses a single field's state and handlers. Works inside a FormProvider context. | When you want fine-grained control over individual fields. |
Field | Renders a field using the registered renderer for its schema type. Supports variant for type-specific rendering (e.g. "password"). Declarative API. | For most form fields — quick and declarative. |
Renderers are plain functions that receive field state and return React nodes. They map schema type names (like "string", "number", "boolean") to UI components. Here are two examples:
const htmlRenderers = {
string: ({ value, onChange, onBlur, error, dirty, label, name, fieldProps }) => (
<div className="field">
{label && <label>{label}</label>}
<input
type="text"
name={name}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
className={error ? 'input-error' : ''}
{...fieldProps}
/>
{dirty && <span className="dirty-indicator">*</span>}
{error && <span className="error">{error}</span>}
</div>
),
number: ({ value, onChange, onBlur, error, label, name, fieldProps }) => (
<div className="field">
{label && <label>{label}</label>}
<input
type="number"
name={name}
value={value ?? ''}
onChange={(e) => onChange(Number(e.target.value))}
onBlur={onBlur}
{...fieldProps}
/>
{error && <span className="error">{error}</span>}
</div>
),
boolean: ({ value, onChange, onBlur, label }) => (
<div className="field">
<label>
<input
type="checkbox"
checked={value ?? false}
onChange={(e) => onChange(e.target.checked)}
onBlur={onBlur}
/>
{label}
</label>
</div>
)
};// Example — requires @mui/material (not included in this demo)
const muiRenderers = {
string: ({ value, onChange, onBlur, error }) => (
<TextField
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
error={!!error}
helperText={error}
fullWidth
/>
),
number: ({ value, onChange, onBlur, error }) => (
<TextField
type="number"
value={value ?? ''}
onChange={(e) => onChange(Number(e.target.value))}
onBlur={onBlur}
error={!!error}
helperText={error}
fullWidth
/>
)
};
// Swap UI libraries by changing the provider — all forms update!
<FormSystemProvider renderers={muiRenderers}>
{/* All Field components now render Material UI inputs */}
</FormSystemProvider>Register variant-specific renderers with a "type:variant" key. When <Field variant="password" /> is rendered on a string field, the registry is checked for "string:password" first, then falls back to "string":
const renderers = {
// Default string renderer
string: ({ value, onChange, onBlur, error, label, name, fieldProps }) => (
<div>
{label && <label>{label}</label>}
<input type="text" name={name} value={value ?? ''}
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
{...fieldProps} />
{error && <span className="error">{error}</span>}
</div>
),
// Password variant — used when <Field variant="password" />
'string:password': ({ value, onChange, onBlur, error, label, name, fieldProps }) => (
<div>
{label && <label>{label}</label>}
<input type="password" name={name} value={value ?? ''}
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
{...fieldProps} />
{error && <span className="error">{error}</span>}
</div>
),
// Textarea variant
'string:textarea': ({ value, onChange, onBlur, error, label, name, fieldProps }) => (
<div>
{label && <label>{label}</label>}
<textarea name={name} value={value ?? ''}
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
{...fieldProps} />
{error && <span className="error">{error}</span>}
</div>
)
};
// Usage:
<Field forProperty={(t) => t.name} form={form} label="Name" />
<Field forProperty={(t) => t.password} form={form}
variant="password" label="Password"
fieldProps={{ placeholder: 'Enter password', autoComplete: 'current-password' }} />
<Field forProperty={(t) => t.bio} form={form}
variant="textarea" label="Bio"
fieldProps={{ rows: 4 }} />FormSystemProvider is a React context provider that makes renderers available to all Field components underneath it. Place it once at the root of your app:
import { FormSystemProvider } from '@cleverbrush/react-form';
function App() {
return (
<FormSystemProvider renderers={myRenderers}>
{/* All forms in your app go here */}
</FormSystemProvider>
);
}You can nest providers to override renderers in specific parts of your app. Child providers merge with parent renderers, so you only need to specify the overrides.
The main hook for creating form instances. Returns everything you need to manage form state:
const form = useSchemaForm(MySchema, {
createMissingStructure: true, // default: true
validateOnMount: false, // default: false — set to true to show errors on mount
validationDebounceMs: 300 // optional — debounce onChange validation (ms)
});| Method / Property | Description |
|---|---|
form.submit() | Validates and returns ValidationResult. Call this on form submission. |
form.validate() | Runs validation without submitting. Useful for "validate on blur" patterns. |
form.reset(values?) | Resets the form to initial values (or provided values). Clears dirty/touched state. |
form.getValue() | Returns the current form values as a typed object. |
form.setValue(values) | Sets form values programmatically (partial update). |
form.useField(forProperty) | Hook to access a specific field's state. Type-safe via PropertyDescriptor selector. |
Access individual field state and handlers. Can be used via form.useField(forProperty) directly or via the context-based useField(forProperty) inside a FormProvider.
| Property | Type | Description |
|---|---|---|
value | T | undefined | Current field value |
initialValue | T | undefined | Value at form creation / last reset |
dirty | boolean | True if value differs from initialValue |
touched | boolean | True after the field has been blurred |
error | string | undefined | Current validation error message (if any) |
validating | boolean | True while async validation is running |
onChange(value) | (T) => void | Update the field value (triggers re-render) |
onBlur() | () => void | Mark the field as touched (triggers validation) |
setValue(value) | (T) => void | Programmatically set the value (alias for onChange) |
schema | SchemaBuilder | The schema builder for this field (for introspection) |
You don't have to use the Field component at all. Use form.useField() directly for full control over rendering:
function MyForm() {
const form = useSchemaForm(ContactSchema);
const name = form.useField((t) => t.name);
const email = form.useField((t) => t.email);
return (
<div>
<label>
Name
<input
value={name.value ?? ''}
onChange={(e) => name.onChange(e.target.value)}
onBlur={name.onBlur}
/>
{name.touched && name.error && (
<span className="error">{name.error}</span>
)}
</label>
<label>
Email
<input
value={email.value ?? ''}
onChange={(e) => email.onChange(e.target.value)}
onBlur={email.onBlur}
/>
{email.dirty && <span>(modified)</span>}
{email.error && <span className="error">{email.error}</span>}
</label>
<button onClick={() => form.submit()}>Submit</button>
</div>
);
}The Fieldcomponent is a declarative way to render form fields. It looks up the registered renderer for the field's schema type and passes the field state to it. Use variant for type-specific rendering, and label, name, fieldProps to forward additional rendering hints:
// Uses the renderer registered for "string" schema type
<Field forProperty={(t) => t.name} form={form} />
// Variant-based resolution: looks up "string:password", falls back to "string"
<Field forProperty={(t) => t.password} form={form} variant="password" />
// With label, name, and extra props for the renderer
<Field
forProperty={(t) => t.email}
form={form}
label="Email address"
name="email"
fieldProps={{ placeholder: 'you@example.com', autoComplete: 'email' }}
/>
// Override the renderer for a specific field
<Field
forProperty={(t) => t.email}
form={form}
renderer={({ value, onChange, error }) => (
<div>
<input type="email" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
{error && <span>{error}</span>}
</div>
)}
/>| Prop | Type | Description |
|---|---|---|
forProperty | (tree) => PropertyDescriptor | Type-safe property selector (e.g. (t) => t.name) |
form | SchemaFormInstance | The form instance from useSchemaForm |
renderer | FieldRenderer | Optional override renderer for this specific field |
variant | string | Variant hint for renderer resolution (e.g. "password", "textarea"). Checked as "type:variant" in the registry, falls back to base type. Also forwarded to the renderer. |
label | string | Visible label text forwarded to the renderer |
name | string | HTML name attribute forwarded to the renderer for FormData submission |
fieldProps | Record<string, unknown> | Extra renderer-specific props (e.g. placeholder, autoComplete) |
FormProvider puts a form instance into React context, allowing child components to use the context-based useField hook without passing the form prop explicitly:
import { FormProvider, useField } from '@cleverbrush/react-form';
function NameField() {
// No need to pass form prop — reads from context
const name = useField((t) => t.name);
return (
<input
value={name.value ?? ''}
onChange={(e) => name.onChange(e.target.value)}
onBlur={name.onBlur}
/>
);
}
function MyForm() {
const form = useSchemaForm(ContactSchema);
return (
<FormProvider form={form}>
<NameField />
{/* other fields */}
</FormProvider>
);
}PropertyDescriptor selectors support nested paths. Access deeply nested fields with dot notation in the forProperty accessor:
const AddressSchema = object({
city: string().minLength(1),
street: string().minLength(1),
zip: string().minLength(5).maxLength(10)
});
const UserSchema = object({
name: string().minLength(2),
address: AddressSchema
});
function UserForm() {
const form = useSchemaForm(UserSchema);
return (
<div>
<Field forProperty={(t) => t.name} form={form} />
<Field forProperty={(t) => t.address.city} form={form} />
<Field forProperty={(t) => t.address.street} form={form} />
<Field forProperty={(t) => t.address.zip} form={form} />
</div>
);
}Validation is built into the schema — you don't configure it separately. Here's how it works step by step:
.addValidator() run next (can be async)useField().errorconst RegistrationSchema = object({
username: string().minLength(3).maxLength(20).addValidator(async (value) => {
// Check if username is taken (async!)
const taken = await checkUsernameAvailability(value);
if (taken) {
return { valid: false, errors: [{ message: 'This username is already taken' }] };
}
return { valid: true };
}),
password: string().minLength(8, 'Password must be at least 8 characters'),
confirmPassword: string().minLength(8, 'Please confirm your password')
}).addValidator(async (value) => {
// Object-level validation
if (value.password !== value.confirmPassword) {
return { valid: false, errors: [{ message: 'Passwords do not match' }] };
}
return { valid: true };
});| Feature | @cleverbrush/react-form | React Hook Form | Formik | React Final Form |
|---|---|---|---|---|
| Schema-driven validation | ✓ built-in | ~ via resolver | ~ via plugin | ✗ |
| Single source of truth (types + validation) | ✓ | ✗ | ✗ | ✗ |
| Headless / UI-agnostic | ✓ | ✓ | ~ | ✓ |
| PropertyDescriptor introspection | ✓ | ✗ | ✗ | ✗ |
| Immutable schema integration | ✓ | ✗ | ✗ | ✗ |
| Nested objects | ✓ | ✓ | ✓ | ✓ |
| Zero extra dependencies | ✓ | ✓ | ✗ | ✓ |
| Global renderer system | ✓ | ✗ | ✗ | ✗ |
| Type-safe field selectors | ✓ | ~ | ✗ | ✗ |
| Auto-field rendering by type | ✓ | ✗ | ✗ | ✗ |
| Async validation support | ✓ | ✓ | ✓ | ✓ |
| Hook | Description |
|---|---|
useSchemaForm(schema, opts?) | Creates a form instance from a schema. Returns form state, handlers, and field accessors. |
useField(forProperty) | Access a single field's state and handlers inside a FormProvider context. |
useFormSystem() | Access the form system context (shared renderers, config). |
| Component | Description |
|---|---|
<FormSystemProvider> | Top-level provider for shared form configuration (custom renderers, defaults). |
<FormProvider> | Provides a specific form instance to child components via context. |
<Field> | Renders a form field using the registered renderer for its schema type. |
| Type | Description |
|---|---|
FieldRenderProps | Props passed to renderer functions: value, onChange, onBlur, error, dirty, touched, validating, setValue, schema, variant, label, name, fieldProps. |
FieldRenderer | Function type: (props: FieldRenderProps) => ReactNode |
SchemaFormInstance | Return type of useSchemaForm. Contains submit, validate, reset, getValue, setValue, useField. |
UseFieldResult | Return type of useField. Contains value, error, dirty, touched, validating, onChange, onBlur, setValue, schema. |
FormSystemConfig | Configuration object for FormSystemProvider: renderers record. Keys can be "type" or "type:variant". |