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.
Return { ok: false, error: string, issues } from handleSubmit to apply a
submission message and field issues together. An issue is plain
{ pointer: string, detail: string } data: pointer is a form-relative JSON
Pointer, not a dotted path. '' means a form-level error. No HTTP, client, or
Next.js dependency is needed by this package.
The API contract, server handler, action and component remain separate. The
following assumes your application exports a configured typed client for
api; authentication, persistence and safe fallback messages stay application-owned.
// profile-contract.ts (shared)
import { object, string } from '@cleverbrush/schema';
import { defineApi, endpoint } from '@cleverbrush/server/contract';
export const Profile = object({ name: string().minLength(2) });
export const api = defineApi({ profiles: {
save: endpoint.post('/profiles').body(Profile).responses({ 200: Profile })
} });// profile-handler.ts (server)
import type { Handler } from '@cleverbrush/server';
import { api } from './profile-contract';
export const saveProfileHandler: Handler<typeof api.profiles.save> =
async ({ body }) => body; // Replace with application persistence.
// Register this handler with your application's implementation scope.// save-profile.ts (server action / application boundary)
import { decodeValidationIssues } from '@cleverbrush/client';
import type { InferType } from '@cleverbrush/schema';
import { Profile } from './profile-contract';
import { client } from './api-client';
export async function saveProfile(values: InferType<typeof Profile>) {
try {
return { ok: true as const, data: await client.profiles.save({ body: values }) };
} catch (error) {
const issues = decodeValidationIssues(error, { source: 'body' });
if (issues) return { ok: false as const, error: 'Check your input.', issues };
// Add application-owned handling for expected general errors here.
throw error;
}
}// ProfileForm.tsx (browser; add your framework's client directive if needed)
import { useSchemaForm } from '@cleverbrush/react-form';
import { Profile } from './profile-contract';
import { saveProfile } from './save-profile';
export function ProfileForm() {
const form = useSchemaForm(Profile);
const name = form.useField(t => t.name);
return <form onSubmit={form.handleSubmit(saveProfile)}>
<label>Name <input value={name.value ?? ''}
onChange={e => name.onChange(e.target.value)} onBlur={name.onBlur}
aria-invalid={name.touched && !!name.error} aria-describedby="name-error" /></label>
<span id="name-error">{name.touched && name.error}</span>
{form.error && <p role="alert">{form.error}</p>}
<button disabled={form.submitting}>Save</button>
</form>;
}Ordinary shared-schema errors are already caught locally. This bridge handles
structured server rejections (including schema-version differences); it does
not manufacture field errors from business-error strings. Never serialize an
ApiError instance to the browser. Only its decoded issue data crosses the action
boundary. A form with different property names must explicitly map the returned
pointers to its own paths before returning the failure result.
Use form.setIssues(issues) for custom flows; it replaces the external issue set.
form.setIssues([]) clears only external errors, not local schema errors. Prefer
returned issues for asynchronous submissions: handleSubmit ignores stale issues
after any value update, reset or unmount. Manual callers own that race protection.
const form = useSchemaForm(object({ addresses: array(object({ city: string() })) }));
const city = form.useField(t => t.addresses[0].city); // string | undefined
form.setIssues([{ pointer: '/addresses/0/city', detail: 'Choose another city.' }]);
// Equivalent with Field / a typed form system:
// <Field form={form} forProperty={t => t.addresses[0].city} />- Matched fields become touched and show their first external message through
field.error. External messages take precedence until cleared. - Local validation, including debounced validation, does not erase external issues on unchanged fields. Editing clears related ancestor/descendant issues, not unrelated fields. Root issues clear when values change.
setValueclears issues for changed values. Replacing/reordering an array clears its indexed issues: indices are positions, not stable item identities.- Reset and the next submission clear all external issues. Existing string-only submission errors continue to work and clear on reset/resubmission.
- Root, unknown, and currently unbound field issues are included in
form.erroralongside the application message, joined by newlines. Render that summary even when using inline messages. Binding/unbinding a field updates the summary. - Indexed selectors support object, primitive and nested arrays, but are not
native arrays: no
push, negative indices, or add/remove/reorder UI helpers.
JSON Pointer escaping preserves distinct names: /a.b, /a/b, /a~1b, and
/a~0b target a dotted name, a nested field, a slash in a name, and a tilde in a
name respectively. Unknown paths never create values or mutate the form.
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 youThe 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 existWhat 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-formPeer dependencies: react >=18, @cleverbrush/schema ^2.0.0
For typed renderer props/variants and managed submission, see the new consumer examples and migration guide. The provider-based APIs below remain supported.
Typed fields and headless form.useField() accept properties with schema
defaults, including enums, booleans, arrays, and nullable values. A default
does not erase the property's inferred type. Defaults are applied during
validation; call form.reset(values) to establish a visible clean baseline.
reset() still clears the store rather than restoring schema defaults.
Shared UI packages can directly export createFormSystem(...) results and
system.Field while emitting TypeScript declarations. The named
TypedFormSystem and TypedFieldComponent types preserve the registry's
field/variant/props checks across package boundaries.
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>
);
}- Define a schema using
@cleverbrush/schema— this is your single source of truth for types, validation rules, and field metadata - Register renderers via
FormSystemProvider— plain functions that map schema types ("string","number","boolean") to your UI components (plain HTML, MUI, Ant Design, etc.) - Create a form instance via
useSchemaForm(schema)— returns state management, validation, submit/reset lifecycle - Render fields via
<Field forProperty={(t) => t.name} form={form} />— the component looks up the registered renderer for the field's schema type - Submit —
form.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?) |
Clear values, or establish supplied values as a clean baseline; synchronize mounted fields and clear errors/touched/dirty/validation |
form.handleSubmit(onValid, options?) |
Awaitable event handler: validate, prevent duplicate submits, handle success/failure results |
form.submitting |
Reactive, read-only pending state from validation through callbacks |
form.error |
Reactive, read-only submission error; cleared on reset or the next attempt |
form.getValue() |
Get current form values as plain object |
form.setValue(values) |
Shallow-merge values and synchronize mounted fields without marking touched |
Form snapshots and dirty checks use deepClone and deepEqual from
@cleverbrush/deep. Plain objects, arrays and Dates are cloned
on reset/setters; changing caller-owned input cannot change their baseline. Cycles,
shared references, sparse arrays and null prototypes are preserved. Replacing a
value with structurally equal data clears dirty, even with different sharing of
child references. Files and other opaque objects retain identity: a different File
is dirty even if its name/content match. Treat returned snapshots and opaque values
as read-only; update through setters rather than mutating them in place.
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: '[email protected]', 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()orform.submit()runs the schema's full validation- Per-property errors are resolved via
getErrorsFor()using PropertyDescriptors - Each field's
errorstate is updated automatically - Renderers receive the
errorstring andtouchedboolean to decide how/when to display errors
Here'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 |
- Linting: Biome — enforced on every PR via CI
- Type checking: TypeScript strict mode — field selectors and form state are fully typed end-to-end
- Unit tests: Vitest + Testing Library — covering form state management, validation, async validators, field rendering, and provider configuration
- CI: Every pull request must pass lint + build + test before merge — see
.github/workflows/ci.yml
BSD-3-Clause