Skip to content

pavelivanov/formular

Repository files navigation

formular

Small, type-safe form library for React. Form state lives in a pure JS class graph wired together by an event emitter; React components subscribe via hooks and opt in to re-renders only for the slice they care about.

npm npm

v4 is a ground-up rewrite, currently published under the next dist-tag. The stable v3.x line remains on latest. See MIGRATION.md for v3 β†’ v4 and CHANGELOG.md for the release notes.

Why

Most form libraries store form state in React state. That couples every keystroke to the render tree and makes large forms easy to thrash.

formular keeps form state outside of React:

  • Form<FieldValues> and FieldManager<T> are plain classes with their own state machines.
  • Mutations emit events; components that want to react subscribe through hooks backed by useSyncExternalStore.
  • No React state means no render cascade on every field update, and no provider re-creation churn when initialValues load asynchronously.

Current bundle: ~6.8 KB gzip (ESM), including 15 built-in validators.

Install

# Try v4 (beta):
npm install formular@next

# Stay on v3 (stable):
npm install formular

Peer: react >=18 for v4. A runnable end-to-end example lives at examples/vite-react.

Basic usage

Use createForm<T>() once per form shape. It returns a stable bundle of hooks (plus the Provider) with your value type closed over β€” each call site just passes a literal path and TypeScript narrows from there:

import { FieldError, FieldLabel, createForm } from 'formular'

type ContactForm = {
  name: string
  email: string
}

const contact = createForm<ContactForm>()

function NameField() {
  const field = contact.useFieldRegister('name', { required: true })
  //    ^? FieldManager<string>

  return (
    <div>
      <FieldLabel field={field}>Name</FieldLabel>
      <input
        value={field.state.value ?? ''}
        onChange={(e) => field.setValue(e.target.value)}
        aria-invalid={!!field.state.error}
      />
      <FieldError field={field} />
    </div>
  )
}

function SubmitButton() {
  const form = contact.useForm()
  const { isSubmitting, isValid } = contact.useFormState()

  return (
    <button
      disabled={isSubmitting || !isValid}
      onClick={form.handleSubmit((values) => console.log('submit', values))}
    >
      Submit
    </button>
  )
}

export function ContactFormPage() {
  return (
    <contact.FormContextProvider initialValues={{ name: '', email: '' }}>
      <NameField />
      <SubmitButton />
    </contact.FormContextProvider>
  )
}

If you just need an ad-hoc field outside a typed form contract, the direct useFieldRegister<T>(name) / useFieldArray<Item>(path) hooks are still exported and take a plain string path.

API

Components

  • FormContextProvider<FieldValues> β€” creates a Form instance and puts it on context. Props: initialValues, onSubmit, onSubmitError, onChange, formId, children. If initialValues changes after mount it re-seeds the form (edited fields keep their current value; untouched fields update; unmounted fields pick up the new seed on registration).
  • FieldLabel β€” label bound to a field; renders a * when the field is required. Defaults htmlFor to field.name unless id is provided. Headless β€” pass your own classNames.
  • FieldError β€” renders the field's current error message as an aria-live="polite" region (or nothing when no error).

Hooks

  • useForm<FieldValues>() β€” returns the ambient Form instance.
  • useFormContext<FieldValues>() β€” alias of useForm; throws if used outside a provider.
  • useFormState() β€” subscribes to form state changes. Only components that call this re-render on form state changes.
  • useFieldRegister<FieldValues>(name, options?) β€” registers a field (or returns the existing one) and subscribes the component to it. The typed overload constrains name to keyof FieldValues and returns FieldManager<FieldValues[K]>.
  • useField<T>(name) β€” read-only subscription to a field that some other component registered. Returns undefined until it registers.
  • useFormValidation() β€” returns stable { validate, submit, reset } callbacks bound to the form.

Validation

Fields support three, complementary validation sources, run in this order:

  1. required: true β€” empties fail with "This field is required".
  2. schema β€” any Standard Schema v1 implementation (Zod 3.24+, Valibot 0.40+, ArkType, …). First issue message becomes the error.
  3. validators β€” array of functions returning string | null or Promise<string | null>. First non-null wins.

Each stage short-circuits on the first error.

Standard Schema

import { z } from 'zod'
import { useFieldRegister } from 'formular'

const field = useFieldRegister<ContactForm>('email', {
  schema: z.string().email('Invalid email'),
  required: true,
})

Sync and async schemas are both supported. Value transformations from the schema (e.g. Zod's .transform()) are not applied to the field value; if you need the parsed value, parse it yourself in onSubmit.

Built-in function validators

All return null on success and a message on failure. Compose with compose(...) or pass as the validators: [...] field option.

import { compose, email, minLength, useFieldRegister } from 'formular'

const field = useFieldRegister<ContactForm>('email', {
  validators: [ compose(email(), minLength(5)) ],
  required: true,
})

Built-ins:

  • String/number: minLength, maxLength, pattern, email, url, phoneNumber, numeric, min, max, creditCard.
  • Date: dateFormat, minAge.
  • Cross-field: confirmField(otherName, message?).
  • Async: asyncValidator(fn) β€” wraps an async function; thrown errors become error strings. Does not debounce β€” use the field's validationDelay option for that.

If you're starting a new project today, prefer schema over the built-ins β€” it scales to the whole ecosystem (Zod/Valibot/ArkType) and gives you composition, transforms, and inference for free.

FieldOptions

type FieldOptions<T> = {
  defaultValue?: T
  validators?: Validator<T>[]
  /** Milliseconds to debounce in-flight validation after setValue. */
  validationDelay?: number
  required?: boolean
  readOnly?: boolean
  /** Override the default "value is empty" check used by `required`. */
  emptyCheckFn?: (value: T) => boolean
}

Events

Form emits:

Name Payload When
state change FormState Any tracked form-level state change
change values Any field value actually changed
field registered name, FieldManager A new field registered
field unregistered name A field unregistered
submit { values, errors } Submit completed (valid or invalid)
submit error error, { values, errors, isValid, phase } Submit/onSuccess threw

Hook helpers (useFormState, useField, useFieldRegister) wire the right subscriptions for you β€” reach for raw form.on only for side effects outside the render tree.

Field arrays

import { useFieldArray } from 'formular'

type Form = {
  tags: string[]
}

function Tags() {
  const { fields, append, remove, move } = useFieldArray<Form>('tags')

  return (
    <>
      {fields.map((field) => (
        <Row key={field.id}>
          <input
            value={field.value}
            onChange={(e) => {
              // whole-array field; update via replace or per-index setValues
            }}
          />
          <button onClick={() => remove(field.index)}>Γ—</button>
        </Row>
      ))}
      <button onClick={() => append('')}>add tag</button>
    </>
  )
}

Operations: append, prepend, insert(i, item), remove(i), swap(a, b), move(from, to), replace(items), clear().

fields[i].id is stable across inserts, swaps, and moves, so drop it straight into key=. The hook keys on the array path as a single field, so form.setValues({ tags: [...] }) stays the source of truth and external mutations still reach the UI.

Per-row sub-fields are reindexed automatically. If a component registers useFieldRegister<Item>('items.2.name') and the user calls remove(1), the field's internal state (value, error, touched) is carried over to items.1.name β€” the FieldManager instance is preserved, only its path changes. swap, move, insert, prepend behave the same. replace and clear destroy all sub-fields at that array path; newly-mounted rows register fresh.

Subscribers can observe the shift via the field renamed event:

form.on('field renamed', (oldPath, newPath) => { … })

Nested paths

Field names are dotted paths typed against your form shape. You can register fields at any depth:

type Contact = {
  name: string
  address: {
    street: string
    city: string
    zip: { code: string }
  }
}

function AddressFields() {
  const street = useFieldRegister<Contact>('address.street', { required: true })
  const code = useFieldRegister<Contact>('address.zip.code')
  // street: FieldManager<string>
  // code:   FieldManager<string>
  // …
}

setValues and setInitialValues take a DeepPartial shape β€” the walker hands each subtree to the registered field at that path, or keeps recursing until it finds one (or hits a leaf, in which case the value is buffered until a field registers):

form.setValues({ address: { street: '42 Elm' } })
// applies to the 'address.street' field, leaves everything else untouched

getValues() reconstructs the nested shape. getErrors() and the submit result use dotted-path keys ({ 'address.street': '...' }) so they're stable for UI lookup.

If you register a whole-object field (useFieldRegister<T>('address')), setValues({ address: {...} }) hands the whole object to that field instead of descending. Mix granular and whole-object fields however suits your form.

Testing

A small helper for reaching into a form during tests, exported from the formular/testing subpath:

import { render, fireEvent } from '@testing-library/react'
import { createTestForm } from 'formular/testing'
import { NameField } from './NameField'

test('typing into NameField updates the form', () => {
  const { form, Provider } = createTestForm<Contact>({
    initialValues: { name: '', email: '' },
  })

  const { getByTestId } = render(
    <Provider>
      <NameField />
    </Provider>,
  )

  fireEvent.change(getByTestId('name'), { target: { value: 'Ada' } })

  expect(form.getField('name')?.getValue()).toBe('Ada')
})
  • Renderer-agnostic. Pair with @testing-library/react, Enzyme, whatever β€” Provider is just a minimal React component.
  • You own the lifetime. The Provider does NOT destroy the form on unmount, so your assertions work after the render root is torn down. Call form.destroy() in teardown if you're leak-sensitive.
  • Backed by <FormContextProvider form={…}> β€” a new optional prop on the main Provider that lets any caller hand in a pre-created Form instance. Use it directly if you want a single Provider in your test tree.

Devtools

A floating inspection panel is available as a subpath export. It reads the ambient form (or a specific one via the form prop) and shows live state, field list, and an event log.

import { FormularDevtools } from 'formular/devtools'

function Page() {
  return (
    <contact.FormContextProvider initialValues={…}>
      {process.env.NODE_ENV !== 'production' && <FormularDevtools />}
      <Form body />
    </contact.FormContextProvider>
  )
}

The panel:

  • Collapses by default. Click the formular pill in the corner to open.
  • Three tabs β€” state (values, errors, flags), fields (registered paths + their state), events (rolling log of state change, change, field registered/renamed/unregistered, submit, submit error).
  • Self-contained (inline styles only; no global CSS, no portals).
  • Tree-shakes out when not imported β€” the main bundle doesn't pay. Current cost when you do import it: 3.1 KB brotli as a self-contained subpath.

Render it first inside your provider so its useEffect subscribes to form events before siblings register fields β€” otherwise the event tab starts empty.

Props: form? (override ambient form), position? ('bottom-right' | 'bottom-left' | 'top-right' | 'top-left'), defaultOpen?, defaultTab? ('state' | 'fields' | 'events'), enabled? (default true β€” flip to false or gate on NODE_ENV for production).

Dynamic & conditional fields

Fields register when their component mounts and unregister on unmount. Unmounting a field destroys its state. If you need persistence across visibility, keep the field mounted (display: none) or hoist the value up via setValues.

Hydrating from async data

Two patterns work:

  1. Gate the provider on data β€” render FormContextProvider only once server data is ready.
  2. Hydrate lazily β€” render the provider immediately, then call form.setValues(data) or re-seed via changing initialValues. setValues buffers keys for fields that haven't mounted yet, so ordering between data arrival and field registration doesn't matter.

v3 β†’ v4

v4 is a ground-up rewrite. The API from v3 is not preserved. See CHANGELOG.md for the mapping.

If you're on v3 and don't want to migrate, the v3.x line will continue to receive security and correctness fixes.

License

ISC

About

Build forms in React with pride 🦁

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors