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.
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.
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>andFieldManager<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
initialValuesload asynchronously.
Current bundle: ~6.8 KB gzip (ESM), including 15 built-in validators.
# Try v4 (beta):
npm install formular@next
# Stay on v3 (stable):
npm install formularPeer: react >=18 for v4. A runnable end-to-end example lives at
examples/vite-react.
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.
FormContextProvider<FieldValues>β creates aForminstance and puts it on context. Props:initialValues,onSubmit,onSubmitError,onChange,formId,children. IfinitialValueschanges 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. DefaultshtmlFortofield.nameunlessidis provided. Headless β pass your ownclassNames.FieldErrorβ renders the field's current error message as anaria-live="polite"region (or nothing when no error).
useForm<FieldValues>()β returns the ambientForminstance.useFormContext<FieldValues>()β alias ofuseForm; 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 constrainsnametokeyof FieldValuesand returnsFieldManager<FieldValues[K]>.useField<T>(name)β read-only subscription to a field that some other component registered. Returnsundefineduntil it registers.useFormValidation()β returns stable{ validate, submit, reset }callbacks bound to the form.
Fields support three, complementary validation sources, run in this order:
required: trueβ empties fail with"This field is required".schemaβ any Standard Schema v1 implementation (Zod 3.24+, Valibot 0.40+, ArkType, β¦). First issue message becomes the error.validatorsβ array of functions returningstring | nullorPromise<string | null>. First non-null wins.
Each stage short-circuits on the first error.
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.
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'svalidationDelayoption 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.
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
}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.
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) => { β¦ })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 untouchedgetValues() 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.
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 βProvideris 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-createdForminstance. Use it directly if you want a single Provider in your test tree.
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
formularpill 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).
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.
Two patterns work:
- Gate the provider on data β render
FormContextProvideronly once server data is ready. - Hydrate lazily β render the provider immediately, then call
form.setValues(data)or re-seed via changinginitialValues.setValuesbuffers keys for fields that haven't mounted yet, so ordering between data arrival and field registration doesn't matter.
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.
ISC