v4.0.0-beta.1
Pre-release4.0.0-beta.1
First published release of the v4 line. The pre-release line under the
next dist-tag; latest remains on v3 until external adopters
confirm stability. Rollup of everything built across six in-tree alphas.
Summary
- Ground-up rewrite. The public API from v3 is not preserved β see
MIGRATION.md for the v3 β v4 mapping. - Class-based
Form<T>+FieldManager<T>pair.FormContextProvider
puts a single form on React context; hooks subscribe to it via
useSyncExternalStore. Components re-render only on the slice they
read. - Peer dep:
react >=18. Bundle: 5.96 KB brotli (ESM, everything),
4.32 KB (tree-shaken core). Under CI size budgets.
What's in this release
Core
Form<T>/FieldManager<T>replace v3'sForm/Field/
FormGrouptrio. Nesting is expressed via field names;FormGroup
is not ported.FormContextProvider<T>+useFormContext<T>for an ambient form.useSyncExternalStore-based hooks βuseForm,useFormState,
useFieldRegister,useField,useFormValidationβ each
subscribes to a specific slice so siblings don't thrash.- Async validation sequenced by a monotonic counter (replaces v3's
CancelablePromise). Stale runs discard themselves. handleSubmit(onSuccess?)returns a React-safe submit handler.
Distinctsubmitandsubmit errorevents with a
phase: 'submit' | 'onSuccess'tag on errors.setValues/setInitialValuesbuffer writes for fields that
haven't registered yet, so async-hydrated data doesn't race field
mounts.
Typed API (the headline)
-
createForm<T>()factory closes the form shape over a namespace
of hooks βuseFieldRegister,useFieldArray,useField, plus the
Provider β so literal path arguments narrow at the call site:const contact = createForm<ContactForm>() const street = contact.useFieldRegister('address.street') // ^? FieldManager<string>
TypeScript's partial-explicit-generic inference doesn't narrow
consttype parameters in hook signatures; the factory sidesteps
that by keeping only one generic in play at call-sites. -
Typed nested paths:
Path<T>,PathValue<T, P>,ArrayPath<T>,
andDeepPartial<T>are exported.setValues/setInitialValues
takeDeepPartial<T>and walk down to each registered field.
getValues()reconstructs the nested shape.
Validation
- Standard Schema v1 accepted directly at the field level
(Zod 3.24+, Valibot 0.40+, ArkType, β¦). TheFieldOptions.schema
option runs the schema beforevalidators; the first issue message
becomes the field error. No@hookform/resolvers-style adapter
package required β the interface is inlined at
src/standard-schema.tsand re-exported asStandardSchemaV1. - Pipeline order:
requiredβschemaβvalidators. Each stage
short-circuits on the first error. - 15 built-in function validators kept for BYO (
minLength,
maxLength,pattern,email,url,phoneNumber,numeric,
min,max,creditCard,dateFormat,minAge,confirmField,
asyncValidator,compose). Recommendation in the README: prefer
schemafor new projects.
Field arrays
useFieldArray(path)with the usual list operations:append,
prepend,insert,remove,swap,move,replace,clear.
Returns{ fields, ...ops }wherefields[i].idis stable across
mutations so React keys survive reorders.- Per-row field reindexing: sub-fields registered at
items.<N>.<rest>are atomically renamed when list operations run.
A field's state (value, error, touched, subscriptions) carries over
to its new index rather than being orphaned.replaceandclear
destroy the sub-fields; new rows register fresh. Observable via the
field renamedevent.
Components
- Headless
FieldLabelandFieldError. Plain<label>and an
aria-live="polite"div. No styling opinions β pass your own
classNames.
Tooling
-
Build: tsdown. ESM + CJS output with a proper
exportsmap and
sideEffects: false. -
Test: Vitest. 74 tests colocated under
src/**/*.spec.ts. -
Lint: ESLint 9 flat config.
-
CI matrix: Node 18 / 20 / 22 running
typecheck+lint+buildtest+size.
-
Bundle-size budget in CI via
size-limit:Bundle Limit (brotli) Actual ESM entry 6.5 kB 5.96 kB CJS entry 7 kB 6.22 kB Tree-shaken core 5 kB 4.32 kB
Notable fixes discovered during the alpha series
setByPathused to mutate caller-owned arrays and objects when
reconstructing form state, which silently corrupted field-array
internals (out.rows[2] = {}on a 2-item field would expand the
live array to 3 items).setByPathnow shallow-copies each
container on descend.unregisterFieldnow takes an optionalexpectedFieldreference
and skips destruction if a different FieldManager occupies the
path β otherwise a staleuseEffectcleanup could destroy a field
that a concurrent array reindex moved there.FieldManager.validateno longer hangs when a validator throws
(v3 bug that survived into alpha.1; fixed in alpha.2).
Example app
examples/vite-react is a runnable Vite + React 19 contact form
exercising nested paths, useFieldArray, and Zod schemas (including
an async .refine()). See its README.