# @mongez/react-form — Full Documentation (LLM-optimized) A headless React form handler for Web and React Native. The library owns state, validation, and value collection; the consumer owns rendering. This file is structured for LLM consumption: each section is self-contained, canonical patterns appear once, and the API reference is at the end. For tutorial-style human-readable docs, see README.md. --- ## 0. What's new in v4 (major rewrite) v4 keeps the shape of the everyday API (`useFormControl`, the rules system, value collection) compatible but rebuilds the core, and carries **three breaking changes** — see "Breaking changes" below before assuming a no-op upgrade. - **`Form` / `NativeForm` are now function components** (`forwardRef`). They lazily instantiate a single **`FormEngine`** and expose it via `ref`. The engine is the thing your `ref` and `useForm()` return — it implements `FormInterface`. - **`FormEngine` is a plain class** (not a `React.Component`). It is React-free, unit-testable in isolation, and the home of all form logic. - **`BaseForm` is a deprecated alias for `FormEngine`** (`export const BaseForm = FormEngine`), kept for one major. To support a custom renderer, subclass `FormEngine` and render a thin function component over it instead of subclassing `BaseForm`. - **SSR-safe id via React `useId()`** — the auto-generated form id (`form-`) is now stable across server/client, fixing the old `Math.random()` hydration mismatch. No `id` prop is required for SSR. - **Async validation genuinely gates submission.** `formControl.validate()` returns `ReactNode | Promise`. Sync rules stay synchronous (no extra microtask); the path goes async only when a rule returns a Promise. New per-control `isValidating` flag (on `FormControl` and the hook return). Stale async results are discarded via a per-control sequence token. - **Standard Schema support** (`@warlock.js/seal`, `zod`, `valibot`, …) with **zero runtime dependency** — whole-form (`
`) and per-field (`useFormControl({ schema })`), plus type inference. - **Reactive hydration**: the `values` prop re-hydrates mounted controls and seeds later-mounting ones; `form.fill()`, `form.setValues()`, `form.setDefaultValue()`. `defaultValue` stays the reset baseline. - **Bulk server errors**: `form.setErrors({ "dot.name": message })` (HTTP 422 mapping). - **Awaited submit**: if `onSubmit` returns a Promise, submitting state auto-clears when it settles. - **`validateOn: "change" | "blur" | "submit"`** — per-control prop > `` > global config > `"change"`. - **New hooks**: `useFieldArray`, `useWatch`. **New a11y helpers** on the `useFormControl` return: `getInputProps`, `getErrorProps`, `errorId`, `onBlur`. - **Security hardening**: dot-notation name collection rejects `__proto__` / `constructor` / `prototype` segments (prototype-pollution guard) and caps numeric segments at 10,000 before treating them as an array index (sparse-array memory guard); `patternRule` caches compiled patterns, caps the source at 200 chars, and fails closed on values over 2000 chars (ReDoS guard). See §9 and §5. ### Breaking changes - **`useId` renamed to `useControlId`.** `import { useId } from "@mongez/react-form"` no longer resolves — the rename stops the package from shadowing React 18's own `useId`. Same signature (`{ id?, name }`) and behavior. - **`useValue`, `useError`, and `useChecked` hooks removed with no alias.** Use `useFormControl` (destructure `value` / `error` / `checked` from its return) or `useWatch(name)` for a reactive read outside a control. - **`Form` / `NativeForm` refs now yield a `FormEngine`**, not a class-component instance. Only code that subclassed `BaseForm` or relied on class-component lifecycle methods on the ref needs review — the `FormInterface` method surface (`validate`, `values`, `submit`, `reset`, `on`, `control`, …) is unchanged. Each topic has a full section below. --- ## 1. Installation > **Auto-trigger:** code imports `Form`, `NativeForm`, `useFormControl`, `enValidationTranslation`, `arValidationTranslation`, `frValidationTranslation`, `esValidationTranslation`, `itValidationTranslation`, or `deValidationTranslation` from `@mongez/react-form`; user asks "how do I install @mongez/react-form", "how do I set up a form in a new project", or "why does my validation show validation.required instead of a real message"; `import { Form, useFormControl } from "@mongez/react-form"` at app entry. > **Skip when:** `mongez-react-form-create-form-control` for writing custom input components; `mongez-react-form-validation-rules` for picking/composing rules; `mongez-react-form-react-native-usage` for RN-specific wiring beyond initial install; `react-hook-form`, `formik`, or `final-form` projects; locale wiring for non-form `@mongez/localization` usage. ```bash npm install @mongez/react-form ``` Runtime dependencies (`@mongez/events`, `@mongez/localization`, `@mongez/supportive-is`, `@mongez/reinforcements`) install transitively. ## 2. One-time setup — locale registration Validation messages flow through `@mongez/localization`. Register the bundles under the `validation` namespace at app entry: ```ts import { extend } from "@mongez/localization"; import { enValidationTranslation, arValidationTranslation, frValidationTranslation, esValidationTranslation, itValidationTranslation, deValidationTranslation, } from "@mongez/react-form"; extend("en", { validation: enValidationTranslation }); extend("ar", { validation: arValidationTranslation }); // ...register only the locales the app uses ``` Without this, error messages render as raw keys like `validation.required`. --- ## 3. Form components > **Auto-trigger:** code imports `Form`, `NativeForm`, `FormEngine`, `useFormEngine`, or `BaseForm` from `@mongez/react-form`, attaches a `ref` to `` / ``, or imports `useFormControl` / `useForm` / `useSubmitButton` in a file that also imports from `react-native` (e.g. `TextInput`, `Pressable`, `View`, `Text` from `"react-native"`); user asks "how do I use @mongez/react-form on React Native or Expo", "why doesn't my form submit fire on RN", "how do I get the form engine via ref", "what replaced BaseForm in v4", "how do I wire `onChangeText` / `onFocus` / `Pressable` into a form control", or "how do I make a checkbox on React Native"; `import { NativeForm } from "@mongez/react-form"`. > **Skip when:** `mongez-react-form-getting-started` once install and locale registration are done; `mongez-react-form-create-form-control` for the platform-agnostic hook contract; `mongez-react-form-submit-button` for non-RN button patterns; Web-only `Form` component usage; `react-hook-form`/`formik` on RN. Two components, same API, different platforms — **both are function components (`forwardRef`)** built over the React-free `FormEngine`: - **`Form`** — Web. Renders an HTML `` element. Browser submit event drives the submission. - **`NativeForm`** — React Native. Renders a Fragment by default (or any component passed via `component` prop). Submission is always programmatic via `form.submit()`. Both lazily instantiate one `FormEngine` (which implements `FormInterface`) and expose it via `ref`. That same engine is what `useForm()` returns to descendants. ### Accessing the engine via `ref` ```tsx import { Form, type FormInterface } from "@mongez/react-form"; import { useRef } from "react"; const formRef = useRef(null); {/* ... */}; // later, imperatively: formRef.current?.fill({ user: { firstName: "Jane" } }); formRef.current?.submit(); ``` The ref resolves to the `FormEngine` instance — every method on `FormInterface` (`values()`, `validate()`, `fill()`, `setErrors()`, `reset()`, …) is available. ### Form props (both Web and Native) `FormProps` extends `React.FormHTMLAttributes` (minus the overridden keys), so any standard `
` attribute (`className`, `style`, `noValidate`, …) flows through to the rendered element. ```ts type FormProps = { onSubmit?: (options: FormSubmitOptions) => void | Promise; onError?: (invalidControls: FormControl[]) => void; component?: React.ComponentType; // override the rendered element defaultValue?: Record; // RESET BASELINE for every control by name (dot-notation supported) values?: Record; // REACTIVE current values — re-hydrates on identity change (see §15) schema?: Schema; // whole-form Standard Schema validator (see §16) validateOn?: "change" | "blur" | "submit"; // default validation trigger for all controls (see §17) ignoreEmptyValues?: boolean; // omit empty values from form.values() id?: string; // form id — OPTIONAL; auto-derived from React useId() and SSR-safe children: React.ReactNode; }; ``` - **`onSubmit` may return a `Promise`** — when it does, the engine auto-clears submitting state once it settles (§18). Sync `onSubmit` behaves as before. - **`schema` drives `onSubmit` value typing**: `FormProps` types `values` as `InferFormValues` (§16). ### `FormSubmitOptions` (the argument to `onSubmit`) ```ts { form: FormInterface; event?: React.FormEvent; // undefined when programmatically submitted (and always on Native) values: FormValues; // getter — calls form.values() each access; typed from schema when present formData: FormData; // getter — calls form.formData() each access } ``` ### Canonical form (Web) ```tsx import { Form } from "@mongez/react-form"; api.save(values).catch(() => form.submitting(false))} onError={(invalidControls) => scrollTo(invalidControls[0])} defaultValue={{ user: { firstName: "John" } }} ignoreEmptyValues > {/* ...inputs... */} ``` ### Canonical form (React Native) ```tsx import { NativeForm } from "@mongez/react-form"; import { View } from "react-native"; {/* ...inputs... */} ``` ### SSR — the form id is now SSR-safe automatically In v4 the form id is derived from React's `useId()` when no `id` prop is passed (`useFormEngine` computes `props.id ? String(props.id) : "form-" + useId()`). `useId()` returns the same value on the server render and the client hydration, so the rendered `
` attribute is **deterministic on both sides** — the old `Math.random()` hydration mismatch is gone. **No `id` prop is required for SSR correctness.** You may still pass an explicit, static `id`: ```tsx api.signup(values)}> {/* ...inputs... */}
``` - A static `id` makes the `
` easy to target in tests and styles, and it sets the event prefix (`form.`) explicitly. - Keep each form's `id` unique on the page — the id drives the internal event prefix, so duplicate ids cross-wire events. - Inputs derive their id from `name` (`input-`), which is already deterministic. This is implemented by the exported `useControlId({ id?, name })` hook — call it yourself if you need the same id outside `useFormControl`. **Renamed from `useId` in v4** (see "Breaking changes" in §0) so importing it no longer shadows React 18's own `useId`. > Note on the internal engine id: when constructed without options, `FormEngine` falls back to a counter-based id (`frm-`), not `Math.random()`. In practice `Form` / `NativeForm` always pass the `useId()`-derived id, so the rendered attribute is the SSR-safe `form-`. --- ## 4. Form controls — the canonical pattern > **Auto-trigger:** code imports `useFormControl`, `useRadioInput`, `useFieldArray`, `useWatch`, `RadioGroupContext`, `HiddenInput`, `FormControlProps`, `FormControlHook`, or `FormControl` from `@mongez/react-form`; calls `getInputProps` / `getErrorProps` or reads `isValidating` / `errorId` / `onBlur` from the hook; user asks "how do I build a custom text input / checkbox / radio / multi-select for @mongez/react-form", "how do I wire `inputRef` / `otherProps` / `checked` / `setChecked` / `getInputProps`", "how do I build a dynamic list of fields", "how do I watch a field's value", or "how do I make a multi-value input"; `import { useFormControl } from "@mongez/react-form"` in a component file. > **Skip when:** `mongez-react-form-validation-rules` for choosing or writing rules (rules go into the `rules` array, but the rules system itself is a separate skill); `mongez-react-form-submit-button` for submit-button wiring; `mongez-react-form-form-events` for subscribing to lifecycle events; raw React `useState` form inputs unrelated to `@mongez/react-form`; `react-hook-form`'s `useController` or `Controller`. Every input is built around `useFormControl`. The hook registers the input with the surrounding form and returns a state bundle. ### Canonical text input ```tsx import { useFormControl, type FormControlProps } from "@mongez/react-form"; export default function TextInput(props: FormControlProps) { const { value, changeValue, id, error, inputRef, otherProps } = useFormControl(props); return ( <> changeValue(e.target.value)} {...otherProps} /> {error && {error}} ); } ``` Equivalent on React Native: ```tsx import { useFormControl, type FormControlProps } from "@mongez/react-form"; import { TextInput as RNTextInput, Text, View } from "react-native"; export default function TextInput(props: FormControlProps) { const { value, changeValue, inputRef, formControl, error, disabled } = useFormControl(props); return ( (formControl.isTouched = true)} editable={!disabled} /> {error && {error}} ); } ``` ### Checkbox For checkboxes use `checked` / `setChecked`, not `value` / `changeValue`. **Must** set `type: "checkbox"`. ```tsx const { checked, setChecked, id } = useFormControl({ ...props, type: "checkbox" }); ``` Optional collection behavior via the second argument: ```ts useFormControl(props, { uncheckedValue: 0, // value to emit when unchecked collectUnchecked: true, // include unchecked controls in form.values() }); ``` ### Radio group Build a `RadioGroup` (one `useFormControl`) that provides `RadioGroupContext`. Each `RadioInput` consumes via `useRadioInput(value)`: ```tsx import { useFormControl, RadioGroupContext, requiredRule, useRadioInput } from "@mongez/react-form"; export function RadioGroup({ children, ...props }) { const { value, changeValue } = useFormControl({ ...props, rules: [requiredRule] }); return ( {children} ); } export function RadioInput({ value, children }) { const { isSelected, changeValue } = useRadioInput(value); return ( ); } ``` ### Multi-value control ```ts const { value, changeValue } = useFormControl(props, { multiple: true }); // value is always an array ``` ### Hidden input ```tsx import { HiddenInput } from "@mongez/react-form"; ``` ### Hook return shape (`FormControlHook`) ```ts { id: string; name: string; type: string; value: any; changeValue: (value, options?: FormControlChangeOptions) => void; error: ReactNode; errorId: string; // stable id for the error element (input's aria-describedby) errorsList: { [ruleName: string]: ReactNode }; setError: (error: ReactNode) => void; checked: boolean; setChecked: (checked: boolean) => void; inputRef: RefObject; visibleElementRef: RefObject; formControl: FormControl; // escape hatch — the underlying registration disabled: boolean; disable: () => void; enable: () => void; isInvalid: boolean; // touched AND failing validation (getter) isValidating: boolean; // an async validation rule is in-flight (see §14) onBlur: () => void; // blur handler — validates when validateOn === "blur" (see §17) otherProps: object; // pass-through props (excludes hook + rule-preserved keys; validateOn + schema are stripped out) getInputProps: (overrides?) => object; // a11y-complete prop bag for the host input (see §19) getErrorProps: () => { id, role, "aria-live" }; // prop bag for the error element (see §19) validate: () => ReactNode | Promise; } ``` > **`otherProps` no longer leaks `validateOn` or `schema`.** Both are destructured out before the rest props are assembled, so spreading `otherProps` onto a DOM element won't emit invalid attributes. --- ## 5. Validation > **Auto-trigger:** code imports `requiredRule`, `minLengthRule`, `maxLengthRule`, `lengthRule`, `minRule`, `maxRule`, `emailRule`, `numberRule`, `integerRule`, `floatRule`, `urlRule`, `patternRule`, `alphabetRule`, `matchRule`, `strongRule`, or `InputRule` from `@mongez/react-form`; user asks "how do I validate email / required / min length / pattern / password strength in @mongez/react-form", "how do I write a custom validation rule", or "how do I override a validation error message"; `rules: [...]` array passed to `useFormControl` with rule identifiers. > **Skip when:** `mongez-react-form-create-form-control` for the input component contract itself (rules plug into it but aren't the same topic); `mongez-react-form-form-events` for lifecycle events; `mongez-react-form-getting-started` for locale bundle registration; `@mongez/supportive-is` raw predicate checks unrelated to the rules system; `zod`, `yup`, `valibot`, or HTML5 `pattern`/`required` constraint validation. ### Composition Pass `rules: InputRule[]` in the first argument to `useFormControl`. Rules run in array order; the first failing rule short-circuits unless `{ validateAll: true }` is set in the second argument. ```tsx import { useFormControl, requiredRule, minLengthRule, emailRule, } from "@mongez/react-form"; useFormControl({ ...props, rules: [requiredRule, minLengthRule, emailRule] }); ``` ### Built-in rules | Rule | Activated by prop | Type-gated | Notes | |---|---|---|---| | `requiredRule` | `required` | — | Empty = null/undefined/""/[] | | `minLengthRule` | `minLength` | — | Strings + arrays | | `maxLengthRule` | `maxLength` | — | Strings + arrays | | `lengthRule` | `length` | — | Exact length | | `minRule` | `min` | — | Numeric | | `maxRule` | `max` | — | Numeric | | `emailRule` | — | `type="email"` | | | `numberRule` | — | `type="number"` | | | `integerRule` | — | `type="integer"` | | | `floatRule` | — | `type="float"` | | | `urlRule` | — | `type="url"` | | | `alphabetRule` | — | `type="alphabet"` | | | `patternRule` | `pattern` (RegExp) | — | Patterns cached by source+flags; source capped at 200 chars. A value over 2000 chars **fails the rule** (fails closed — it is not truncated and matched, which would let an anchored pattern pass on a valid prefix) (ReDoS guard). An oversized/invalid pattern skips validation instead of failing the field | | `matchRule` | `match` (other input name) | — | Subscribes to the other input's changes | | `strongRule` | `strong` (boolean or object) | `type="password"` | Composite: 5 criteria, per-criterion errors in `errorsList["strong."]` | `requiresType` rules only run when `formControl.type` matches. `requiresValue: true` rules (the default) skip empty values — that's why `requiredRule` must come first and is the only rule with `requiresValue: false`. ### Per-instance message overrides ```tsx // Replace the entire error string // Replace named placeholders within the localized template ``` ### Per-instance custom validation ```tsx { if (!value) return; if (await isTaken(value)) return "Username already taken"; }} /> ``` The `validate` prop accepts sync or async functions. An async (`Promise`-returning) `validate` flips the control's `isValidating` flag and **genuinely gates submission** — `form.validate()` awaits it before the submit pipeline proceeds (§14). The per-instance `validate` runs **first** in the rule list (before the passed `rules`, then any per-field `schema` last). ### Writing a custom rule ```ts import { trans } from "@mongez/localization"; import type { InputRule } from "@mongez/react-form"; export const phoneNumberRule: InputRule = { name: "phoneNumber", requiresType: "phoneNumber", validate: ({ value }) => { if (!/^01[0-2|5]{1}[0-9]{8}$/.test(value)) { return trans("validation.phoneNumber"); } }, }; ``` `InputRule` shape: ```ts { name?: string; validate: (options: InputRuleOptions) => ReactNode | undefined | Promise<...>; requiresValue?: boolean; // default true: skip on empty value requiresType?: string; // run only when control's type matches preservedProps?: string[]; // keep these props OUT of otherProps onInit?: (options) => EventSubscription | undefined; // setup on mount } ``` ### `strongRule` (composite password rule) The only built-in rule with non-trivial configuration. Activated by the `strong` prop, requires `type="password"`. ```ts type StrongPasswordCriteria = { minLength?: number; // default 8 — set to 0 to disable uppercase?: boolean; // default true lowercase?: boolean; // default true digit?: boolean; // default true symbol?: boolean; // default true }; // all defaults // override // disable one ``` Each failing criterion populates a namespaced entry on `formControl.errorsList`: ``` errorsList["strong"] // first failing message (canonical rule entry) errorsList["strong.minLength"] // only if length check failed errorsList["strong.uppercase"] // only if uppercase check failed errorsList["strong.lowercase"] errorsList["strong.digit"] errorsList["strong.symbol"] ``` Use this to drive password-strength checklist UIs without composing five separate rules. Translation keys: `validation.strongMinLength` (with `:length`), `validation.strongUppercase`, `validation.strongLowercase`, `validation.strongDigit`, `validation.strongSymbol`. Override per-criterion via `errors={{ "strong.minLength": "..." }}`. Don't combine with `minLengthRule` — duplicates the length error. ### `validateAll` mode ```ts useFormControl(opts, { validateAll: true }); // error becomes ReactNode[], errorsList[ruleName] populated for every failing rule ``` --- ## 6. Submit buttons > **Auto-trigger:** code imports `useSubmitButton` or calls `form.submitting(true|false)`, `form.submit()`, `form.isSubmitting()`, or `form.disable()` from `@mongez/react-form`; user asks "how do I build a submit button for @mongez/react-form", "why is my submit button stuck disabled after a failed API request", or "how do I disable submit until the form is dirty"; `import { useSubmitButton } from "@mongez/react-form"` in a button component. > **Skip when:** `mongez-react-form-form-events` for subscribing to `submit` / `submitting` / `invalidControls` events directly (use that when not using `useSubmitButton`); `mongez-react-form-create-form-control` for input components rather than submit buttons; native `; } ``` ### React Native ```tsx import { useForm, useSubmitButton } from "@mongez/react-form"; import { Pressable, Text } from "react-native"; export function SubmitButton({ children }) { const form = useForm(); const { disabled, isSubmitting } = useSubmitButton(); return ( form?.submit()}> {isSubmitting ? "..." : children} ); } ``` ### Re-enabling after a failed request ```ts api.save(values).catch(() => form.submitting(false)); ``` This is mandatory — without it the button stays disabled forever. --- ## 7. Form events > **Auto-trigger:** code calls `form.on(...)`, `useForm()`, `getActiveForm()`, or `getForm(...)`, or imports `FormEventType`, `FormInterface`, or `EventSubscription` from `@mongez/react-form`; references events like `submit`, `submitting`, `validating`, `validation`, `validControl`, `invalidControl`, `validControls`, `invalidControls`, `dirty`, `register`, `unregister`, `reset`, `resetting`, or `disable`; user asks "how do I autosave on dirty change", "how do I scroll to the first invalid input", "how do I block submission conditionally", or "how do I track form analytics on submit/validation failure". > **Skip when:** `mongez-react-form-submit-button` when `useSubmitButton` already covers the disabled/submitting state derivation; `mongez-react-form-validation-rules` for writing/composing rules (rules fire validation events but the rule authoring topic is separate); `mongez-react-form-create-form-control` for the input component contract; raw `addEventListener` on a DOM ``; `react-hook-form`'s `watch` / `formState` subscriptions. Subscribe via `form.on(event, callback)`. Returns an `EventSubscription` — unsubscribe in `useEffect` cleanup. ### Event catalog | Event | Payload | When | |---|---|---| | `register` / `registering` | `(formControl, form)` | A control registers | | `unregister` | `(formControl, form)` | A control unregisters | | `validating` | `(form)` | Pre-validation — return `false` to abort | | `validation` | `(isValid, validatedInputs, form)` | Validation completed | | `validControl` / `invalidControl` | `(formControl, form)` | Per-control transition | | `validControls` / `invalidControls` | `(controls[], form)` | Debounced aggregate state | | `submitting` | `(isSubmitting, form)` | In-flight state changes | | `submit` | `(form)` | After submission completes (and again on `submitting(false)`) | | `resetting` / `reset` | `(form)` | Reset lifecycle | | `dirty` | `(isDirty, form)` | Aggregate dirty state changed | | `disable` | `(isDisabled, form)` | `form.disable()` / `form.enable()` | ### Submit ordering `validating` → per-control validation → `validControl` / `invalidControl` per control → `validation` → `validControls` or `invalidControls` (debounced) → if invalid: `onError` prop called → if valid: `submitting(true)` event → `onSubmit` prop called → `submit` event. `submit` may fire twice per user action (once after sync submit, once on `submitting(false)`). Listeners must be idempotent. ### Per-control events ```ts formControl.onChange(callback); formControl.onReset(callback); formControl.onDestroy(callback); ``` --- ## 8. Form-level helpers ### Access from anywhere ```ts import { useForm, getActiveForm, getForm } from "@mongez/react-form"; useForm(); // hook — returns the surrounding form or null getActiveForm(); // module-level — returns the most recently mounted form getForm("form-id"); // module-level — returns by id ``` ### Programmatic API (`FormInterface`) ```ts form.submit(); // trigger validation + submission form.validate(controls?); // Promise — awaits async rules form.validateVisible(); // validate only visible controls (Web only; on RN it's identical to validate()) form.values(names?); // collect values as nested object form.value(name); // single control value by name form.formData(); // FormData object (for multipart uploads) form.controls(names?); // array of FormControl form.control(name, getBy?); // single FormControl by name or id form.change(name, value); // mutate a control's value programmatically form.fill(values, options?); // bulk-write values + seed late controls (§15) form.setValues(values, options?); // alias of fill form.setDefaultValue(defaultValue); // update reset baseline; re-hydrate pristine controls (§15) form.setErrors({ "dot.name": message }); // bulk server-error (422) mapping (§15) form.getInitialValue(name); // seed value from hydration snapshot then baseline form.reset(); // reset all to initial values form.resetErrors(); // clear errors only form.disable(isDisabled); // disable/enable all controls form.enable(); // shorthand for disable(false) form.isValid(); // boolean form.isSubmitting(); // boolean form.submitting(true | false); // toggle in-flight state form.wasSubmitted; // boolean — true after the first submit attempt (§17) form.on(event, callback); // event subscription form.id; // string form.formElement; // HTMLFormElement (Web) | any (Native) form.schema; // the whole-form Standard Schema, if provided form.validateOn; // form-level default validation trigger ``` ### Default values Set at the form level (preferred for shared default sets): ```tsx ``` Or per-control (overrides form-level): ```tsx ``` Per-control `defaultValue` wins; otherwise the form-level value is looked up by name (dot-notation honored). ### Ignoring empty values ```tsx ``` Causes `form.values()` to skip null/undefined/empty-string/empty-array values. Set globally via `setFormConfigurations({ ignoreEmptyValues: true })`. ### Configuration ```ts import { setFormConfigurations } from "@mongez/react-form"; setFormConfigurations({ ignoreEmptyValues: true, // default false formComponent: MyCustomForm, // default "form" — replaces the rendered element for ALL Web Forms validateOn: "blur", // default "change" — global validation trigger for all controls (§17) }); ``` --- ## 9. Input name and value collection The `name` prop supports **dot notation** and is mapped into nested objects: - `user.firstName` → `{ user: { firstName: "..." } }` - `addresses.0.city` → `{ addresses: [{ city: "..." }] }` - `tags[0]` → `tags.0` (same as `tags.0`) - Repeated names → collected into an array, or use `multiple: true` to force array form `form.formData()` emits the same nested structure as bracket notation on the wire: - `{ user: { firstName: "X" } }` → `user[firstName]=X` - `{ tags: ["a", "b"] }` → `tags[]=a&tags[]=b` ### Security hardening on dot-notation expansion (v4) Field names are usually author-written, but schema-driven and CMS-driven forms build them from server data — treat a rendered form's field names as a potential input boundary: - **Prototype-pollution guard.** A name segment of `__proto__`, `constructor`, or `prototype` (e.g. `__proto__.isAdmin`) is rejected rather than written through to `Object.prototype`; the partial branch built before the rejected segment is discarded, not left on the result. - **Sparse-array memory guard.** A numeric segment above `10,000` (e.g. `items.4000000000.x`) is treated as a plain object key instead of an array index, so it can't force-allocate a multi-billion-length array. Both guards live in `createNestedObjectFromDotNotation` in `src/engine/FormEngine.ts`, run on every `form.values()` / `form.collectValues()` call. --- ## 10. Stepper / multi-step forms Use `form.validateVisible()` between steps. Each input (or its wrapper) must attach `visibleElementRef`: ```tsx const { visibleElementRef, value, changeValue, error } = useFormControl(props); return (
changeValue(e.target.value)} />
); ``` ```ts await form.validateVisible(); if (form.isValid()) goToNextStep(); ``` **Inactive steps must stay mounted but hidden** for this to work — `visibleElementRef.current.hidden` (or any ancestor's `hidden`) is what the check looks for. Unmounted steps are simply not registered. On React Native, `validateVisible()` is identical to `validate()` (the DOM-based visibility check is a no-op on Native). --- ## 11. Active forms registry ```ts getActiveForm(); // most recently mounted form getForm("form-id"); // by id ``` The "active form" tracks the most recently mounted form globally — when forms unmount, the previous active form (if still mounted) is restored. Useful for non-React code (deep-link handlers, global keyboard shortcuts, autosave drivers). --- ## 12. Type reference ### `FormControl` (key fields) ```ts { id: string; name: string; type: string; value: any; initialValue: any; checked: boolean; initialChecked: boolean; isDirty: boolean; isTouched: boolean; isValidating: boolean; // an async validation rule is in-flight (§14) isValid: boolean | null; // null = not yet validated error: ReactNode; errorsList: { [ruleName: string]: ReactNode }; disabled: boolean; multiple?: boolean; isControlled: boolean; rendered: boolean; defaultValue?: any; uncheckedValue?: any; collectUnchecked?: boolean; inputRef: any; visibleElementRef: any; data?: any; change(value, opts?): void; setChecked(checked): void; setError(error): void; validate(): Promise; // awaited by form.validate() so async rules gate submit (§14) isCollectable(): boolean; collectValue(): any; isVisible(): boolean; focus(): void; blur(): void; clear(): void; reset(): void; disable(isDisabled): void; unregister(): void; onChange(callback): EventSubscription; onReset(callback): EventSubscription; onDestroy(callback): EventSubscription; } ``` ### `FormControlProps` (consumer-facing prop type) ```ts { name: string; // required id?: string; type?: string; // default "text" value?: any; // controlled value defaultValue?: any; // uncontrolled initial value checked?: boolean; // controlled checked defaultChecked?: boolean; required?: boolean; disabled?: boolean; readOnly?: boolean; placeholder?: string; label?: ReactNode; rules?: InputRule[]; validate?: InputRule["validate"]; // per-instance custom validation (sync or async) schema?: StandardSchemaV1; // per-field Standard Schema — wrapped as a rule, appended last (§16) errors?: { [ruleName: string]: ReactNode }; // override messages per rule errorKeys?: { [placeholder: string]: ReactNode }; // override message placeholders onChange?: (value, options) => void; onError?: (error: ReactNode) => void; validateOn?: "change" | "blur" | "submit"; // per-control validation trigger (§17) [key: string]: any; // anything else flows to otherProps } ``` `FormControlOptions` (the 2nd `useFormControl` argument) also accepts a `schema` field — equivalent to the `schema` prop, handy when authoring a reusable input wrapper. ### `InputRule` ```ts { name?: string; validate: (opts: InputRuleOptions) => ReactNode | undefined | Promise; requiresValue?: boolean; // default true requiresType?: string; preservedProps?: string[]; onInit?: (opts) => EventSubscription | undefined; } ``` ### `InputRuleOptions` (the argument to `validate`) ```ts { value: any; name: string; formControl: FormControl; form: FormInterface | null; checked: boolean; errorKeys: { [key: string]: ReactNode }; [key: string]: any; // also includes everything from the props (label, placeholder, etc.) } ``` --- ## 13. Common anti-patterns to avoid - Listing `requiredRule` after value-dependent rules — they skip empty values and won't run. - Returning `false` from a `validate` function — must return a `ReactNode` or `undefined`. `false` is treated as truthy. - Spreading raw `props` (not `otherProps`) onto the host element — leaks hook-internal props. - Forgetting `form.submitting(false)` in the API-failure path **when `onSubmit` is synchronous** — button stays disabled. (If `onSubmit` returns a Promise, the engine auto-clears submitting state on settle; see §18.) - Calling `form.submitting(false)` manually after an awaited (Promise-returning) `onSubmit` — redundant; the engine already clears it. Harmless but unnecessary. - Subscribing to events without unsubscribing — leaks on remount, duplicate handlers. - Mixing `Form` (Web) and `NativeForm` (Native) in the same code path — pick the one matching the platform. - Using ` ))} ); } ``` ### Return shape (`FieldArrayHelpers`) ```ts { fields: { key: string; index: number; name: string }[]; // map with key={key}, name prefix={name} append: (count = 1) => void; // add rows at the end prepend: (count = 1) => void; // add rows at the start remove: (index?) => void; // remove at index, or the last row when omitted insert: (index) => void; // insert one row at index move: (from, to) => void; // move a row swap: (a, b) => void; // swap two rows replace: (count) => void; // replace all rows with `count` fresh rows } ``` - **`field.key`** is a stable React key — always use it, never the index. **`field.name`** is the dot-notation prefix; compose child names as `` `${field.name}.city` ``. - The initial row count seeds from `form.getInitialValue(name)` (so a hydrated array renders its rows). On `form.reset()`, the list returns to its initial row count. --- ## 21. `useWatch` — reactive value reads > **Auto-trigger:** code imports/calls `useWatch`; user asks "how do I read another field's value reactively", "how do I build a dependent/conditional field", or "how do I watch the whole form's values". `useWatch` reactively reads form values from any descendant and re-renders the calling component whenever **any** control changes (the engine now emits a form-level `change` event on every control change) or the form resets. ```tsx import { useWatch } from "@mongez/react-form"; const all = useWatch(); // whole collected values object const email = useWatch("user.email"); // single control value const [first, last] = useWatch(["firstName", "lastName"]); // array of values ``` Overloads: `useWatch()` → `Record`; `useWatch(name)` → that value; `useWatch(names[])` → an array of values. Outside a form it returns `{}`, `undefined`, or an array of `undefined` respectively. Use it for dependent fields (show a "state" select only when `country === "US"`), live summaries, or character counters — without subscribing to events manually. --- ## 22. Architecture & extending `FormEngine` ### Component model (v4) ``` FormEngine (plain class — implements FormInterface, React-free, no rendering) ├── Form (Web FC — forwardRef; renders , DOM submit, exposes engine via ref) └── NativeForm (React Native FC — forwardRef; Fragment default, programmatic submit) BaseForm = FormEngine (deprecated alias, kept for one major — removed in v5) ``` `Form` / `NativeForm` are **function components** that lazily instantiate one `FormEngine` (held in a `useRef`) and wrap children in ``. The shared wiring lives in `useFormEngine(props, submitHandler)`: - lazy single-engine instantiation (stable identity for context + the event bus) - SSR-safe id from `useId()` - `engine.setOptions(...)` synced every render (no React state mutated) - reactive `values` → `engine.fill(...)` on identity change; reactive `defaultValue` → `engine.setDefaultValue(...)` - `engine.activate()` / `engine.destroy()` for the active-form registry on mount/unmount ### What `FormEngine` provides `FormEngine` implements the full `FormInterface`: **Control registry:** `register(formControl)`, `unregister(formControl)`, `control(value, getBy?)`, `controls(names?)`. Wires per-control `onChange` to maintain `dirtyControls` and emit the form-level `change` event (consumed by `useWatch`). **Validation pipeline (async-aware):** `validate(controls?)` — `await`s each control's `validate()` so async rules gate submission — `validateVisible()`, whole-form schema validation (`validateSchema`), plus the `validating` / `validation` / `validControl` / `invalidControl` / `validControls` / `invalidControls` events. A `validating` listener returning `false` aborts the run. **Value collection:** `values(names?)`, `value(name)`, `formData()`, `collectValues(names?)`, `shouldIgnoreEmptyValues()`. Dot-notation nesting, array indices, `multiple`, `ignoreEmptyValues`. **Hydration:** `fill()`, `setValues()`, `setDefaultValue()`, `getInitialValue()`, `hydrationValues`, `setErrors()` (§15). **State tracking:** `isDirty`, `dirtyControls[]`, `isSubmitting()`, `submitting(boolean)`, `isValid()`, `disable()`, `enable()`, `wasSubmitted`. **Event bus:** `on(event, cb)`, `trigger(...)`, `triggerAll(...)` over `@mongez/events`, typed by `FormEventType`. **Active-form registry:** `activate()` / `destroy()` (called by the host) register/unregister with `setActiveForm()` + `addToFormsList()`, so `getActiveForm()` / `getForm(id)` work. **Submit pipeline:** `handleSubmit(event?)` — sets `wasSubmitted`, awaits `validate()`, short-circuits on invalid/already-submitting, calls `onSubmit({ form, event, values, formData })` with lazy getters, and auto-clears submitting state for a Promise result (§18). `submit()` delegates to an injected `submitHandler` (the host knows how to dispatch web vs. native). ### Custom renderers — subclass `FormEngine`, not `BaseForm` To support another React renderer (terminal UIs, `react-three-fiber`, a custom host), **subclass `FormEngine`** and render a thin function component that lazy-inits it. Do **not** subclass `BaseForm` (it's just the deprecated alias). ```tsx import { FormEngine, FormContext, type FormProps, type FormInterface, } from "@mongez/react-form"; import { useRef, useEffect, useId } from "react"; class HeadlessEngine extends FormEngine {} export function HeadlessForm(props: FormProps) { const id = useId(); const ref = useRef(); if (!ref.current) ref.current = new HeadlessEngine({ ...props, id }); const engine = ref.current; engine.setOptions({ ...props, id }); engine.submitHandler = () => engine.handleSubmit(); useEffect(() => { engine.activate(); return () => engine.destroy(); }, [engine]); return {props.children}; } ``` In practice most cases need neither a subclass nor a custom component — use the **`component` prop** to swap the rendered element: ```tsx ... ... ``` Subclass `FormEngine` only when submit semantics, value handling, or platform integration genuinely differ from both Web and Native. ### `BaseForm` migration `BaseForm` is now `export const BaseForm = FormEngine` (plus a `type BaseForm = FormEngine`). Code that referenced the *type* of a form instance still works (it's assignable to `FormInterface`). Code that **subclassed** `BaseForm` as a `React.Component` (overriding `render()` / `submit()` as instance methods) must migrate to the function-component-over-engine pattern above — there is no `render()` on the engine. The alias is removed in v5. ### DOM-guarded code in `useFormControl` The same `useFormControl` hook runs on Web and Native unchanged. DOM-specific code paths are guarded with `typeof document !== "undefined" && typeof window !== "undefined"`: - `formControl.isVisible()` walks the `parentElement` chain on Web; on Native it always returns `true`. Consequence: `form.validateVisible()` on Native is identical to `form.validate()`. - The auto-touch DOM `focus` event listener is a no-op on Native. Native input components must set `formControl.isTouched = true` manually in their own `onFocus` handler (or wire `onBlur` from the hook). - `inputRef.current.focus()` / `.blur()` work on both — React Native's `TextInput` ref exposes both methods natively. ### Where the code lives - `src/engine/FormEngine.ts` — the React-free engine (all logic; implements `FormInterface`). - `src/components/useFormEngine.ts` — the shared `Form` / `NativeForm` wiring hook. - `src/components/Form.tsx` — Web FC (`forwardRef`). - `src/components/NativeForm.tsx` — Native FC (`forwardRef`). - `src/components/BaseForm.ts` — the deprecated `FormEngine` alias. - `src/hooks/useFormControl.ts` — the hook, with DOM guards inline. - `src/standard-schema/` — `types.ts` (vendored spec) + `adapter.ts` (interop helpers). All other files (rules, hooks, contexts, configurations, active-form registry, types) are 100% platform-agnostic.