A headless, schema-driven form system for React based on @cleverbrush/schema. Uses PropertyDescriptors for type-safe field binding, supports global UI renderer configuration via a provider, and is completely UI-agnostic — works with plain HTML, MUI, Ant Design, or any component library.
The problem: 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
The solution: @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.
// @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 exist
What makes it different:
| 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) | ✓ | ✗ | ✗ | ✗ |
| Type-safe field selectors | ✓ | ~ | ✗ | ✗ |
| Headless / UI-agnostic | ✓ | ✓ | ~ | ✓ |
| Global renderer system | ✓ | ✗ | ✗ | ✗ |
| Auto-field rendering by type + variant | ✓ | ✗ | ✗ | ✗ |
| Nested objects | ✓ | ✓ | ✓ | ✓ |
| Async validation | ✓ | ✓ | ✓ | ✓ |
npm install @cleverbrush/react-form
Peer dependencies: react >=18, @cleverbrush/schema ^2.0.0
import { object, string, number } from '@cleverbrush/schema';
import { useSchemaForm, FormSystemProvider, Field } from '@cleverbrush/react-form';
// 1. Define schema — 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'),
email: string().required('Email is required'),
age: number().required('Age is required').min(18, 'Must be at least 18')
});
// 2. Define renderers once per app — maps schema types to UI components
const renderers = {
string: ({ value, onChange, onBlur, error, touched }) => (
<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, error, touched }) => (
<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>
);
}
@cleverbrush/schema — this is your single source of truth for types, validation rules, and field metadataFormSystemProvider — plain functions that map schema types ("string", "number", "boolean") to your UI components (plain HTML, MUI, Ant Design, etc.)useSchemaForm(schema) — returns state management, validation, submit/reset lifecycle<Field forProperty={(t) => t.name} form={form} /> — the component looks up the registered renderer for the field's schema typeform.submit() runs the schema's full validation and returns a typed result| Part | Responsibility | When to Use |
|---|---|---|
| FormSystemProvider | Global renderer registry via React Context | Once at the app root |
| useSchemaForm | Per-schema form instance (state, validation, lifecycle) | In every component that needs a form |
| useField | Descriptor-based field binding (value, dirty, touched, error) | When you want fine-grained control |
| Field | UI-agnostic component that resolves renderers by schema type | For most form fields — quick and declarative |
Renderers are plain functions that receive field state and return React nodes. Define a renderer map keyed by schema type (string, number, boolean, etc.):
import { FieldRenderProps } from '@cleverbrush/react-form';
const htmlRenderers = {
string: ({ value, onChange, onBlur, error, touched, label, name, fieldProps }: FieldRenderProps) => (
<div>
{label && <label>{label}</label>}
<input
type="text"
name={name}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
{...fieldProps}
/>
{touched && error && <span className="error">{error}</span>}
</div>
),
number: ({ value, onChange, onBlur, error, touched, label, name, fieldProps }: FieldRenderProps) => (
<div>
{label && <label>{label}</label>}
<input
type="number"
name={name}
value={value ?? ''}
onChange={(e) => onChange(Number(e.target.value))}
onBlur={onBlur}
{...fieldProps}
/>
{touched && error && <span className="error">{error}</span>}
</div>
),
boolean: ({ value, onChange, label }: FieldRenderProps) => (
<label>
<input
type="checkbox"
checked={value ?? false}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
</label>
)
};
You can register renderers for specific variants using 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, touched, label, name, fieldProps }: FieldRenderProps) => (
<div>
{label && <label>{label}</label>}
<input type="text" name={name} value={value ?? ''}
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
{...fieldProps} />
{touched && error && <span className="error">{error}</span>}
</div>
),
// Password variant — rendered when <Field variant="password" /> is used on a string field
'string:password': ({ value, onChange, onBlur, error, touched, label, name, fieldProps }: FieldRenderProps) => (
<div>
{label && <label>{label}</label>}
<input type="password" name={name} value={value ?? ''}
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
{...fieldProps} />
{touched && error && <span className="error">{error}</span>}
</div>
),
// Textarea variant
'string:textarea': ({ value, onChange, onBlur, error, touched, label, name, fieldProps }: FieldRenderProps) => (
<div>
{label && <label>{label}</label>}
<textarea name={name} value={value ?? ''}
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
{...(fieldProps as any)} />
{touched && error && <span className="error">{error}</span>}
</div>
)
};
import { TextField, Checkbox } from '@mui/material';
const muiRenderers = {
string: ({ value, onChange, onBlur, error, touched }: FieldRenderProps) => (
<TextField
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
error={touched && !!error}
helperText={touched ? error : undefined}
/>
),
number: ({ value, onChange, onBlur, error, touched }: FieldRenderProps) => (
<TextField
type="number"
value={value ?? ''}
onChange={(e) => onChange(Number(e.target.value))}
onBlur={onBlur}
error={touched && !!error}
helperText={touched ? error : undefined}
/>
),
boolean: ({ value, onChange }: FieldRenderProps) => (
<Checkbox
checked={value ?? false}
onChange={(e) => onChange(e.target.checked)}
/>
)
};
Register renderers once at the top of your app. All <Field> components below resolve renderers by schema type automatically:
import { FormSystemProvider } from '@cleverbrush/react-form';
// Public website with plain HTML inputs
<FormSystemProvider renderers={htmlRenderers}>
<PublicApp />
</FormSystemProvider>
// Admin panel using MUI
<FormSystemProvider renderers={muiRenderers}>
<AdminApp />
</FormSystemProvider>
Inner providers override/extend outer providers:
<FormSystemProvider renderers={muiRenderers}>
<MainApp />
{/* Override just the string renderer in this section */}
<FormSystemProvider renderers={{ string: customStringRenderer }}>
<SpecialSection />
</FormSystemProvider>
</FormSystemProvider>
Creates a form instance bound to a schema. Returns field binding and form lifecycle methods:
const form = useSchemaForm(UserSchema, {
createMissingStructure: true, // default: true — auto-create parent objects when setting nested values
validateOnMount: false, // default: false — set to true to show errors immediately on mount
validationDebounceMs: 300 // optional — debounce onChange validation (ms); validate()/submit() are always immediate
});
| Method | Description |
|---|---|
form.useField(forProperty) |
Bind a field by PropertyDescriptor selector |
form.submit() |
Validate and return ValidationResult (includes result.object on success) |
form.validate() |
Run validation, propagate errors to fields |
form.reset(values?) |
Reset all fields; optionally set new initial values |
form.getValue() |
Get current form values as plain object |
form.setValue(values) |
Merge values into form state |
Binds a single field via PropertyDescriptor selector. Can be used via form.useField() or the context-based standalone useField():
// Via form instance
const name = form.useField((t) => t.name);
const city = form.useField((t) => t.address.city);
// Or via context (inside a FormProvider)
const name = useField((t) => t.name);
| Property | Type | Description |
|---|---|---|
value |
T | undefined |
Current field value |
initialValue |
T | undefined |
Value at form init / last reset |
dirty |
boolean |
true if value differs from initialValue |
touched |
boolean |
true after onBlur has been called |
error |
string | undefined |
Validation error message from schema |
validating |
boolean |
true during async validation |
onChange(value) |
(T) => void |
Update field value |
onBlur() |
() => void |
Mark field as touched |
setValue(value) |
(T) => void |
Alias for onChange |
schema |
SchemaBuilder |
The field's schema builder |
Resolves the renderer from the FormSystemProvider registry by schema type (and optional variant), or uses an explicit renderer prop:
// Auto-resolved from FormSystemProvider (string schema → string renderer)
<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' }}
/>
// Explicit renderer override
<Field forProperty={(t) => t.name} form={form} renderer={customRenderer} />
| Prop | Type | Description |
|---|---|---|
forProperty |
(tree) => PropertyDescriptor |
PropertyDescriptor selector for the field |
form |
SchemaFormInstance |
Form instance from useSchemaForm |
renderer? |
FieldRenderer |
Optional explicit renderer (overrides provider) |
variant? |
string |
Variant hint for renderer resolution and forwarded to the renderer |
label? |
string |
Visible label text forwarded to the renderer |
name? |
string |
HTML name attribute forwarded to the renderer |
fieldProps? |
Record<string, unknown> |
Extra renderer-specific props (e.g. placeholder, autoComplete) |
For full control over rendering, use form.useField() directly:
function UserForm() {
const form = useSchemaForm(UserSchema);
const name = form.useField((t) => t.name);
const email = form.useField((t) => t.email);
return (
<>
<input
value={name.value ?? ''}
onChange={(e) => name.onChange(e.target.value)}
onBlur={name.onBlur}
/>
{name.touched && name.error && <span>{name.error}</span>}
<input
value={email.value ?? ''}
onChange={(e) => email.onChange(e.target.value)}
onBlur={email.onBlur}
/>
<button onClick={() => form.submit()}>Submit</button>
</>
);
}
Context bridge that allows standalone useField() usage outside of form.useField():
import { FormProvider, useField } from '@cleverbrush/react-form';
function NameInput() {
const name = useField((t) => t.name);
return <input value={name.value ?? ''} onChange={(e) => name.onChange(e.target.value)} />;
}
function UserForm() {
const form = useSchemaForm(UserSchema);
return (
<FormProvider form={form}>
<NameInput />
</FormProvider>
);
}
PropertyDescriptor selectors support nested paths:
const UserSchema = object({
name: string(),
address: object({
city: string(),
zip: number()
})
});
function UserForm() {
const form = useSchemaForm(UserSchema);
return (
<>
<Field forProperty={(t) => t.name} form={form} />
<Field forProperty={(t) => t.address.city} form={form} />
<Field forProperty={(t) => t.address.zip} form={form} />
</>
);
}
Validation uses @cleverbrush/schema validators. Errors are automatically propagated to the corresponding field state:
const SignupSchema = object({
username: string().addValidator(async (val) => {
if (val.length < 3) {
return {
valid: false,
errors: [{ message: 'Username must be at least 3 characters' }]
};
}
return { valid: true };
}),
email: string()
});
function SignupForm() {
const form = useSchemaForm(SignupSchema);
return (
<FormSystemProvider renderers={htmlRenderers}>
<Field forProperty={(t) => t.username} form={form} />
<Field forProperty={(t) => t.email} form={form} />
<button onClick={async () => {
const result = await form.submit();
if (result.valid) {
console.log('Success:', result.object);
}
}}>Submit</button>
</FormSystemProvider>
);
}
form.validate() or form.submit() runs the schema's full validationgetErrorsFor() using PropertyDescriptorserror state is updated automaticallyerror string and touched boolean to decide how/when to display errorsHere's a complete, realistic example showing all pieces together — a registration form with nested address, custom validation, and MUI renderers:
import { object, string, number } from '@cleverbrush/schema';
import { useSchemaForm, FormSystemProvider, Field } from '@cleverbrush/react-form';
import { TextField } from '@mui/material';
// Schema — single source of truth for types, validation, and form fields
const RegistrationSchema = object({
name: string().required('Name is required').minLength(2, 'Too short'),
email: string().required('Email is required').matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email'),
age: number().required('Age is required').min(18, 'Must be 18+'),
address: object({
city: string().required('City is required'),
zip: string().required('ZIP is required').minLength(5, 'Invalid ZIP')
})
});
// Renderers — define once, reuse everywhere
const renderers = {
string: ({ value, onChange, onBlur, error, touched }) => (
<TextField
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
error={touched && !!error}
helperText={touched ? error : undefined}
fullWidth
margin="normal"
/>
),
number: ({ value, onChange, onBlur, error, touched }) => (
<TextField
type="number"
value={value ?? ''}
onChange={(e) => onChange(Number(e.target.value))}
onBlur={onBlur}
error={touched && !!error}
helperText={touched ? error : undefined}
fullWidth
margin="normal"
/>
)
};
// Form component — just declare which fields to show
function RegistrationForm() {
const form = useSchemaForm(RegistrationSchema);
return (
<div>
<Field forProperty={(t) => t.name} form={form} />
<Field forProperty={(t) => t.email} form={form} />
<Field forProperty={(t) => t.age} form={form} />
<Field forProperty={(t) => t.address.city} form={form} />
<Field forProperty={(t) => t.address.zip} form={form} />
<button onClick={async () => {
const result = await form.submit();
if (result.valid) {
console.log('Registered:', result.object);
}
}}>Register</button>
</div>
);
}
// App — wrap with provider
function App() {
return (
<FormSystemProvider renderers={renderers}>
<RegistrationForm />
</FormSystemProvider>
);
}
| Export | Type | Description |
|---|---|---|
FormSystemProvider |
Component | Global renderer registry provider |
FormProvider |
Component | Form context bridge for standalone useField |
Field |
Component | Auto-rendered field by schema type |
useSchemaForm |
Hook | Create a form instance from schema |
useField |
Hook | Context-based field binding (use inside FormProvider) |
useFormSystem |
Hook | Access FormSystemProvider config |
| Type | Description |
|---|---|
FieldRenderer |
(props: FieldRenderProps) => ReactNode |
FieldRenderProps |
Props passed to renderers: value, initialValue, dirty, touched, error, validating, onChange, onBlur, setValue, schema, variant?, label?, name?, fieldProps? |
FormSystemConfig |
{ renderers?: Record<string, FieldRenderer> } — keys can be "type" or "type:variant" |
FieldState |
{ value, initialValue, dirty, touched, error, validating } |
UseFieldResult |
FieldState & { onChange, onBlur, setValue, schema } |
UseSchemaFormOptions |
{ createMissingStructure?: boolean; validateOnMount?: boolean; validationDebounceMs?: number } |
SchemaFormInstance |
Return type of useSchemaForm |
FormSystemProviderProps |
Props for FormSystemProvider |
FormProviderProps |
Props for FormProvider |
FieldProps |
Props for Field |
.github/workflows/ci.ymlBSD-3-Clause