npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@uniform-ts/core

v0.0.4

Published

UniForm – auto-render forms from Zod schemas

Downloads

473

Readme

UniForm

Headless React + Zod V4 form library. Zero styles — bring your own components.

UniForm takes a Zod schema and automatically renders a fully customizable form. It handles introspection, validation, coercion, and layout — you provide the components and styling.

Features

  • Schema-driven — define your form once with Zod V4, get inputs, labels, validation, and types for free
  • Headless — zero CSS, zero opinions; bring your own design system
  • Full Zod V4 support — scalars, enums, objects, arrays, optionals, nullables, defaults, pipes/transforms, unions, discriminated unions
  • react-hook-form under the hood — performant, uncontrolled forms with zodResolver
  • createForm() / UniForm — type-safe form definition object that lives outside React; attach typed setOnChange handlers per field with access to all form methods
  • Per-field onChange in fields prop — react to individual field changes inline, with typed values and full form control methods
  • Per-field custom components — pass any React.ComponentType<FieldProps> directly as meta.component (inline, no registry) or register under a custom string key; direct components bypass the registry and the default ArrayField/ObjectField routing, allowing fully custom multi-value widgets for array-typed fields
  • Layout hooksclassNames, fieldWrapper, layout.formWrapper, layout.sectionWrapper, layout.submitButton
  • Section grouping — group fields into named sections via meta.section; style or swap individual section wrappers via layout.sections
  • Conditional fields — show/hide fields based on form values with meta.condition; hidden fields automatically reset to their default value
  • Field ordering — control render order with meta.order
  • createAutoForm() factory — bake in your design system defaults once, use everywhere
  • Deep field overrides — dot-notated fields prop for nested object/array overrides
  • Pluggable coercion — automatic string→number, string→Date with customizable coercion map
  • Custom validation messages — global, per-field, and per-field-per-error-code message overrides
  • Programmatic control via refreset(), submit(), setValues(), getValues(), setErrors(), clearErrors(), focus(), isSubmitting via AutoFormHandle
  • Form state persistence — auto-save form values to localStorage (or custom storage) with configurable debounce; restored on mount, cleared on submit
  • Enhanced array fields — opt-in row reordering (move up/down), duplicate, collapsible object rows with summary, minItems/maxItems constraints from Zod .min()/.max(), via movable/duplicable/collapsible meta flags
  • Array button stylingclassNames.arrayAdd, arrayRemove, arrayMove, arrayDuplicate, arrayCollapse
  • Custom array row layoutlayout.arrayRowLayout lets you fully control button placement within each array row
  • Field index & depth CSS vars--field-index and --field-depth on every field wrapper for advanced CSS targeting
  • Value cascadeonValuesChange fires on every change with the full form values; use with ref.setValues() to imperatively sync field values
  • i18n / custom labelslabels prop (and factory-level labels config) replaces every hard-coded UI string ("Submit", "Add", "Remove", move/duplicate/collapse buttons) without touching layout slots
  • Async setOnChangeUniForm.setOnChange handlers can be async; use them to fetch dependent data (e.g. cascading dropdowns, availability checks) and apply the results via ctx.setFieldMeta / ctx.setValue
  • Async defaultValues — pass () => Promise<Partial<TValues>> as defaultValues; the form renders a loadingFallback while the promise is in flight, then resets with the loaded values
  • Tree-shakeable — ESM + CJS builds via tsup with sideEffects: false

Quick Start

Installation

npm install @uniform-ts/core react react-hook-form zod

Basic Usage

import * as z from 'zod/v4'
import { createForm, AutoForm } from '@uniform-ts/core'

const schema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.email('Invalid email'),
  age: z.number().min(0).optional(),
  role: z.enum(['user', 'admin', 'editor']),
  subscribe: z.boolean(),
})

// createForm wraps your schema — pass the result to <AutoForm form={...}>
const myForm = createForm(schema)

function MyForm() {
  return (
    <AutoForm
      form={myForm}
      defaultValues={{ role: 'user', subscribe: false }}
      onSubmit={(values) => {
        // values is fully typed as z.infer<typeof schema>
        console.log(values)
      }}
    />
  )
}

That's it — UniForm introspects the schema, renders appropriate inputs, validates with Zod, and calls onSubmit with typed values.

API Reference

<AutoForm> Props

| Prop | Type | Default | Description | | ----------------- | ------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------- | | form | UniForm<TSchema> | required | A UniForm / createForm instance carrying the schema and setOnChange handlers | | onSubmit | (values: z.infer<TSchema>) => void \| Promise<void> | required | Called with fully typed, validated values on successful submit | | defaultValues | Partial<z.infer<TSchema>> or () => Promise<Partial<z.infer<TSchema>>> | {} | Pre-fill form fields. Pass an async function to load from an API (see Async Default Values) | | components | ComponentRegistry | defaultRegistry | Override field type → component mapping | | fields | Record<string, Partial<FieldOverride>> | {} | Per-field metadata overrides (supports dot-notated paths for nested fields) | | fieldWrapper | React.ComponentType<FieldWrapperProps> | DefaultFieldWrapper | Wrap each scalar field in a custom container | | layout | LayoutSlots | {} | Replace form wrapper, section wrapper, submit button, array row layout, or loadingFallback | | classNames | FormClassNames | {} | CSS class names for form, field wrappers, labels, errors, descriptions | | disabled | boolean | false | Disable all form fields and the submit button | | coercions | CoercionMap | defaultCoercionMap | Custom per-type value coercion functions | | messages | ValidationMessages | undefined | Custom validation error messages | | ref | React.Ref<AutoFormHandle> | undefined | Imperative handle for programmatic control | | persistKey | string | undefined | When set, form values auto-save to storage under this key | | persistDebounce | number | 300 | Debounce interval in ms for persistence writes | | persistStorage | PersistStorage | localStorage | Custom storage adapter (must implement getItem/setItem/removeItem) | | onValuesChange | (values: z.infer<TSchema>) => void | undefined | Called on every field change with the full current form values | | labels | FormLabels | {} | Override hard-coded UI text (submit button, array buttons) for i18n |

createForm(schema) / UniForm

createForm wraps a Zod schema in a UniForm instance. Pass the result to <AutoForm form={...}>.

The main reason to use UniForm over passing a bare schema is typed setOnChange handlers: you can react to individual field changes, read the new value (typed to the schema), and call any form method — all outside React.

import { createForm, AutoForm } from '@uniform-ts/core'

const addressForm = createForm(addressSchema)
  .setOnChange('country', (value, ctx) => {
    // value is typed as the 'country' field type
    ctx.setFieldMeta('state', { hidden: value !== 'US' })
    ctx.setValue('state', undefined)
  })

// In component:
<AutoForm form={addressForm} onSubmit={handleSubmit} />

UniForm.setOnChange(field, handler)

Set the typed onChange handler for a specific field. Returns this for chaining. Calling setOnChange again for the same field replaces the previous handler — only one handler per field is active at a time. This prevents accidental handler accumulation when called inside a React render cycle.

Handler receives:

  • value — the new field value, typed to the schema
  • ctx: UniFormContext — all FormMethods plus setFieldMeta

UniFormContext

The context passed to every setOnChange handler. Extends FormMethods with:

| Method | Description | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | setFieldMeta(field, meta) | Dynamically override per-field UI properties (hidden, disabled, options, label, placeholder, description). Pass value to immediately call setValue on the field. |

All standard FormMethods are also available: setValue, setValues, getValues, resetField, reset, setError, setErrors, clearErrors, submit, focus.

UniForm.condition(field, predicate)

Attach a typed visibility condition for a specific field. The field is shown when predicate(values) returns true. Takes precedence over any condition set via the fields prop.

const form = createForm(schema).condition(
  'companyName',
  (values) => values.type === 'business',
)

createAutoForm(config)

Factory function that returns a pre-configured <AutoForm> component with baked-in defaults.

import { createAutoForm } from '@uniform-ts/core'

const MyAutoForm = createAutoForm({
  components: { string: MyTextInput, number: MyNumberInput },
  fieldWrapper: MyFieldWrapper,
  layout: { submitButton: MySubmitButton },
  classNames: { form: 'my-form', label: 'my-label' },
  disabled: false,
  coercions: { number: (v) => (v === '' ? undefined : Number(v)) },
  messages: { required: 'This field is required' },
})

// Use it — no need to pass components/layout/classNames every time
<MyAutoForm form={myForm} onSubmit={handleSubmit} />

// Instance props merge with and override factory defaults
<MyAutoForm form={myForm} onSubmit={handleSubmit} classNames={{ form: 'override' }} />

Config type: AutoFormConfig

| Key | Type | Merge behavior | | -------------- | ---------------------------------------- | ------------------------------------------------ | | components | ComponentRegistry | Deep merge (instance overrides specific keys) | | fieldWrapper | React.ComponentType<FieldWrapperProps> | Instance replaces factory | | layout | LayoutSlots | Shallow merge (except sections — deep-merged) | | classNames | FormClassNames | Shallow merge | | disabled | boolean | OR logic (either true → disabled) | | coercions | CoercionMap | Shallow merge | | messages | ValidationMessages | Shallow merge | | labels | FormLabels | Shallow merge (instance overrides specific keys) |

Types

FieldMeta

Metadata attached to each field, extracted from Zod's .meta() or set via the fields prop:

type FieldMeta = {
  label?: string
  placeholder?: string
  description?: string
  section?: string // Group field into a named section
  order?: number // Control render order
  span?: number // Grid column hint (set as --field-span CSS var)
  hidden?: boolean // Hide the field
  disabled?: boolean // Disable the field
  options?: SelectOption[] // Override options for select fields
  condition?: (values: Record<string, unknown>) => boolean // Show/hide conditionally
  component?: string | React.ComponentType<FieldProps>
  //          ^^^^^^   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  //          registry key        direct component (bypasses registry)
  onChange?: (value: unknown, form: FormMethods) => void | Promise<void> // Per-field change handler (may be async)
  [key: string]: unknown // Extensible
}

FieldOverride

The type for entries in the fields prop. Like FieldMeta, but with typed condition and onChange:

type FieldOverride<TSchema, TValue> = Partial<FieldMetaBase> & {
  condition?: (values: z.infer<TSchema>) => boolean
  onChange?: (
    value: TValue,
    form: FormMethods<z.infer<TSchema>>,
  ) => void | Promise<void>
  [key: string]: unknown
}

ComponentRegistry

Map field types to React components:

type ComponentRegistry = {
  string?: React.ComponentType<FieldProps>
  number?: React.ComponentType<FieldProps>
  boolean?: React.ComponentType<FieldProps>
  date?: React.ComponentType<FieldProps>
  select?: React.ComponentType<FieldProps>
  textarea?: React.ComponentType<FieldProps>
  [key: string]: React.ComponentType<FieldProps> | undefined
}

FieldProps

Props received by every field component:

type FieldProps = {
  name: string
  value: unknown
  onChange: (value: unknown) => void
  onBlur: () => void
  ref: RefCallBack // react-hook-form ref for DOM registration
  label: string
  placeholder?: string
  description?: string
  error?: string
  required: boolean
  disabled?: boolean
  options?: SelectOption[] // For select fields
  meta: FieldMeta
}

FieldWrapperProps

Props received by the field wrapper component:

type FieldWrapperProps = {
  children: React.ReactNode
  field: FieldConfig
  error?: string
  span?: number
  index?: number // Zero-based render index → --field-index CSS var
  depth?: number // Nesting depth (0 = top-level) → --field-depth CSS var
}

FormMethods

All programmatic form control methods — available on UniFormContext, in per-field onChange callbacks, and as AutoFormHandle via ref:

type FormMethods<TValues> = {
  setValue: (name, value) => void
  setValues: (values: Partial<TValues>) => void
  getValues: () => TValues
  watch: (() => TValues) & (<K extends keyof TValues>(name: K) => TValues[K])
  resetField: (name) => void
  reset: (values?: Partial<TValues>) => void
  setError: (name, message: string) => void
  setErrors: (errors: Partial<Record<string, string>>) => void
  clearErrors: (names?) => void
  submit: () => void
  focus: (fieldName) => void
}

LayoutSlots

type LayoutSlots = {
  formWrapper?: React.ComponentType<{ children: React.ReactNode }>
  sectionWrapper?: React.ComponentType<{
    children: React.ReactNode
    title: string
    className?: string
  }>
  submitButton?: React.ComponentType<{ isSubmitting: boolean }>
  arrayRowLayout?: React.ComponentType<ArrayRowLayoutProps>
  /** Shown while async `defaultValues` are resolving. Default: `<p>Loading…</p>` */
  loadingFallback?: React.ReactNode
  /** Per-section styling / component overrides keyed by section title. */
  sections?: Record<string, SectionConfig>
}

SectionConfig

type SectionConfig = {
  /** CSS class name forwarded to the section wrapper. */
  className?: string
  /** Replace the section wrapper component for this section only. */
  component?: React.ComponentType<{
    children: React.ReactNode
    title: string
    className?: string
  }>
}

Tip: Since loadingFallback is part of LayoutSlots, you can set a global loading UI once in createAutoForm({ layout: { loadingFallback: <AppSpinner /> } }) and every form using that factory will automatically use it.

ArrayRowLayoutProps

type ArrayRowLayoutProps = {
  children: React.ReactNode // The rendered form fields for this row
  buttons: {
    moveUp: React.ReactNode | null
    moveDown: React.ReactNode | null
    duplicate: React.ReactNode | null
    remove: React.ReactNode
    collapse: React.ReactNode | null
  }
  index: number
  rowCount: number
}

FieldDependencyResult

Return type of ctx.setFieldMeta() inside UniForm setOnChange handlers. All fields are optional — return only what you want to override:

type FieldDependencyResult = {
  options?: SelectOption[] // Override available options (for select fields)
  hidden?: boolean // Show or hide the field
  disabled?: boolean // Enable or disable the field
  label?: string // Override the field label
  placeholder?: string // Override the placeholder
  description?: string // Override the description
}
type FormClassNames = {
  form?: string
  fieldWrapper?: string
  label?: string
  description?: string
  error?: string
  arrayAdd?: string
  arrayRemove?: string
  arrayMove?: string
  arrayDuplicate?: string
  arrayCollapse?: string
}

CoercionMap

type CoercionMap = Record<string, (value: unknown) => unknown>

Default coercions: number (empty→undefined, else Number()), date (empty→undefined, else new Date()), boolean (Boolean()), string (null'').

ValidationMessages

type ValidationMessages = {
  required?: string // Global required override
  [fieldName: string]: string | Record<string, string> | undefined
  //                   ^^^^^^   ^^^^^^^^^^^^^^^^^^^^^^
  //                   catch-all  per-error-code map
}

Recipes

Custom Components

Replace the default input for any field type:

function MyTextInput(props: FieldProps) {
  return (
    <input
      ref={props.ref}
      id={props.name}
      value={String(props.value ?? '')}
      onChange={(e) => props.onChange(e.target.value)}
      onBlur={props.onBlur}
      placeholder={props.placeholder}
      disabled={props.disabled}
      className='my-input'
    />
  )
}

;<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  components={{ string: MyTextInput }}
/>

Per-field Custom Components

You can override the component for a single field in two ways:

Option 1 — Direct React component (inline, no registry needed)

Pass a React.ComponentType<FieldProps> directly as meta.component — either in the Zod schema or via the fields prop:

// In the Zod schema
function StarRating(props: FieldProps) { /* ... */ }

const schema = z.object({
  title: z.string(),
  rating: z.number().min(1).max(5).meta({ component: StarRating }),
})

<AutoForm form={createForm(schema)} onSubmit={handleSubmit} />
// Or via the fields prop (no schema change needed)
<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  fields={{ rating: { component: StarRating } }}
/>

The direct component bypasses the registry entirely and takes highest priority in the resolution chain.

Array fields with a direct component

A direct meta.component also bypasses the default ArrayField row-by-row UI. This lets you use a fully custom multi-value widget (e.g. a tag picker, multi-select) on a z.array(z.string()) field — the component owns the whole array value:

function TagPicker(props: FieldProps) {
  const selected = Array.isArray(props.value) ? (props.value as string[]) : []
  // ... render your chip UI, call props.onChange(newArray) on changes
}

const schema = z.object({
  tags: z
    .array(z.string())
    .min(1, 'Pick at least one tag')
    .meta({
      component: TagPicker,
      suggestions: ['React', 'TypeScript', 'Zod'],
    }),
})

<AutoForm form={createForm(schema)} onSubmit={handleSubmit} />

Zod still validates the array (.min(1) etc.) — only the render is taken over by your component.

Option 2 — String field as select

A z.string() field can be rendered as a select by setting meta.component: 'select' together with meta.options. UniForm treats it as type "select" during introspection:

const schema = z.object({
  role: z.string().meta({
    component: 'select',
    options: [
      { label: 'User', value: 'user' },
      { label: 'Admin', value: 'admin' },
      { label: 'Editor', value: 'editor' },
    ],
  }),
})

This is an alternative to z.enum(...) — useful when the option list is dynamic or when you need a plain string output type rather than a union literal.

Option 3 — Named key in the registry

Register a component under a custom string key — either in createAutoForm or the components prop — then reference it with meta.component: 'yourKey':

// Register at factory level, available to all forms
const AppAutoForm = createAutoForm({
  components: {
    colorpicker: ColorPicker,
    autocomplete: AutocompleteInput,
  },
})

const schema = z.object({
  theme: z.string().meta({ component: 'colorpicker' }),
  city: z.string().meta({ component: 'autocomplete' }),
})

<AppAutoForm form={createForm(schema)} onSubmit={handleSubmit} />
// Or register per-instance via the components prop
<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  components={{ colorpicker: ColorPicker }}
  fields={{ theme: { component: 'colorpicker' } }}
/>

Resolution priority (highest → lowest):

  1. Direct React component in meta.component
  2. String key in meta.component → merged registry
  3. Field type key in merged registry (e.g. string, number)
  4. Field type key in default registry
  5. Warn + render nothing

Field onChange Handlers

React to individual field changes — inline via the fields prop (typed to the schema), or statically via UniForm.setOnChange.

Inline via fields prop

<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  fields={{
    country: {
      onChange: (value, form) => {
        // value is typed as the 'country' field type
        // form provides setValue, setValues, getValues, reset, etc.
        form.setValue('state', undefined)
      },
    },
  }}
/>

Statically via createForm / UniForm (outside the component)

// Define once at module level — handlers are stable, no React rules apply
const addressForm = createForm(addressSchema).setOnChange(
  'country',
  (value, ctx) => {
    ctx.setFieldMeta('state', { hidden: value !== 'US' })
    ctx.setValue('state', undefined)
  },
)

function MyForm() {
  return <AutoForm form={addressForm} onSubmit={handleSubmit} />
}

UniForm.setOnChange also supports ctx.setFieldMeta for dynamic field overrides — not available in the inline fields version.

Async handlers

setOnChange handlers (both inline and on UniForm) can be async. This is useful for server-side lookups that update other fields:

const productForm = createForm(productSchema).setOnChange(
  'sku',
  async (sku, ctx) => {
    // Disable the derived field while loading
    ctx.setFieldMeta('productName', { disabled: true, placeholder: 'Loading…' })

    const { name } = await fetchProduct(sku)

    ctx.setValue('productName', name)
    ctx.setFieldMeta('productName', { disabled: false, placeholder: '' })
  },
)

Async handlers are fire-and-forget — the field value is already committed to RHF before the async work runs. Cancel in-flight requests yourself with AbortController if needed.

Grid Layout with classNames and span

<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  classNames={{
    form: 'grid grid-cols-12 gap-4',
    fieldWrapper: 'p-2',
    label: 'font-semibold block mb-1',
    error: 'text-red-500 text-sm',
  }}
  fields={{
    firstName: { span: 6 },
    lastName: { span: 6 },
    email: { span: 12 },
  }}
/>

The span value is set as --field-span CSS custom property on each field wrapper. Each wrapper also receives --field-index (zero-based render order) and --field-depth (nesting depth). Use CSS Grid to consume them:

.grid > * {
  grid-column: span var(--field-span, 12);
}
/* Style every other top-level field */
.grid > *:nth-child(even) {
  background: var(--field-index);
}

Section Grouping

<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  fields={{
    firstName: { section: 'Personal', order: 1 },
    lastName: { section: 'Personal', order: 2 },
    street: { section: 'Address', order: 3 },
    city: { section: 'Address', order: 4 },
  }}
  layout={{
    sectionWrapper: ({ children, title, className }) => (
      <fieldset className={className}>
        <legend>{title}</legend>
        {children}
      </fieldset>
    ),
  }}
/>

Use layout.sections to style or swap the wrapper for individual sections:

<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  layout={{
    sections: {
      Personal: { className: 'bg-blue-50 p-4 rounded' },
      Address: { component: AddressCard }, // completely different component
    },
  }}
/>

className is forwarded as a prop to the active wrapper (global sectionWrapper or the per-section component). Factory-level and instance-level sections are merged — instance wins on conflicts.

Conditional Fields

Show a field only when another field has a specific value. Conditional fields are fully lifecycle-managed:

  • Hidden → not submitted: fields whose condition starts false are never pre-registered in the form store, so they don't appear in onSubmit data.
  • Shown → hidden: when a condition becomes false and the field unmounts, its value is discarded — it starts fresh the next time it appears.
const schema = z.object({
  type: z.enum(['personal', 'business']),
  companyName: z.string().optional(),
})

const myForm = createForm(schema)
  // Attach condition on the UniForm instance (takes precedence over fields prop):
  .condition('companyName', (values) => values.type === 'business')

// Or via the fields prop:
<AutoForm
  form={createForm(schema)}
  onSubmit={handleSubmit}
  fields={{
    companyName: {
      condition: (values) => values.type === 'business',
    },
  }}
/>

Discriminated Unions

Pass a z.discriminatedUnion directly to createForm — no flat schema needed:

import * as z from 'zod/v4'
import { createForm, AutoForm } from '@uniform-ts/core'

const notificationUnion = z.discriminatedUnion('channel', [
  z.object({
    channel: z.literal('email'),
    recipientEmail: z.string().email('Must be a valid email'),
    subject: z.string().min(1, 'Subject is required'),
  }),
  z.object({
    channel: z.literal('sms'),
    phoneNumber: z
      .string()
      .regex(/^\+?[1-9]\d{7,14}$/, 'Must be a valid phone number'),
    messageBody: z.string().max(160, 'SMS body must be ≤ 160 chars'),
  }),
  z.object({
    channel: z.literal('webhook'),
    endpointUrl: z.string().url('Must be a valid URL'),
    secret: z.string().min(16, 'Secret must be at least 16 characters'),
  }),
])

const notificationForm = createForm(notificationUnion)

function NotificationForm() {
  return (
    <AutoForm
      form={notificationForm}
      defaultValues={{ channel: 'email' }}
      onSubmit={(values) => console.log(values)}
    />
  )
}

How it works:

  • The discriminator field (channel) renders as a select with one option per variant
  • When the discriminator changes, AutoForm swaps to the matching variant and renders only that variant's fields
  • Validation uses the original union schema via zodResolver — only the active variant's fields are validated
  • Shared fields (same key in multiple variants) persist their values when switching variants, since react-hook-form retains unregistered field values by default
  • Variant-specific field values from a previous variant remain in the form store but are stripped by Zod during parsing

Custom Validation Messages

<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  messages={{
    required: 'This field is required', // Global
    email: 'Please provide an email', // Per-field catch-all
    age: { too_small: 'Must be at least 18' }, // Per-field per-code
  }}
/>

Resolution order: per-field per-code → per-field string → global required → Zod's original message.

AutoFormHandle

Imperative handle exposed via ref:

type AutoFormHandle<TSchema> = FormMethods<z.infer<TSchema>> & {
  /** `true` while an async `onSubmit` handler is in flight. */
  isSubmitting: boolean
}
// FormMethods: reset, submit, setValue, setValues, getValues, watch,
//              resetField, setError, setErrors, clearErrors, focus

PersistStorage

Adapter interface for form persistence (defaults to localStorage):

type PersistStorage = {
  getItem: (key: string) => string | null
  setItem: (key: string, value: string) => void
  removeItem: (key: string) => void
}

Factory Pattern with createAutoForm

import { createAutoForm, createForm } from '@uniform-ts/core'

const AppAutoForm = createAutoForm({
  components: {
    string: MyTextInput,
    number: MyNumberInput,
    boolean: MyToggle,
    select: MyDropdown,
  },
  fieldWrapper: MyFieldWrapper,
  layout: { submitButton: MySubmitButton },
  classNames: { form: 'app-form', label: 'app-label' },
})

// Then use it everywhere — no prop repetition
<AppAutoForm form={createForm(userSchema)} onSubmit={saveUser} />
<AppAutoForm form={createForm(settingsSchema)} onSubmit={saveSettings} />

Deep Field Overrides

Override metadata for nested fields using dot-notated paths:

const schema = z.object({
  address: z.object({
    street: z.string(),
    city: z.string(),
    zip: z.string(),
  }),
})

<AutoForm
  form={createForm(schema)}
  onSubmit={handleSubmit}
  fields={{
    'address.street': { placeholder: '123 Main St' },
    'address.city': { label: 'City / Town' },
    'address.zip': { span: 6 },
  }}
/>

Programmatic Control via Ref

Use ref to control the form from outside — ideal for wizards, external save buttons, and multi-step flows:

import { useRef } from 'react'
import { AutoForm } from '@uniform-ts/core'
import type { AutoFormHandle } from '@uniform-ts/core'

function WizardForm() {
  const formRef = useRef<AutoFormHandle<typeof schema>>(null)

  return (
    <div>
      <AutoForm ref={formRef} form={myForm} onSubmit={handleSubmit} />

      <button onClick={() => formRef.current?.reset()}>Reset</button>
      <button onClick={() => formRef.current?.submit()}>Save (external)</button>
      <button onClick={() => formRef.current?.setValues({ name: 'Alice' })}>
        Pre-fill
      </button>
    </div>
  )
}

All AutoFormHandle methods: reset(), submit(), setValue(), setValues(), getValues(), watch(), resetField(), setError(), setErrors(), clearErrors(), focus(), plus isSubmitting (boolean, true while async onSubmit is in flight).

Async Default Values

Pass an async function as defaultValues to pre-fill the form from an API:

async function loadProfile() {
  const res = await fetch('/api/profile')
  return res.json() // Returns Partial<ProfileValues>
}

function EditProfileForm() {
  return (
    <AutoForm
      form={profileForm}
      defaultValues={loadProfile} // async function — called once on mount
      layout={{ loadingFallback: <ProfileSkeleton /> }} // shown while the promise is in flight
      onSubmit={handleSubmit}
    />
  )
}
  • The form renders loadingFallback (or <p>Loading…</p> by default) until the promise resolves.
  • On resolve, the form resets with the loaded values and renders normally.
  • If you need to replay the loading state (e.g. when navigating between records), change the key prop on <AutoForm> to remount it.

Reading Live Values with watch

watch reads the current live value of a field (or all fields) from outside the form. Unlike getValues, it subscribes to react-hook-form's render cycle, so it always reflects the latest value at call time.

import { useRef } from 'react'
import { AutoForm } from '@uniform-ts/core'
import type { AutoFormHandle } from '@uniform-ts/core'

const schema = z.object({
  plan: z.enum(['free', 'pro', 'enterprise']),
  seats: z.number().min(1),
})

const myForm = createForm(schema)

function PricingForm() {
  const formRef = useRef<AutoFormHandle<typeof schema>>(null)

  return (
    <div>
      <AutoForm ref={formRef} form={myForm} onSubmit={handleSubmit} />

      {/* Read a single field */}
      <button
        onClick={() => {
          const plan = formRef.current?.watch('plan')
          console.log('Current plan:', plan)
        }}
      >
        Log current plan
      </button>

      {/* Read all fields */}
      <button
        onClick={() => {
          const values = formRef.current?.watch()
          console.log('All values:', values)
        }}
      >
        Log all values
      </button>
    </div>
  )
}

Tip: watch is most useful for reading values imperatively in event handlers. To react to changes as they happen, prefer onValuesChange or UniForm.setOnChange.

Form State Persistence

Auto-save form values to storage so users don't lose progress on page reload:

<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  persistKey='my-form'
  persistDebounce={500}
/>

Values are restored on mount and cleared after a successful submit. Use persistStorage for a custom adapter (e.g. sessionStorage):

<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  persistKey='my-form'
  persistStorage={sessionStorage}
/>

Enhanced Array Fields

Array fields support reordering, duplication, and collapsible rows — all opt-in via meta flags:

const schema = z.object({
  members: z.array(
    z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }),
  ).min(1).max(5), // Enforced: can't remove below 1, can't add above 5
})

<AutoForm
  form={createForm(schema)}
  onSubmit={handleSubmit}
  fields={{
    members: {
      movable: true,      // Show ↑/↓ move buttons
      duplicable: true,   // Show Duplicate button
      collapsible: true,  // Show collapse/expand toggle (object items only)
    },
  }}
/>
  • movable: Renders Move Up / Move Down buttons (only when >1 row)
  • duplicable: Renders a Duplicate button (hidden when at maxItems)
  • collapsible: Renders a collapse/expand toggle for object rows with summary text
  • Add and Remove are always shown
  • Constraints from .min() / .max() are enforced — "Add" is disabled at max, "Remove" is disabled at min

Style the array buttons via classNames:

<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  fields={{ members: { movable: true, duplicable: true, collapsible: true } }}
  classNames={{
    arrayAdd: 'btn btn-primary',
    arrayRemove: 'btn btn-danger',
    arrayMove: 'btn btn-secondary',
    arrayDuplicate: 'btn btn-outline',
    arrayCollapse: 'btn btn-ghost',
  }}
/>

Custom Array Row Layout

Use layout.arrayRowLayout to control where buttons appear within each array row:

import type { ArrayRowLayoutProps } from '@uniform-ts/core'

function HorizontalRowLayout({
  children,
  buttons,
  index,
  rowCount,
}: ArrayRowLayoutProps) {
  return (
    <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
      <div style={{ display: 'flex', flexDirection: 'column' }}>
        {buttons.moveUp}
        {buttons.moveDown}
      </div>
      <div style={{ flex: 1 }}>{children}</div>
      <div style={{ display: 'flex', gap: '0.25rem' }}>
        {buttons.duplicate}
        {buttons.remove}
      </div>
    </div>
  )
}

;<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  fields={{ tasks: { movable: true, duplicable: true } }}
  layout={{ arrayRowLayout: HorizontalRowLayout }}
/>

The default layout renders collapse toggle, then children, then all action buttons in a row.

Customizing UI Text (i18n)

Use the labels prop to replace every hard-coded string in the default UI — submit button, all array action buttons — without needing to replace entire layout slot components:

<AutoForm
  form={myForm}
  onSubmit={handleSubmit}
  labels={{
    submit: 'Enviar',
    arrayAdd: 'Agregar fila',
    arrayRemove: 'Eliminar',
    arrayMoveUp: '⬆ Subir',
    arrayMoveDown: '⬇ Bajar',
    arrayDuplicate: 'Duplicar',
    arrayCollapse: '▼ Ocultar', // shown when row is expanded
    arrayExpand: '▶ Mostrar', // shown when row is collapsed
  }}
/>

Set factory-level defaults with labels in createAutoForm — per-instance labels props shallow-merge and override:

const AppAutoForm = createAutoForm({
  labels: { submit: 'Save' },
})

// Uses factory default "Save"
<AppAutoForm form={myForm} onSubmit={handleSubmit} />

// Per-instance override wins → "Save & Close"
<AppAutoForm form={myForm} onSubmit={handleSubmit} labels={{ submit: 'Save & Close' }} />

FormLabels type reference:

type FormLabels = {
  submit?: string // default: "Submit"
  arrayAdd?: string // default: "Add"
  arrayRemove?: string // default: "Remove"
  arrayMoveUp?: string // default: "↑"
  arrayMoveDown?: string // default: "↓"
  arrayDuplicate?: string // default: "Duplicate"
  arrayCollapse?: string // shown when row is expanded (default: "▼")
  arrayExpand?: string // shown when row is collapsed (default: "▶")
}

All unspecified keys fall back to their built-in English defaults. labels only affects the default submit button and array controls — if you supply a custom layout.submitButton component, that component owns its own text.

Value Cascade (onValuesChange)

Use onValuesChange together with a ref to set one field based on another:

const formRef = useRef<AutoFormHandle<typeof schema>>(null)

<AutoForm
  ref={formRef}
  form={myForm}
  onSubmit={handleSubmit}
  onValuesChange={(values) => {
    const seats = { free: 1, starter: 5, pro: 20, enterprise: 100 }[values.plan]
    if (seats !== undefined && values.seats !== seats) {
      formRef.current?.setValues({ seats })
    }
  }}
/>

Always guard with an equality check to prevent an infinite update loop.

Tip: For simple field-to-field reactions (resetting, toggling visibility), prefer UniForm.setOnChange or the fields prop onChange — they're more ergonomic and fully typed. Use onValuesChange when you need to observe the entire form state holistically.

Development

pnpm install       # Install dependencies
pnpm build         # Build @uniform-ts/core
pnpm test          # Run all tests
pnpm dev           # Start the playground dev server

Monorepo Structure

uniform/
├── packages/
│   └── core/          # The library (@uniform-ts/core)
└── apps/
    └── playground/    # Vite + React dev app

Tech Stack

  • pnpm workspaces — monorepo management
  • tsup — library bundler (ESM + CJS + .d.ts)
  • Vite — playground dev server
  • Vitest — unit and integration tests
  • TypeScript — strict mode throughout
  • Zod V4 (zod@>=3.25, imported from zod/v4)
  • react-hook-form — form state management
  • @hookform/resolvers (^5.2) — Zod v4-aware resolver

Contributing

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/my-feature)
  3. Run tests (pnpm test) and ensure they pass
  4. Submit a pull request

License

MIT