@cleverbrush/react-form

Headless, schema-driven React forms — define your schema once, get validated forms with any UI library.

$npm install @cleverbrush/react-form

Requires @cleverbrush/schema and react as peer dependencies.

Quick Start

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>
  );
}

Try it live

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:

💡 Why @cleverbrush/react-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 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. 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 exist

Headless Architecture

The 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.

PropertyDescriptor Selectors

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.

Production Tested

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.

How It Works — Step by Step

  1. Define a schema using @cleverbrush/schema — this is your single source of truth for types, validation rules, and field metadata
  2. Register renderers via FormSystemProvider — plain functions that map schema types ("string", "number") to your UI components (plain HTML, MUI, Ant Design, etc.)
  3. Create a form instance via useSchemaForm(schema) — returns state management, validation, and submit/reset lifecycle
  4. Render fields via <Field forProperty={(t) => t.name}form={form}/> — the component looks up the registered renderer for the field's schema type automatically
  5. Submitform.submit() runs the schema's full validation and returns a typed result with per-field errors

Core Concepts

ConceptWhat It DoesWhen to Use
FormSystemProviderRegisters renderers globally (maps schema types like "string", "number" to React components).Once at the app root. All forms underneath inherit the renderers.
useSchemaFormCreates a form instance from a schema. Returns state, validation, submit, reset, and field accessors.In every component that needs a form.
useFieldAccesses a single field's state and handlers. Works inside a FormProvider context.When you want fine-grained control over individual fields.
FieldRenders 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.

Registering Renderers

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:

Plain HTML Renderers

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>
  )
};

Material UI Renderers (Example)

// 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>

Variant Renderers

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

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.

useSchemaForm

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)
});

Returned API

Method / PropertyDescription
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.

useField

Access individual field state and handlers. Can be used via form.useField(forProperty) directly or via the context-based useField(forProperty) inside a FormProvider.

Returned State

PropertyTypeDescription
valueT | undefinedCurrent field value
initialValueT | undefinedValue at form creation / last reset
dirtybooleanTrue if value differs from initialValue
touchedbooleanTrue after the field has been blurred
errorstring | undefinedCurrent validation error message (if any)
validatingbooleanTrue while async validation is running
onChange(value)(T) => voidUpdate the field value (triggers re-render)
onBlur()() => voidMark the field as touched (triggers validation)
setValue(value)(T) => voidProgrammatically set the value (alias for onChange)
schemaSchemaBuilderThe schema builder for this field (for introspection)

Headless Usage

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>
  );
}

Field Component

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>
  )}
/>

Props

PropTypeDescription
forProperty(tree) => PropertyDescriptorType-safe property selector (e.g. (t) => t.name)
formSchemaFormInstanceThe form instance from useSchemaForm
rendererFieldRendererOptional override renderer for this specific field
variantstringVariant 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.
labelstringVisible label text forwarded to the renderer
namestringHTML name attribute forwarded to the renderer for FormData submission
fieldPropsRecord<string, unknown>Extra renderer-specific props (e.g. placeholder, autoComplete)

FormProvider

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>
  );
}

Nested Objects

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

Validation is built into the schema — you don't configure it separately. Here's how it works step by step:

  1. Schema constraints (minLength, max, matches, etc.) are checked first
  2. Custom validators added via .addValidator() run next (can be async)
  3. Per-field errors are extracted and made available via useField().error
  4. form.submit() triggers full validation and returns the result

Custom Validator Example

const 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 };
});

Comparison with Alternatives

Feature@cleverbrush/react-formReact Hook FormFormikReact 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

API Reference

Hooks

HookDescription
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).

Components

ComponentDescription
<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.

Types

TypeDescription
FieldRenderPropsProps passed to renderer functions: value, onChange, onBlur, error, dirty, touched, validating, setValue, schema, variant, label, name, fieldProps.
FieldRendererFunction type: (props: FieldRenderProps) => ReactNode
SchemaFormInstanceReturn type of useSchemaForm. Contains submit, validate, reset, getValue, setValue, useField.
UseFieldResultReturn type of useField. Contains value, error, dirty, touched, validating, onChange, onBlur, setValue, schema.
FormSystemConfigConfiguration object for FormSystemProvider: renderers record. Keys can be "type" or "type:variant".