Changelog
All notable changes to @mongez/react-form are documented here. The format follows Keep a Changelog and this project follows Semantic Versioning.
[4.0.0] 2026-08-18 Added (25) · Fixed (4) · Security (3) · Changed / BREAKING (6)
Major rewrite. The form’s logic now lives in a React-free FormEngine class, and Form / NativeForm are function components built over it. Most apps upgrade with no code changes; the breaking items below are narrow. A migration guide follows the change lists.
Added
FormEngine— a plain, platform-agnostic class (not aReact.Component) that owns all form logic: registration, validation, value collection, dirty tracking, events, hydration, and the submit pipeline. It implementsFormInterfaceand can be unit-tested in isolation.Form/NativeFormhold one in a ref and expose it viaref.- Function-component
FormandNativeForm(forwardRef). The engine instance is reachable through the componentref(useImperativeHandle), soref.currentis aFormEngine/FormInterface. - Standard Schema support (zero runtime dependency — schemas are duck-typed on the
~standardproperty, so@warlock.js/seal,zod,valibot,arktype, … all work without being imported):- Whole-form:
<Form schema={...}>validates the collected values on submit; each issue is mapped back to its control byissue.path→ dot-notation name →control.setError. Issues whose control is not in the validated subset are ignored, sovalidateVisible()won’t fail on hidden fields. - Per-field:
useFormControl({ schema })(oroptions.schema) wraps the schema as anInputRuleappended last in the control’s pipeline. - Type inference:
FormProps<Schema>typesonSubmit’svaluesas the schema’s inferred output. New helpers exported fromstandard-schema:InferFormValues,InferFormInput,StandardSchemaV1,isStandardSchema,standardSchemaToRule,runStandardSchema,issuePathToName.
- Whole-form:
- Reactive hydration:
<Form values={...}>re-hydrates already-mounted controls when its object identity changes and seeds later-mounting controls (engine.hydrationValues, read viaform.getInitialValue(name)). New engine helpers:form.fill(values, { dirty?: false, validate?: false }),form.setValues(...)(alias offill), andsetDefaultValue(...)(re-hydrates only pristine, non-dirty controls).defaultValuestays the reset baseline. form.setErrors({ "dot.name": message })— bulk server-error mapping (e.g. an HTTP 422 response) onto controls by dot-notation name; names with no matching control are ignored.useFieldArray(name)→{ fields: [{ key, index, name }], append, prepend, remove, insert, move, swap, replace }. Stable keys survive reordering/removal; input names derive from each row’s index so the form collects the rows as an array.useWatch()— reactive value reads:useWatch()(whole values object),useWatch(name)(one control),useWatch(names[])(several). Re-renders on any control change or form reset.validateOnmodes —"change" | "blur" | "submit", resolved per-control prop ><Form validateOn>>setFormConfigurations({ validateOn })global >"change"."blur"/"submit"still revalidate on change once the field has errored or the form has been submitted. New hook returnonBlur; new engine flagwasSubmitted.- Accessibility helpers on
useFormControl:getInputProps(overrides)returns a complete prop bag (id,name,value/checked,onChange,onBlur,ref,disabled,aria-invalid,aria-required,aria-describedby),getErrorProps()returns{ id, role: "alert", "aria-live": "polite" }, pluserrorId. - Awaited submit: when
onSubmitreturns a Promise, the engine auto-clears the submitting state once it settles (success or failure) — no manualform.submitting(false)needed. SynchronousonSubmitbehaves as before. isValidating— new in-flight async-validation flag, exposed onFormControl, on the engine’s per-control state, and on theuseFormControlhook return.- Form-level
"change"event — the engine now emits a singlechangeevent on every control change (consumed byuseWatch). - SSR-safe form id via React
useId()(see Fixed). - Default English validation messages ship out of the box. Importing the package registers the English
validationbundle once (overridable by your ownextend("en", …)at app entry), so errors render as readable text with zero setup instead of raw keys likevalidation.required. useFormState()— a one-stop reactive snapshot of form-level state:{ isValid, isDirty, isSubmitting, isSubmitted, isValidating, formErrors }(theisValidatinghere is the form-level aggregate across controls).<Form focusFirstError>— opt-in: moves focus to the first invalid control after a failed validation/submit.form.formErrors— form-level error messages with no owning control. Whole-form schema issues with a root/empty path (cross-field errors like “passwords don’t match”) now land here and block submission instead of being silently dropped.form.reset(values?)— reset to a new baseline (merged intodefaultValue), e.g. after saving an edit form.form.validate(names?)— validate a subset by control name (in addition to passingFormControl[]).form.isDisabled()andenable()on the publicFormInterface;form.control(...).onClear(cb)to observeclear().- Next.js App Router support: the client-only components and hooks now ship a
"use client"directive at the leaf-module level, so they can be imported into a Server Component tree without the consumer marking their own file. Pure exports (rules, types,standard-schemahelpers,configurations,locales,FormEngine) stay server-importable. "sideEffects"allowlist inpackage.jsonso the default-locale registration (locales/register-defaults) survives tree-shaking while the rest of the package stays tree-shakeable.
Fixed
- Async validation now genuinely gates submission.
formControl.validate()resolves the rendered error before submit proceeds, so a failing async rule blocksonSubmit. A sync-fast-path keeps purely-synchronous rules synchronous (no added microtask) and goes async only when a rule returns a Promise. Stale async results are discarded via a per-control monotonic sequence token (validationSeq). validateOnno longer leaks onto the DOM.validateOn(andschema) are destructured out of the props forwarded to the host element, so they no longer appear as unknown attributes on the rendered<form>/<input>.- SSR form-id hydration mismatch fixed. The id now comes from
useId()(form-<id>) instead of a construction-timeMath.random()(frm-<random>), so the server and client render the sameidattribute. removeFromFormsListleak fixed. Unmounting a form now removes it from the active-forms registry (FormEngine.destroy()calls bothremoveActiveFormandremoveFromFormsList), so torn-down forms no longer linger in the global map.
Security
- Prototype pollution through dot-notation field names (
src/engine/FormEngine.ts:1048). Collecting values expanded a control’s dot-notationnameinto nested objects by assigning into plain{}accumulators, so a control named__proto__.isAdmin(orconstructor.prototype.isAdmin) wrote through toObject.prototypethe moment the form’s values were gathered. Field names are usually author-written, but not always: schema-driven and CMS-driven forms build them from server data, and a rendered form is exactly the kind of surface where “just data” becomes a name. Those key segments are now rejected, and the partial branch built before the rejected segment is discarded rather than left behind on the result. - ReDoS in the
patternrule (src/rules/pattern.ts). The pattern was compiled to a freshRegExpon every keystroke (no caching), and both the pattern and the value it ran against were unbounded — a catastrophically-backtracking pattern from server- or CMS-driven field config would pin the browser’s main thread on a long input. Compiled patterns are now cached by source+flags, the pattern source is capped at 200 characters, a value over 2000 characters fails the rule rather than being truncated and matched (truncating first would let an anchored pattern like^[0-9a-f]+$pass on a valid prefix with an arbitrary tail), and an oversized or syntactically-invalid pattern fails safe: validation is skipped rather than blocking the user on what is a configuration problem, not their input. - Sparse-array memory blow-up via numeric field-name segments (
src/engine/FormEngine.ts,createNestedObjectFromDotNotation). A control named e.g.items.4000000000.xproduced an array withlengthin the billions, which could exhaust memory or pin the main thread the first time the collected values were iterated or serialized. Numeric name segments above 10,000 are now treated as a plain object key instead of an array index.
Changed / BREAKING
FormandNativeFormare now function components. Arefon either yields theFormEngine(still assignable toFormInterface) rather than a class-component instance. If you relied on class-component semantics (e.g. subclassing the rendered component, calling React lifecycle methods on the ref), see the migration guide.BaseFormis deprecated and is now a type/value alias forFormEngine, kept for one major version (removed in v5). Custom renderers should subclass / composeFormEngineinstead of subclassingBaseForm.formControl.validate()now returnsReactNode | Promise<ReactNode>(the engine’sFormControl.validate()is typedPromise<ReactNode>). Code that treated the old return value as a synchronous error mustawaitit (or handle the union) when an async rule or schema is in play.defaultValueclarified as the reset baseline. It seeds controls and is whatform.reset()restores to. For data that arrives after mount (edit forms), use the reactivevaluesprop orform.fill()instead —defaultValueis no longer the right tool for late-arriving values.useIdrenamed touseControlId, to stop shadowing React 18’s ownuseId.import { useId } from "@mongez/react-form"no longer resolves.useValue,useError, anduseCheckedhooks removed with no alias. UseuseFormControl(oruseWatchfor reactive reads) instead.
Migration guide
Most apps need no changes. Review these only if they apply:
- Refs to
<Form>/<NativeForm>. The ref is now aFormEngine(typedFormInterface). All the existing methods (validate,values,submit,reset,on,control, …) are unchanged. Update anyreftype annotations from the old class component toFormInterface(orFormEngine). BaseFormsubclasses / imports.BaseFormstill imports and still works (it’s an alias forFormEngine) but is deprecated. To customize rendering, render a thin function component that lazily instantiates aFormEngine(seeuseFormEngine) instead of subclassingBaseForm. Plan to migrate before v5.- Manual
formControl.validate()callers. It may now return aPromise. If you call it directly and inspect the result,awaitit (or branch on the union) so async rules and schemas are handled. The built-in submit pipeline already awaits internally. - Async submit handlers. If your
onSubmitis async, you can delete manualform.submitting(false)calls — the engine clears the submitting state when the returned Promise settles. (Keep them only ifonSubmitis synchronous and you toggle submitting yourself.) - Late-arriving form data (edit forms). If you were stuffing fetched records into
defaultValueafter mount and expecting controls to update, switch to the reactivevaluesprop (or callform.fill(record)).defaultValueis now strictly the reset baseline. - Custom inputs forwarding all props to the DOM.
validateOnandschemaare now stripped before forwarding, so if you previously filtered them yourself you can drop that workaround. import { useId } from "@mongez/react-form". Rename the import touseControlId; the return value and usage are unchanged.useValue/useError/useCheckedimports. These hooks are gone. Replace them withuseFormControl(props)(destructurevalue,error,checkedfrom its return) oruseWatch(name)for a reactive read outside a control.
[3.4.8] 2026-06-18 Docs (3)
Docs
- SSR: pass a static
idto every<Form>. Documented that an omittedidmakes<Form>generate a randomfrm-<random>id at construction (viaMath.random()), which differs between the server render and the client hydration and triggers a React hydration mismatch. A stable, uniqueidper form makes the rendered<form id="form-…">attribute deterministic on both sides. Added to the getting-started skill,llms.txt, andllms-full.txt. Input ids are already SSR-safe (derived fromnameasinput-<name>), so only the<Form>wrapper needs an explicit id. - Refreshed the getting-started skill into the highlight-cards format used across the other
@mongezpackage skills — feature highlights, a 30-second quick peek, and step-by-step setup. - Trimmed the
TRIGGER/SKIPauto-trigger lines from every skill’s frontmatterdescription, leaving one concise description per skill.
[3.4.7] 2026-05-27 Docs (1) · Tests (1) · CI (1) · Changed (3) · Fixed (4)
Docs
- Rewrote
README.mdfrom a ~2800-line tutorial into a ~500-line dense reference: top-of-file pitch, a 30-second tour, one canonical pattern per hook/component, a table-driven rule reference, and recipe-driven sections for submit flows, multi-step forms, React Native, and theBaseFormextension path. The exhaustive API reference now lives inllms-full.txt, which the README links to.
Tests
- Added a Vitest suite (
vitest.config.tswith happy-dom and sibling-package source aliasing, plussrc/__tests__/setup.tsregistering the English validation translations). Coverage spans value collection and dot-notation nesting into objects/arrays,ignoreEmptyValues, form-level vs control-leveldefaultValueprecedence,HiddenInput, id derivation, every built-in rule (including the compositestrongRuleper-criterion errors), submit-button state, the form-events lifecycle,NativeForm, and the active-forms registry.
CI
- Added
.github/workflows/test.yml— Node 18/20/22 on Ubuntu, Node 20 on Windows, plus a Node 20 job pinned to React 19 to surface concurrent-rendering regressions early.
Changed
- Declared
@mongez/reinforcementsas an explicitdependenciesentry (it was already imported byBaseForm/useFormControland resolved transitively). - Raised
peerDependencies.reactfrom>=16.8.0to>=18.0.0to match the tested matrix. - Cleaned
keywords— removed marketing-only entries (material-io,semantic,formik) and addedreact-native,headless.
Fixed
integerRule— the predicate now uses||instead of&&, so numeric-but-non-integer inputs like"3.14"are correctly rejected (previously they passed as valid integers).maxRule— replaced the falsy short-circuit (!value) with an explicit empty check, so a numeric0is validated instead of skipped.useFormControlonIniteffect — no longer re-runs on every render: the latest props are read through a ref, and therulesdependency is a stable name-based key, so consumers passing freshrules={[...]}literals don’t retrigger subscriptions each commit.reset()— now clearsformControl.isDirtybefore writing the value (via a newdirty: falsechange option), so the per-control listener sees the cleared flag and the aggregatedirty(false)event fires as expected.
[3.4.6 and earlier] Earlier releases
Per-release notes for 3.4.6 and earlier predate this changelog format. The full version history is available via the git tags and GitHub releases.