Field-level state
Each field is its own Jotai atom. Typing re-renders that field and nothing else, even in forms with thousands of inputs.
Wit Form is a headless form library that keeps every field in its own Jotai atom. Typing re-renders that one field, so forms with thousands of inputs stay fast without memoizing anything.
$ pnpm add wit-form jotaiThe counters show renders. Type in a field: only its counter, and the preview that watches it, go up.
Why atoms
Most form libraries keep values in one shared object, so every keystroke re-renders every field. Wit Form gives each field its own atom: an update only reaches the field that changed.
Edit any field in either form. Keystrokes go to both, and the counters are real React renders.
Each keystroke replaces the form object, so every field that reads it renders again, unless you memoize and tune selectors.
The input writes to its own atom. The rest of the form doesn't notice, with no memo, selectors or manual tuning.
Each form has 11 fields. Watch how many of them a single keystroke wakes up.
The API
Wit Form manages state and validation. You keep full control of markup, styling and accessibility.
useField hands you a value, a setter and an error. Wire them to any input, from a native element to your design system.
Read the guideimport { FormProvider, useField, useForm } from 'wit-form';
function TextField({ name, label }: { name: string; label: string }) {
const { fieldValue, setFieldValue, onBlur, error } = useField<string>({
name,
validate: (value) => (!value ? 'Required' : null),
});
return (
<label>
{label}
<input
value={fieldValue ?? ''}
onChange={(e) => setFieldValue(e.target.value)}
onBlur={onBlur}
/>
{error && <span role="alert">{error}</span>}
</label>
);
}
function Profile() {
const { handleSubmit } = useForm({ onSubmit: save });
return (
<form onSubmit={handleSubmit}>
<TextField name="name" label="Name" />
<TextField name="address.city" label="City" />
<button>Save</button>
</form>
);
}
export const ProfileForm = () => <FormProvider><Profile /></FormProvider>;Every row gets a stable rowId. Append, insert and remove rows, and only the cells that change re-render.
Read the guideconst { fieldArrayProps, append, insert, remove, error } = useFieldArray({
name: 'items',
fieldNames: ['product', 'qty'],
validate: (rows) => (rows.length === 0 ? 'Add at least one item' : null),
});
return (
<>
{fieldArrayProps.rowIds.map((rowId, index) => (
<div key={rowId}>
<Cell name="product" rowId={rowId} />
<Cell name="qty" rowId={rowId} />
<button onClick={() => insert(index + 1, { qty: 1 })}>Insert</button>
<button onClick={() => remove(index)}>Remove</button>
</div>
))}
{error}
<button onClick={() => append({ qty: 1 })}>Add item</button>
</>
);Validate a field, a whole list or the full form. depFields re-run a rule when the fields it depends on change.
Read the guide// Re-validates whenever "password" changes
useField({
name: 'confirmPassword',
depFields: ['password'],
validate: (value, other) =>
value !== other?.values.password ? 'Passwords do not match' : null,
});
// Form-wide rules run on submit, together with field rules
useForm({
onSubmit,
validate: (values) =>
values.startDate > values.endDate
? ['Start date must be before end date']
: [],
});Subscribe to one field, one column of a table or the whole form. Only the component that watches re-renders.
Read the guide// Re-renders only when qty or price change in any row
function OrderTotal() {
const { values: rows } = useFieldArrayColumnWatch({
fieldArrayName: 'items',
fieldNames: ['qty', 'price'],
});
const total = rows.reduce((sum, row) => sum + row.qty * row.price, 0);
return <output>{total}</output>;
}
// Enable Save only after something changed
function SaveButton() {
const isDirty = useIsDirty();
return <button disabled={!isDirty}>Save</button>;
}Features
Headless by design: Wit Form owns state and validation, and your components own the UI.
Each field is its own Jotai atom. Typing re-renders that field and nothing else, even in forms with thousands of inputs.
Watch one field, one table column or the whole form. Only the component that watches re-renders.
Store data next to a value, like a select option's label, and get it back in onSubmit.
Name a field address.city and submit gets { address: { city } }. No reshaping needed.
Add, insert, move, swap and remove rows, including lists inside lists, with stable row ids.
Per field, per list and form-wide. Plain functions or any Standard Schema (Zod, Valibot, ArkType), sync or async with debouncing.
No UI ships with the library. Wire useField to native inputs or to your design system.
Initial values, form state flags, server errors, focus on the first invalid field, resets and multi-step wizards.
About 10 kB gzipped, ESM-only. createFormHooks type-checks every field name and value. React and Jotai are the only peers.
Open the render-performance example and watch the counters: editing a cell re-renders that cell and its column total, while the other rows stay put.
Try the live benchmarkNo. Jotai is the store under the hood, so you install it next to Wit Form, but you never create or read atoms yourself. You work with useForm, useField and the other hooks.
No. Each FormProvider creates its own store, so forms stay isolated from your app state and from each other. If you want a form in a shared store, for example to read it from outside its provider, pass skipJotaiProvider: true. See FormProvider.
Yes. Pass any Standard Schema (Zod, Valibot, ArkType, Effect Schema, Yup 1.7+) as schema, for a field, a list or the whole form, with no adapter to install. Errors land on the matching fields. You can also call any library from a plain validator. See schema validation.
No, it's headless. You write small field components with useField once, styled like the rest of your app, and reuse them in every form. They can wrap native inputs or your design system.
React 18 or newer, with Jotai 3 or newer. The package is ESM-only and ships its own TypeScript types. In the Next.js App Router, use the hooks in client components (files with 'use client').
Wit Form is its successor, with the same API on Jotai in place of Recoil. Swap the packages, change the imports, and remove any RecoilRoot you added only for forms. See the migration guide.
Install the package, wrap your form in a provider and call useField. That's the whole setup.
$ pnpm add wit-form jotai