react-f0rm 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -1062
- package/devtools.d.ts +1 -0
- package/devtools.js +1 -0
- package/dist/array-3hAjl6Bz.d.ts +160 -0
- package/dist/devtools/index.cjs.js +1 -1
- package/dist/devtools/index.cjs.js.map +1 -1
- package/dist/devtools/index.d.cts +34 -0
- package/dist/devtools/index.d.mts +34 -0
- package/dist/devtools/index.d.ts +3 -2
- package/dist/devtools/index.mjs +1 -1
- package/dist/devtools/index.mjs.map +1 -1
- package/dist/errors-8oPux7GR.cjs.js +2 -0
- package/dist/errors-8oPux7GR.cjs.js.map +1 -0
- package/dist/errors-ClH3eV1R.mjs +2 -0
- package/dist/errors-ClH3eV1R.mjs.map +1 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.cts +1833 -0
- package/dist/index.d.mts +1833 -0
- package/dist/index.d.ts +678 -128
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +1210 -480
- package/dist/index.umd.js.map +1 -1
- package/dist/index.umd.min.js +2 -2
- package/dist/index.umd.min.js.map +1 -1
- package/dist/persist.cjs.js +1 -1
- package/dist/persist.cjs.js.map +1 -1
- package/dist/persist.d.cts +49 -0
- package/dist/persist.d.mts +49 -0
- package/dist/persist.mjs +1 -1
- package/dist/persist.mjs.map +1 -1
- package/dist/resolvers/standard-schema.cjs.js +1 -1
- package/dist/resolvers/standard-schema.cjs.js.map +1 -1
- package/dist/resolvers/standard-schema.d.cts +2 -0
- package/dist/resolvers/standard-schema.d.mts +2 -0
- package/dist/resolvers/standard-schema.d.ts +1 -67
- package/dist/resolvers/standard-schema.mjs +1 -1
- package/dist/resolvers/standard-schema.mjs.map +1 -1
- package/dist/resolvers/yup.cjs.js +1 -1
- package/dist/resolvers/yup.cjs.js.map +1 -1
- package/dist/resolvers/yup.d.cts +6 -0
- package/dist/resolvers/yup.d.mts +6 -0
- package/dist/resolvers/yup.d.ts +0 -1
- package/dist/resolvers/yup.mjs +1 -1
- package/dist/resolvers/yup.mjs.map +1 -1
- package/dist/resolvers/zod.cjs.js +1 -1
- package/dist/resolvers/zod.cjs.js.map +1 -1
- package/dist/resolvers/zod.d.cts +6 -0
- package/dist/resolvers/zod.d.mts +6 -0
- package/dist/resolvers/zod.d.ts +0 -1
- package/dist/resolvers/zod.mjs +1 -1
- package/dist/resolvers/zod.mjs.map +1 -1
- package/dist/server/index.cjs.js +1 -1
- package/dist/server/index.cjs.js.map +1 -1
- package/dist/server/index.d.cts +106 -0
- package/dist/server/index.d.mts +106 -0
- package/dist/server/index.d.ts +31 -2
- package/dist/server/index.mjs +1 -1
- package/dist/server/index.mjs.map +1 -1
- package/dist/standard-schema-CYbnugL7.d.ts +1184 -0
- package/dist/standardSchema-BpZvg0tZ.mjs +2 -0
- package/dist/standardSchema-BpZvg0tZ.mjs.map +1 -0
- package/dist/standardSchema-CxV_DhBO.cjs.js +2 -0
- package/dist/standardSchema-CxV_DhBO.cjs.js.map +1 -0
- package/dist/validate-BEjNsNvd.mjs +2 -0
- package/dist/validate-BEjNsNvd.mjs.map +1 -0
- package/dist/validate-BvN6hiEG.cjs.js +2 -0
- package/dist/validate-BvN6hiEG.cjs.js.map +1 -0
- package/dist/values-Bod74Kzu.mjs +2 -0
- package/dist/values-Bod74Kzu.mjs.map +1 -0
- package/dist/values-DKpOCD5J.cjs.js +2 -0
- package/dist/values-DKpOCD5J.cjs.js.map +1 -0
- package/package.json +90 -34
- package/persist.d.ts +1 -0
- package/persist.js +1 -0
- package/resolvers/standard-schema.d.ts +1 -0
- package/resolvers/standard-schema.js +1 -0
- package/resolvers/yup.d.ts +1 -0
- package/resolvers/yup.js +1 -0
- package/resolvers/zod.d.ts +1 -0
- package/resolvers/zod.js +1 -0
- package/server.d.ts +1 -0
- package/server.js +1 -0
- package/dist/errors-CxSjrWJO.cjs.js +0 -2
- package/dist/errors-CxSjrWJO.cjs.js.map +0 -1
- package/dist/errors-CzWtwjO0.mjs +0 -2
- package/dist/errors-CzWtwjO0.mjs.map +0 -1
- package/dist/form-CvmWHUrd.d.ts +0 -423
- package/dist/validate-B1Gdjeaq.mjs +0 -2
- package/dist/validate-B1Gdjeaq.mjs.map +0 -1
- package/dist/validate-CUmNZqg6.d.ts +0 -238
- package/dist/validate-DAfz8Nbb.cjs.js +0 -2
- package/dist/validate-DAfz8Nbb.cjs.js.map +0 -1
- package/dist/values-B1IV-6V4.mjs +0 -2
- package/dist/values-B1IV-6V4.mjs.map +0 -1
- package/dist/values-CDNAYEOB.cjs.js +0 -2
- package/dist/values-CDNAYEOB.cjs.js.map +0 -1
package/README.md
CHANGED
|
@@ -2,95 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/wmzy/react-f0rm/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/react-f0rm)
|
|
5
|
-
[](https://bundlephobia.com/package/react-f0rm)
|
|
5
|
+
[](https://bundlephobia.com/package/react-f0rm)
|
|
6
6
|
[](https://opensource.org/licenses/ISC)
|
|
7
7
|
|
|
8
8
|
A headless, event-driven React form library with field-level subscriptions.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
**The pitch in one sentence:** a lighter, faster TanStack Form — same field-level-subscription model and headless API, ~25% smaller core, 2.4× faster controlled-field changes — plus react-hook-form's escape hatches (`register` at `{...register('x')}` parity, `uncontrolled` mode, declarative `rules`, nested `formState.errors`-style error reads) and one Standard Schema adapter for every validation library.
|
|
11
|
+
|
|
12
|
+
- [Docs site](https://wmzy.github.io/react-f0rm/) — guides, API reference, migration
|
|
13
|
+
- [Benchmarks](https://wmzy.github.io/react-f0rm/benchmarks) · [Comparison](https://wmzy.github.io/react-f0rm/comparison) · [Storybook gallery](https://wmzy.github.io/react-f0rm/storybook)
|
|
14
|
+
- Coming from TanStack Form? [Migrating from TanStack Form](./docs/from-tanstack-form.md) is the one-page concept map.
|
|
11
15
|
|
|
12
16
|
## Features
|
|
13
17
|
|
|
14
|
-
- **Field-level subscriptions.** Editing one field re-renders exactly that field's component
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
-
|
|
25
|
-
- **
|
|
26
|
-
-
|
|
18
|
+
- **Field-level subscriptions.** Editing one field re-renders exactly that field's component. State is read through React's native `useSyncExternalStore` — tearing-safe under concurrent rendering. `uncontrolled: true` drops the value subscription entirely (react-hook-form `register` model: typing re-renders nothing, bench parity at ~12µs/change).
|
|
19
|
+
- **One shared runtime dependency.** The event core is a facade over `@for-fun/event-emitter` (external in ESM/CJS, so apps sharing it dedupe the copy; inlined only in the UMD bundle). The tradeoff is recorded in [ADR-0001](./docs/decisions/0001-event-emitter-facade.md).
|
|
20
|
+
- **Compile-time-checked paths.** `FieldPath<T>` / `PathValue<T, P>`: typo'd field names fail at compile time on the generic APIs; `FieldErrors<T>` types the error record; an `OpaqueTypes` registry stops recursion into Date/Dayjs-style leaves. See the [TypeScript guide](https://wmzy.github.io/react-f0rm/guides/typescript).
|
|
21
|
+
- **One schema adapter for every library.** Standard Schema v1 covers zod v3.24+/v4, valibot v1, arktype and more — plus `createForm({validate: schema})` accepts a schema directly and infers `TValues` from its output. See [Validation](https://wmzy.github.io/react-f0rm/guides/validation).
|
|
22
|
+
- **Async validation with cancellation.** `validateDebounce` per field + an `AbortSignal` in every validator's meta — superseded rounds cancel their in-flight work; pending debounce counts as validating so submit waits; `asyncAlways` lands a gate's verdict and the validator's result per-source.
|
|
23
|
+
- **Multiple errors per field.** Every field holds an ordered `FieldError[]`; resolvers forward every schema issue.
|
|
24
|
+
- **Headless everywhere.** You own the markup — `useField`/`handleSubmit`/`subscribe`/`watch` work without the DOM (React Native included); `Field`/`Checkbox`/`Select`/`Form` are thin DOM adapters over the same hooks. See [Headless & React Native](https://wmzy.github.io/react-f0rm/guides/headless-react-native).
|
|
25
|
+
- **Selector primitive.** `useStore(form, selector, isEqual?)` is the TanStack `useStore` counterpart; `useValues(form)` watches the whole tree (RHF `watch()` with no arguments); `useWatch` with the `isEqual` bailout covers single events; `useFormState` is the built-in aggregate. See [Hooks Reference](https://wmzy.github.io/react-f0rm/guides/hooks-reference).
|
|
26
|
+
- **React 19 / Server Actions.** `<Form action>` dispatches validated, schema-coerced values as `FormData`; an action returning `{errors: {field: msg}}` hydrates the fields as server errors; `react-f0rm/server` re-validates payloads without React and parses incoming FormData back with `valuesFromFormData`. See the [Server Actions guide](https://wmzy.github.io/react-f0rm/guides/react19-server-actions).
|
|
27
|
+
- **Accessibility wired in.** `aria-invalid` + `aria-describedby` → `fieldErrorId(name)` on every bound field; `renderError` completes the `role="alert"` chain.
|
|
28
|
+
- **`form.register(name)`** — react-hook-form's `register` contract, no hook required: spread the returned props onto an uncontrolled element (`<input {...form.register('email')} />`), the element never re-renders, and the store still carries every write (`getValues`/submit/validation read it). Works in dynamic lists, conditional fields and non-React adapters; `rules`/`mode`/`valueAsNumber` ride the same pipeline `useField` uses. `useField({uncontrolled: true})` stays the hook-side counterpart.
|
|
29
|
+
- **Spreadable `inputProps`.** `<input {...field.inputProps} />` binds value, event extraction (`eventToValue`/`valueAsNumber`/checkbox/file auto-detection), blur, the focus channel, `disabled` and the `aria-invalid`/`aria-describedby` chain in one spread — while the headless `onChange(value)` keeps serving design-system controls that hand raw values.
|
|
30
|
+
- **Nested error tree.** `useErrorsTree(form)` / `getErrorsTree(form)` read errors as `errors.items?.[0]?.name` — the typed optional-chain shape RHF's `formState.errors` uses — alongside the flat dotted record (`useErrors`). One cache, both views, stable references.
|
|
31
|
+
- **Form-level validation cadence.** `createForm({validateMode: 'onChange' | 'onBlur'})` re-runs the form-level `validate` on every user change/blur — TanStack `validators.onChange/onBlur` parity without enumerating `validateDeps`; rounds own their errors, so a passing re-run clears what the last round wrote.
|
|
32
|
+
- **Disabled subtrees.** A field declared `disabled: true` disables its descendants (RHF subtree semantics); a descendant opts back out with `disabled: false`. The form-level flag still disables everything.
|
|
33
|
+
- **Tombstone unregister, async initial values, declarative `rules`** (store-side errors + native constraint attributes), `validateOnMount`, `validateDeps` cross-field re-runs, `useTransform` async transforms, `createFormContext` typed isolated contexts, `reset`/`resetField` with RHF-parity keep-flags, `<Devtools />` from `react-f0rm/devtools`, `react-f0rm/persist` — see the [docs site](https://wmzy.github.io/react-f0rm/) for the full surface.
|
|
34
|
+
- **14.33 KB gzip core** (13.0 KB brotli, emitter-external measurement); devtools/server/persist/resolvers ship as separate tree-shakeable entries.
|
|
27
35
|
|
|
28
36
|
## Install
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
npm i react-f0rm
|
|
32
|
-
```
|
|
33
|
-
or
|
|
34
|
-
```
|
|
35
|
-
yarn add react-f0rm
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## Benchmarks
|
|
39
|
-
|
|
40
|
-
tinybench, run on a desktop-class machine (AMD Ryzen 7 8745HS). Measured rme varies by run — 17–249% depending on scenario and load — so treat the µs means as one significant figure.
|
|
41
|
-
|
|
42
|
-
| Scenario | react-f0rm | Baseline | Speedup |
|
|
43
|
-
|---|---|---|---|
|
|
44
|
-
| Change one of 100 controlled fields | 111µs/change (~9,300 ops/s) | RHF `Controller`: 131µs (~7,800 ops/s) | ~1.2× |
|
|
45
|
-
| Components re-rendered per change | 1 of 100 `Field`s | — | — |
|
|
46
|
-
| Change one of 100 uncontrolled fields | 12.6µs/change (~80,500 ops/s) | RHF `register`: 11.9µs (~85,200 ops/s) | parity (~6% apart) |
|
|
47
|
-
| `getValues()`, 100 fields × depth 3 (cold compute; DEV snapshot guard on both paths) | 55.5µs (ownership merge) | legacy chained `set`: 104µs | 1.9× |
|
|
48
|
-
| Change one of 1000 controlled fields | 0.611ms/change (~1,660 ops/s) | RHF `Controller`: 1.07ms (~944 ops/s) | ~1.8× |
|
|
49
|
-
| Async validation storm — burst of 3 changes × 50 debounced async validators, settled via `trigger` | 27.9ms/burst (~36 ops/s) | — | — |
|
|
50
|
-
| `await trigger(form)` — 100 mixed validators (50 sync + 50 async) settle | 1.23ms (~846 ops/s) | — | — |
|
|
51
|
-
|
|
52
|
-
Notes:
|
|
53
|
-
|
|
54
|
-
- The uncontrolled row is the apples-to-apples `register` comparison: react-f0rm's `uncontrolled: true` (no value subscription) runs at RHF-`register` parity — 12.6µs vs 11.9µs per change — while keeping errors/touched/disabled/validating reactive, which raw `register` does not. The controlled comparison above uses `Controller`, RHF's per-field-subscribed controlled counterpart.
|
|
55
|
-
- Both `getValues()` paths pay the DEV snapshot guard (`freezeValues`: clone + freeze), so the comparison isolates the merge strategy; in production neither side pays it. Ownership merging also cut container allocations from 300 to 111.
|
|
56
|
-
|
|
57
|
-
Reproduce with:
|
|
38
|
+
Requires React 18 or newer (native `useSyncExternalStore`, no shim).
|
|
58
39
|
|
|
59
40
|
```sh
|
|
60
|
-
|
|
61
|
-
npx vitest bench --run test/bench/scale.bench.ts # the three scale scenarios above
|
|
41
|
+
npm i react-f0rm
|
|
62
42
|
```
|
|
63
43
|
|
|
64
|
-
##
|
|
65
|
-
|
|
66
|
-
react-f0rm vs the established options. react-f0rm figures come from this repo (size-limit, tinybench — see [Benchmarks](#benchmarks)); competitor sizes are Bundlephobia gzip observations and drift between versions, so treat them as ballpark rather than gospel.
|
|
67
|
-
|
|
68
|
-
| | react-f0rm | React Hook Form | TanStack Form | Formik |
|
|
69
|
-
|---|---|---|---|---|
|
|
70
|
-
| Rendering model | Controlled fields with field-level subscriptions (`useSyncExternalStore`): editing one of 100 fields re-renders exactly 1 component; `uncontrolled: true` drops the value subscription and runs at RHF-`register` parity (12.6µs vs 11.9µs bench) while errors/touched/disabled stay reactive | Uncontrolled `register` by default (no React re-render while typing); `Controller` opts into per-field re-renders | Field-level subscriptions (`form.Field` / `useField`), each field re-renders itself | Form-wide context: any state change re-renders all subscribed components |
|
|
71
|
-
| Unregister on unmount | Unregisters by default — an unmounted field drops out of `getValues()` (tombstone) instead of silently reviving its initial value; `shouldUnregister: false` per field or per form (`createForm({shouldUnregister: false})`) keeps it | Value kept by default (`shouldUnregister` defaults to `false`); opt in per field or form to unregister on unmount | Values live in the form store; unmounting a field's UI keeps its value and state | No unregister concept — values persist until `reset` |
|
|
72
|
-
| Schema adapters | One Standard Schema entry point (`react-f0rm/resolvers/standard-schema`) covers zod, valibot, arktype, …; legacy zod/yup resolvers also shipped | `@hookform/resolvers` — one adapter module per validation library | Built-in `standardSchemaValidators` (Standard Schema v1), plus per-library adapter packages | Yup built in via `validationSchema`; other libraries hand-wired in `validate` |
|
|
73
|
-
| Path type safety | `FieldPath<T>` / `PathValue<T, P>`: every valid path enumerated, value type resolved, typos fail at compile time on the generic APIs (`useField`, `setValue`, `getValue`, …); segment arrays and runtime-dynamic entry points (`useFieldArray`, `removeField`, …) stay untyped | `Path<T>` / `FieldPath` type-level path checking | Deep inference, including validator argument types — the strongest of the four | Top-level `keyof` only; nested paths are untyped strings |
|
|
74
|
-
| Async initial values | `initialValues: T \| Promise<T> \| () => T \| Promise<T>`: async sources start the form empty with `isLoading: true` (`useIsLoading` / `useFormState().isLoading`) and land the resolved values as the baseline | Async `defaultValues` supported (`formState.isLoading`) | `defaultValues: () => Promise<T>` supported | Not built in — resolve before rendering, or re-render after fetch |
|
|
75
|
-
| Async validation | `validateDebounce` per field + `meta.signal` (`AbortSignal`) handed to every validator — superseded rounds cancel their in-flight work; pending debounce counts as validating so submit waits | Async validators supported, but no built-in debounce and no cancellation signal — both are hand-rolled per project | Built in: `asyncDebounceMs` debounces and the validator meta carries an `AbortSignal` | Async `validate` supported; no debounce, no signal |
|
|
76
|
-
| Multiple errors per field | Native: every field holds `FieldError[]`; `getFieldErrors`/`useFieldErrors` read them; resolvers forward every schema issue | `criteriaMode: 'all'` collects all failing rules per field | Errors are arrays of messages per field | — |
|
|
77
|
-
| SSR / hydration | `renderToString` renders initial values out of the box; server snapshot matches the client's first render (async initialValues render empty + `isLoading` on both sides) | SSR-safe | SSR-safe | SSR-safe |
|
|
78
|
-
| React 19 / Server Actions | Bridge pattern: dispatch the action from `onValidSubmit` via `startTransition`/`useActionState`, passing the values object rather than FormData (see the stance above and the React 19 Server Actions guide); the `react-f0rm/server` entry's `validateValues` re-validates payloads server-side without wrapping them in an action; no submit before JS loads — first-class `action` prop support is not on the 0.x roadmap | `<Form>` accepts a function `action` prop (server-action-style submit) since v7.84, and ships a `react-server` export | Documented server action integration (`createServerValidate` for server-side validation, Next.js examples) | — |
|
|
79
|
-
| Bundle size | 11.83 KB gzip minified (10.73 KB brotli), full core | ~11 KB gzip | ~17.5 KB gzip | ~12.8 KB gzip |
|
|
80
|
-
| Devtools | `<Devtools />` from `react-f0rm/devtools` — separate entry point, tree-shakeable, never lands in the main bundle | `@hookform/devtools` (separate package) | Built-in devtools panel | None (official) |
|
|
81
|
-
| Ecosystem maturity | New, 0.x — small audience, few integrations so far | Most mature: massive adoption, resolvers, UI-kit integrations, abundant examples and answers | Backed by the TanStack family, actively growing | Maintenance mode; the author recommends considering RHF or Final Form for new projects |
|
|
82
|
-
|
|
83
|
-
Bundle-size basis: every column is gzip. react-f0rm is measured on the local build — gzip of the shipped, unminified `dist/index.mjs` after `npm run build` is 11.58 KB (minified, the same file gzips to 11.83 KB; 10.73 KB brotli via size-limit, which minifies and tree-shakes). Competitor figures are Bundlephobia observations of minified+gzip bundles — so ours is the conservative number, not the flattering one.
|
|
84
|
-
|
|
85
|
-
### Which one should you use?
|
|
86
|
-
|
|
87
|
-
**Pick react-f0rm** when you want controlled components with true per-field subscriptions (design systems, editor-like forms), one Standard Schema adapter instead of a package per validator, compile-time-checked paths, and a small core (11.83 KB minified gzip / 10.73 KB brotli) — and you are comfortable with a young library.
|
|
88
|
-
|
|
89
|
-
**Pick React Hook Form** when you want the mature ecosystem — resolvers, UI-library integrations and community answers — today. Its performance edge is gone at the rendering level: raw `register` benches at 11.9µs/change and react-f0rm's `uncontrolled: true` at 12.6µs (parity, see [Benchmarks](#benchmarks)), while our controlled model beats `Controller` 1.2–1.8×. TanStack Form sits in between: choose it when the deepest possible type inference (including validator signatures) matters more to you than bundle size.
|
|
90
|
-
|
|
91
|
-
**Server Actions: bridge, not first-class.** RHF-style `action` prop support, a `react-server` entry point, or a TanStack-style `createServerValidate` helper are **not on the 0.x roadmap** — a deliberate stance, not a gap. react-f0rm's source of truth is the values store, not the DOM: an `action`-prop submit would ship FormData keyed by JSON-stringified path keys, drop every store-only value, and skip the validation gate entirely (the [React 19 Server Actions guide](docs-site/docs/guides/react19-server-actions.md) unpacks all four failure modes). The recommended shape is the bridge — dispatch from `onValidSubmit` via `startTransition`/`useActionState`, passing the values object rather than FormData — which keeps validation gating the action and types/nesting intact. If submitting without JavaScript loaded is a hard requirement, RHF's `action` prop support is the better fit today.
|
|
92
|
-
|
|
93
|
-
## Usage
|
|
44
|
+
## Quick Start
|
|
94
45
|
|
|
95
46
|
```jsx
|
|
96
47
|
import React from 'react';
|
|
@@ -99,1052 +50,129 @@ import {Form, Field} from 'react-f0rm';
|
|
|
99
50
|
export default function Register() {
|
|
100
51
|
return (
|
|
101
52
|
<Form
|
|
102
|
-
initialValues={{name: '
|
|
53
|
+
initialValues={{name: '', email: ''}}
|
|
103
54
|
onValidSubmit={values => console.log(values)}
|
|
104
55
|
>
|
|
105
|
-
<Field name="name" />
|
|
106
|
-
<Field name="email" />
|
|
56
|
+
<Field name="name" rules={{required: true}} renderError={e => <em>{e}</em>} />
|
|
57
|
+
<Field name="email" type="email" />
|
|
107
58
|
<button>SUBMIT</button>
|
|
108
59
|
</Form>
|
|
109
60
|
);
|
|
110
61
|
}
|
|
111
62
|
```
|
|
112
63
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
### `useField`
|
|
116
|
-
|
|
117
|
-
For full control over field rendering:
|
|
64
|
+
Headless — the same form without `<Field>`'s DOM:
|
|
118
65
|
|
|
119
66
|
```jsx
|
|
120
67
|
import {useField} from 'react-f0rm';
|
|
121
68
|
|
|
122
69
|
function CustomField({name}) {
|
|
123
|
-
const {value, onChange, onBlur, error,
|
|
70
|
+
const {value, onChange, onBlur, error, focusRef} = useField({name});
|
|
124
71
|
return (
|
|
125
72
|
<div>
|
|
126
|
-
<input value={value} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
|
|
73
|
+
<input ref={focusRef} value={value ?? ''} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
|
|
127
74
|
{error && <span role="alert">{error}</span>}
|
|
128
75
|
</div>
|
|
129
76
|
);
|
|
130
77
|
}
|
|
131
78
|
```
|
|
132
79
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
```jsx
|
|
136
|
-
const form = useForm({initialValues: {email: ''}});
|
|
137
|
-
const {value, onChange} = useField({form, name: 'email'});
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
The result also carries `focusRef` — a stable callback ref for the input element. `setFocus` and a failed submit's first-error auto-focus ([Focusing the first error](#focusing-the-first-error)) ride the `'focusError'` event, which reaches your element through this ref; leave it off and focus requests aimed at the field are silent no-ops. It only matters for headless callers building their own input — `<Field>` wires it internally, so its users never see it:
|
|
141
|
-
|
|
142
|
-
```jsx
|
|
143
|
-
const {value, onChange, focusRef} = useField({name: 'email'});
|
|
144
|
-
<input ref={focusRef} value={value} onChange={e => onChange(e.target.value)} />
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
`uncontrolled: true` pins the value at mount and skips the value subscription — typing re-renders nothing (the store still carries every write; errors/touched/disabled/validating still re-render the field), the react-hook-form `register` model at `register` parity (12.6µs vs 11.9µs bench). Bind the element with `defaultValue` instead of `value`, exactly like `<Field uncontrolled />`.
|
|
148
|
-
|
|
149
|
-
On unmount the field unregisters by default: its live value drops out of reads and `getValues()` (tombstone — no silent revival from `initialValues`). `shouldUnregister: false` keeps the value per field; `createForm({shouldUnregister: false})` or `<Form shouldUnregister={false}>` flips the form-wide default to react-hook-form's keep-the-value semantics, and a field-level option overrides the form-level flag in either direction.
|
|
150
|
-
|
|
151
|
-
### `subscribe`
|
|
152
|
-
|
|
153
|
-
Linked fields and other non-render side effects — province changed → clear city, autosave, analytics — should not require a mounted watching component. `subscribe` exposes the event core imperatively:
|
|
154
|
-
|
|
155
|
-
```jsx
|
|
156
|
-
import {createForm, subscribe, getValue, setValue} from 'react-f0rm';
|
|
157
|
-
|
|
158
|
-
const form = createForm({initialValues: {province: '', city: ''}});
|
|
159
|
-
|
|
160
|
-
const unsubscribe = subscribe(form, {
|
|
161
|
-
name: 'province',
|
|
162
|
-
callback: () => {
|
|
163
|
-
// Read fresh state through the getters inside the callback.
|
|
164
|
-
if (getValue(form, 'city')) setValue(form, 'city', '');
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
| Option | Type | Default |
|
|
170
|
-
|---|---|---|
|
|
171
|
-
| `name` | field path, or an array of them | omitted — every emission of `event`, payload-less broadcasts (reset, …) included |
|
|
172
|
-
| `event` | `'change'` \| `'errors'` \| `'touched'` \| `'validating'` \| `'submitting'` \| `'submitCount'` \| `'submitSuccessful'` \| `'disabled'` | `'change'` |
|
|
173
|
-
| `scope` | `'leaf'` \| `'branch'` | `'branch'` |
|
|
174
|
-
| `callback` | `() => void`, fired with no arguments | required |
|
|
175
|
-
|
|
176
|
-
Matching follows the event's shape. `'change'` walks the path tree: the default `'branch'` scope wakes a `'tags'` subscriber when any `tags.*` descendant is written, while `'leaf'` matches only the exact key and its ancestors. `'validating'` carries a path per async validator round and narrows by path exactly like `'change'` — the imperative counterpart of a per-field validating indicator. `'errors'` and `'touched'` always match the exact key — another field's error never wakes this subscriber. `'submitting'`/`'submitCount'`/`'submitSuccessful'`/`'disabled'` are payload-less, so a `name` narrows nothing. An array of names creates one subscription per path, and the returned function unsubscribes them all. A number-bearing array (`['tags', 0]`) is one segments path, not a name list — the same rule `trigger` uses.
|
|
177
|
-
|
|
178
|
-
**`subscribe` vs `useWatch`:** `useWatch` (and the `useValue`/`useError`/… readers built on it) feeds rendering — it returns a snapshot and re-renders the component when it changes. `subscribe` runs imperative code and renders nothing. Use `subscribe` for linkages and effects; reach for a hook only when the watched value itself must appear on screen. Its first argument is the form (`useWatch(form, 'change', getter)`), matching every other hook's context shape; the raw emitter remains accepted for back-compat. `useWatch` itself takes an optional fourth argument — `isEqual(prev, next)` — aimed at wide-scope getters that return a fresh reference per call (a whole-values selector, say): on each event the getter recomputes, and an equal verdict keeps the cached snapshot without notifying React at all — no render, not even a bailed-out one (the same contract TanStack's `useSelector` `compare` option has).
|
|
179
|
-
|
|
180
|
-
### `useFieldArray`
|
|
181
|
-
|
|
182
|
-
Manage dynamic lists of fields:
|
|
80
|
+
Or one spread — `inputProps` is the DOM-boundary adapter over the same handlers (event extraction, focus channel, a11y chain included):
|
|
183
81
|
|
|
184
82
|
```jsx
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
function Tags() {
|
|
188
|
-
const {fields, append, remove} = useFieldArray({name: 'tags'});
|
|
83
|
+
function CustomField({name}) {
|
|
84
|
+
const {inputProps, error} = useField({name});
|
|
189
85
|
return (
|
|
190
86
|
<div>
|
|
191
|
-
{
|
|
192
|
-
|
|
193
|
-
<Field name={['tags', index]} />
|
|
194
|
-
<button type="button" onClick={() => remove(index)}>Remove</button>
|
|
195
|
-
</div>
|
|
196
|
-
))}
|
|
197
|
-
<button type="button" onClick={() => append('')}>Add Tag</button>
|
|
87
|
+
<input {...inputProps} />
|
|
88
|
+
{error && <span role="alert" id={fieldErrorId(name)}>{error}</span>}
|
|
198
89
|
</div>
|
|
199
90
|
);
|
|
200
91
|
}
|
|
201
92
|
```
|
|
202
93
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
**Path syntax:** array access is bracket-only — `items[0]`, `items[0].name`. Dotted numeric segments (`items.0`) throw a `TypeError` whose message shows the bracket spelling to use; a quoted segment (`items["0"]`) explicitly names a string key instead of an index. Segment arrays (`['items', 0]`) remain the fully dynamic form.
|
|
206
|
-
|
|
207
|
-
Besides the movers (`append`, `prepend`, `insert`, `remove`, `swap`, `move`), two bulk operations are available:
|
|
208
|
-
|
|
209
|
-
```jsx
|
|
210
|
-
const {fields, replace, update} = useFieldArray({name: 'tags'});
|
|
211
|
-
|
|
212
|
-
replace(['a', 'b', 'c']); // full swap: every row id is regenerated (length may change)
|
|
213
|
-
update(1, 'B'); // overwrite one value, keeping that row's id — no key churn
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
`replace(values)` is the refetch shape — a server response replaces the whole list — while `update(index, value)` rewrites a single row in place.
|
|
217
|
-
|
|
218
|
-
### `useFieldArrayItem`
|
|
219
|
-
|
|
220
|
-
Per-row subscription for large arrays — the counterpart of TanStack Form's field api that `useFieldArray` alone cannot offer. `useFieldArray` subscribes to the whole branch, so any row's edit re-renders the component holding the array (and, without memoization, every row). `useFieldArrayItem` gives one row — identified by the stable `id` from `fields[i].id` — a subscription of its own:
|
|
94
|
+
Or no hook at all — `form.register` is RHF's `register` contract (the element never re-renders; live state through `useValue`/`useError`):
|
|
221
95
|
|
|
222
96
|
```jsx
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
<div>
|
|
229
|
-
<input
|
|
230
|
-
value={item.value ?? ''}
|
|
231
|
-
onChange={e => item.setValue(e.target.value)}
|
|
232
|
-
/>
|
|
233
|
-
{item.error && <span>{item.error}</span>}
|
|
234
|
-
</div>
|
|
235
|
-
);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
function Tags() {
|
|
239
|
-
const {fields, append, remove} = useFieldArray({name: 'tags'});
|
|
240
|
-
return (
|
|
241
|
-
<div>
|
|
242
|
-
{fields.map(field => (
|
|
243
|
-
<div key={field.id}>
|
|
244
|
-
<Row id={field.id} />
|
|
245
|
-
<button type="button" onClick={() => remove(field.index)}>
|
|
246
|
-
Remove
|
|
247
|
-
</button>
|
|
248
|
-
</div>
|
|
249
|
-
))}
|
|
250
|
-
<button type="button" onClick={() => append('')}>
|
|
251
|
-
Add Tag
|
|
252
|
-
</button>
|
|
97
|
+
function DynamicFields({form}) {
|
|
98
|
+
return form.fields.map(name => (
|
|
99
|
+
<div key={name}>
|
|
100
|
+
<input {...form.register(name)} />
|
|
101
|
+
<FieldError name={name} />
|
|
253
102
|
</div>
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
Editing row K re-renders only row K, and a whole-array rewrite (`update`, `append`) re-renders only rows whose value actually changed — untouched rows' renders stay at zero. Two requirements make that hold:
|
|
259
|
-
|
|
260
|
-
- a `useFieldArray({name})` must be mounted at the same path — it publishes the id table rows resolve against;
|
|
261
|
-
- the row component must be `React.memo` with stable props (`id`, optionally `form`): everything else comes from the hook, so the array component's own re-render cannot drag the rows along.
|
|
262
|
-
|
|
263
|
-
Rows whose index migrates — `remove`/`move`/`swap`/`insert` reshuffles — re-render by design: the row's path contains the index, exactly like TanStack Form's per-field api. `replace` regenerates every id, so every row remounts. The win is single-row edits staying single-row.
|
|
264
|
-
|
|
265
|
-
The hook returns `{value, setValue, errors, error, name, index, form}` — the `useField`-style shape plus `index` and `name` (the row's current path key, e.g. `["tags",0]`) for building nested fields. Value reads and writes live on the array layer — the same layer every `useFieldArray` operation touches — so `value`, `setValue` and `update`/`append`/… always agree with each other; editing through a leaf-path `useField({name: ['tags', i]})` writes a different layer and does not flow into `item.value`.
|
|
266
|
-
|
|
267
|
-
Without a paired `useFieldArray` the row is inert rather than broken: `index` is `-1`, `value` is `undefined`, and `setValue` is a no-op.
|
|
268
|
-
|
|
269
|
-
### `createFormContext`
|
|
270
|
-
|
|
271
|
-
The module-level context serves one form per subtree; nesting two forms (or reusing a component under a different form) makes them fight over it. `createFormContext` builds an isolated bundle of bindings, typed against your values shape:
|
|
272
|
-
|
|
273
|
-
```tsx
|
|
274
|
-
import {createFormContext} from 'react-f0rm';
|
|
275
|
-
|
|
276
|
-
interface Values {
|
|
277
|
-
name: string;
|
|
278
|
-
email: string;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const ProfileForm = createFormContext<Values>();
|
|
282
|
-
|
|
283
|
-
function NameField() {
|
|
284
|
-
// name is constrained to FieldPath<Values>; value is inferred as string
|
|
285
|
-
const {value, onChange} = ProfileForm.useField({name: 'name'});
|
|
286
|
-
return <input value={value} onChange={e => onChange(e.target.value)} />;
|
|
287
|
-
}
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
Each call returns `{context, FormProvider, useFormContext, useField, useFieldArray, useFieldArrayItem}` bound to a private React context — pass the form via `<ProfileForm.FormProvider form={form}>`, and providers from separate instances never see each other's forms.
|
|
291
|
-
|
|
292
|
-
The bundle also carries its raw React context, so `<Form>` can provide into it while keeping its full submit machinery — validation, submit handling, focus-on-error — instead of you wiring `<FormProvider>` + `handleSubmit` by hand:
|
|
293
|
-
|
|
294
|
-
```tsx
|
|
295
|
-
const ProfileForm = createFormContext<Values>();
|
|
296
|
-
|
|
297
|
-
<Form context={ProfileForm.context} form={form} onValidSubmit={save}>
|
|
298
|
-
<NameField /> {/* ProfileForm.useField resolves the form <Form> manages */}
|
|
299
|
-
<button type="submit">Save</button>
|
|
300
|
-
</Form>
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
The module-level `useFormContext()`/`useField` do not see that form — that is the isolation working.
|
|
304
|
-
|
|
305
|
-
## Controlled Forms
|
|
306
|
-
|
|
307
|
-
Pass a `values` prop to `<Form>` (or `values` to `useForm`) to drive the form from outside:
|
|
308
|
-
|
|
309
|
-
```jsx
|
|
310
|
-
<Form
|
|
311
|
-
values={selectedRecord}
|
|
312
|
-
onValidSubmit={values => save(values)}
|
|
313
|
-
>
|
|
314
|
-
<Field name="email" />
|
|
315
|
-
</Form>
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
Whenever the `values` reference changes, the new object is synced into the form: uncommitted user edits are discarded — master-detail semantics, where selecting another record replaces the draft — while touched flags and errors are kept. The sync guard is reference-first with a structural fallback: re-renders that pass the same `values` reference never re-sync, and neither does an inline literal whose content is structurally equal to what the form was last seeded from — only genuinely different content replaces the draft, so an unrelated re-render never interrupts what the user is typing.
|
|
319
|
-
|
|
320
|
-
## Disabled
|
|
321
|
-
|
|
322
|
-
Disable a whole form — during submission, while a record loads, or for read-only views:
|
|
323
|
-
|
|
324
|
-
```jsx
|
|
325
|
-
const form = useForm({disabled: isReadOnly});
|
|
326
|
-
|
|
327
|
-
// or toggle at runtime — every bound field re-renders:
|
|
328
|
-
setDisabled(form, true);
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
The flag is OR-ed into every bound field: `Field`, `Checkbox` and `Select` render their control disabled when either the form flag or their own `disabled` prop is true — a field cannot opt out of a disabled form. `useField` exposes the merged flag as `disabled`, kept live through the form's event core:
|
|
332
|
-
|
|
333
|
-
```jsx
|
|
334
|
-
const {disabled, value, onChange} = useField({name: 'email'});
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
## Submit Handlers
|
|
338
|
-
|
|
339
|
-
```jsx
|
|
340
|
-
<Form
|
|
341
|
-
initialValues={{email: ''}}
|
|
342
|
-
onValidSubmit={(values, e) => {
|
|
343
|
-
// Called after successful validation
|
|
344
|
-
saveToServer(values);
|
|
345
|
-
}}
|
|
346
|
-
onInvalidSubmit={(errors, values) => {
|
|
347
|
-
// Called when validation fails
|
|
348
|
-
// errors: [{path: 'email', type: 'custom', message: 'Invalid email'}]
|
|
349
|
-
console.error(errors);
|
|
350
|
-
}}
|
|
351
|
-
>
|
|
352
|
-
<Field name="email" />
|
|
353
|
-
<button>Submit</button>
|
|
354
|
-
</Form>
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
`onSubmit`/`onValidSubmit` only run once both native constraint validation (see [Accessibility](#accessibility)) and your custom validators pass.
|
|
358
|
-
|
|
359
|
-
### `handleSubmit`
|
|
360
|
-
|
|
361
|
-
The same submit flow is available as a standalone function — the headless counterpart of `<Form>`'s submit wiring, usable where there is no `<form>` element (React Native, toolbar buttons, …):
|
|
362
|
-
|
|
363
|
-
```jsx
|
|
364
|
-
import {useForm, handleSubmit} from 'react-f0rm';
|
|
365
|
-
|
|
366
|
-
function Profile({onSave}) {
|
|
367
|
-
const form = useForm({initialValues: {email: ''}});
|
|
368
|
-
const submit = handleSubmit(form, {
|
|
369
|
-
onSubmit: values => onSave(values), // runs first on success
|
|
370
|
-
onValidSubmit: values => console.log(values), // then this
|
|
371
|
-
onInvalidSubmit: (errors, values) => console.error(errors)
|
|
372
|
-
});
|
|
373
|
-
return <Button title="Save" onPress={submit} />;
|
|
374
|
-
}
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
All callbacks are optional — a missing one is simply skipped. The returned handler runs the full submit state machine (`isSubmitting`, `submitCount`, `isSubmitSuccessful`) around native constraint validation (skipped when the event target has no `checkValidity`) and your validators, and can be invoked with or without an event object.
|
|
378
|
-
|
|
379
|
-
`onInvalidSubmit` receives an array of `{path, type, message}` entries: custom validation failures carry dotted paths with your validator's type (`'custom'` for plain strings, `'standard'` for the Standard Schema adapter), and native constraint failures carry `type: 'native'` — `path` is the dotted field path and `message` comes from the browser's `validationMessage`. Native failures are read from the DOM and never enter the form's error state.
|
|
380
|
-
|
|
381
|
-
### Submit button state
|
|
382
|
-
|
|
383
|
-
`useCanSubmit(form)` is the single flag a submit button's `disabled` prop wants — `!isSubmitting && !hasErrors`:
|
|
384
|
-
|
|
385
|
-
```jsx
|
|
386
|
-
import {useForm, useCanSubmit, handleSubmit} from 'react-f0rm';
|
|
387
|
-
|
|
388
|
-
function Profile({onSave}) {
|
|
389
|
-
const form = useForm({initialValues: {email: ''}});
|
|
390
|
-
const canSubmit = useCanSubmit(form);
|
|
391
|
-
return (
|
|
392
|
-
<button disabled={!canSubmit} onClick={handleSubmit(form, {onSubmit: onSave})}>
|
|
393
|
-
Save
|
|
394
|
-
</button>
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
It is `false` for the whole async `onSubmit` span (not just the validation pass) and whenever any field holds an error — client validation or server backfill (`setServerErrors` lands there too). Deliberately no dirty or validating semantics: an untouched-but-clean form can submit. The snapshot recomputes on either input's event and re-renders only when the boolean itself flips. The underlying readers stay exported — `useIsSubmitting`, `useHasErrors`, `useSubmitCount` — for UIs that need the parts separately.
|
|
400
|
-
|
|
401
|
-
For the validation span itself reach for `useIsValidating(form)` — `true` while any field's async validator or pending debounce window is open and while the form-level validate round is in flight (the same marks `trigger` and submit wait out). The classic consumers are a spinner and a double-click guard on the same button:
|
|
402
|
-
|
|
403
|
-
```jsx
|
|
404
|
-
import {useIsValidating} from 'react-f0rm';
|
|
405
|
-
|
|
406
|
-
const isValidating = useIsValidating(form);
|
|
407
|
-
<button disabled={!canSubmit || isValidating} onClick={submit}>
|
|
408
|
-
{isValidating ? 'Checking…' : 'Save'}
|
|
409
|
-
</button>
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
`useCanSubmit` stays validating-free on purpose (see above), so combine the two flags when you want the stricter gate. The submission-outcome sibling `useIsSubmitSuccessful(form)` is exported alongside: `true` once `onSubmit`/`onValidSubmit` resolved without throwing, `false` when validation failed or a handler threw, `undefined` before the first submit — the usual success-banner/redirect trigger.
|
|
413
|
-
|
|
414
|
-
### Focusing the first error
|
|
415
|
-
|
|
416
|
-
After a failed submit, the offending field is focused automatically — pass `shouldFocusError: false` (on `<Form>` or `handleSubmit`) to disable; it defaults to `true`. Custom validation failures focus the first errored field through a `'focusError'` event that bound fields (like `Field`) subscribe to; native constraint failures focus the submitted form's first `:invalid` control directly.
|
|
417
|
-
|
|
418
|
-
The same channel is exposed as an imperative API:
|
|
419
|
-
|
|
420
|
-
```jsx
|
|
421
|
-
import {setFocus} from 'react-f0rm';
|
|
422
|
-
|
|
423
|
-
setFocus(form, 'email'); // focus the bound field's element
|
|
424
|
-
setFocus(form, 'user.name', {shouldSelect: true}); // focus and select its text
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
`setFocus` rides the `'focusError'` event, so it is a silent no-op when the field is unmounted or nothing subscribes — unknown names never throw.
|
|
428
|
-
|
|
429
|
-
## Validation
|
|
430
|
-
|
|
431
|
-
### Validation modes
|
|
432
|
-
|
|
433
|
-
`mode` controls when field validators run; `reValidateMode` controls when a field is re-validated once it already has an error — it supplements `mode` in every mode:
|
|
434
|
-
|
|
435
|
-
| Option | Values | Default |
|
|
436
|
-
|---|---|---|
|
|
437
|
-
| `mode` | `'onSubmit'` \| `'onBlur'` \| `'onChange'` \| `'onTouched'` \| `'all'` | `'onSubmit'` |
|
|
438
|
-
| `reValidateMode` | `'onChange'` \| `'onBlur'` \| `'onSubmit'` | `'onChange'` |
|
|
439
|
-
|
|
440
|
-
- `'onSubmit'` — validate only on submit.
|
|
441
|
-
- `'onBlur'` — validate when the field loses focus.
|
|
442
|
-
- `'onChange'` — validate on every change.
|
|
443
|
-
- `'onTouched'` — validate on the first blur, then on every change.
|
|
444
|
-
- `'all'` — validate on both change and blur.
|
|
445
|
-
|
|
446
|
-
```jsx
|
|
447
|
-
import {createForm} from 'react-f0rm';
|
|
448
|
-
|
|
449
|
-
const form = createForm({
|
|
450
|
-
initialValues: {email: ''},
|
|
451
|
-
mode: 'onBlur', // validate on blur…
|
|
452
|
-
reValidateMode: 'onChange' // …then re-validate on every change once errored
|
|
453
|
-
});
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
#### Per-field mode override
|
|
457
|
-
|
|
458
|
-
Sometimes one field deserves a different schedule than the rest of the form — a signup form that validates on submit, except the email field whose check should fire as soon as the user leaves the input. Any field can declare its own `mode`: it replaces the form-level `mode` for that field only, while every other field keeps the form's timing.
|
|
459
|
-
|
|
460
|
-
```jsx
|
|
461
|
-
import {Form, Field, useForm} from 'react-f0rm';
|
|
462
|
-
|
|
463
|
-
function Register() {
|
|
464
|
-
// Form default: validate on submit.
|
|
465
|
-
const form = useForm({initialValues: {email: '', bio: ''}});
|
|
466
|
-
|
|
467
|
-
return (
|
|
468
|
-
<Form form={form}>
|
|
469
|
-
{/* This field alone validates on blur... */}
|
|
470
|
-
<Field name="email" mode="onBlur" validate={checkEmail} />
|
|
471
|
-
{/* ...while every other field waits for submit. */}
|
|
472
|
-
<Field name="bio" />
|
|
473
|
-
</Form>
|
|
474
|
-
);
|
|
475
|
-
}
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
The same option exists on `useField` (and `Checkbox` / `Select`):
|
|
479
|
-
|
|
480
|
-
```jsx
|
|
481
|
-
const email = useField({name: 'email', mode: 'onBlur', validate: checkEmail});
|
|
482
|
-
```
|
|
483
|
-
|
|
484
|
-
- Accepted values are the same `ValidationMode` union as the form's `mode`; omit it and the field follows `form.mode` exactly as before.
|
|
485
|
-
- Precedence is per field: `field.mode ?? form.mode`. A field cannot change another field's timing, and declaring `mode: 'onSubmit'` opts a field out of an `'onChange'` form.
|
|
486
|
-
- `reValidateMode` stays form-level for every field: once a field has an error (after a failed submit, say), re-validation follows the form's `reValidateMode` regardless of the field's own `mode` — a `mode: 'onBlur'` field with the default `reValidateMode: 'onChange'` still re-validates on every keystroke while errored.
|
|
487
|
-
- Manual `trigger` and submit validation are unaffected — they always run the field's validators regardless of any mode.
|
|
488
|
-
|
|
489
|
-
### Triggering validation manually
|
|
490
|
-
|
|
491
|
-
`trigger` runs field validators on demand. Without a name it runs every registered validator; a single name — or an array of names — narrows it to those fields:
|
|
492
|
-
|
|
493
|
-
```jsx
|
|
494
|
-
import {trigger} from 'react-f0rm';
|
|
495
|
-
|
|
496
|
-
trigger(form); // every registered field validator
|
|
497
|
-
trigger(form, 'email'); // one field
|
|
498
|
-
trigger(form, ['user.name', 'user.email']); // several
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
`trigger` returns a promise that waits for the triggered validation to settle — async validators and pending debounce windows included — so errors have already landed in `form.errors` when it resolves. It never rejects: landing errors is the expected outcome here, not a failure. It resolves `true` when the triggered scope is error-free, `false` otherwise:
|
|
502
|
-
|
|
503
|
-
```jsx
|
|
504
|
-
if (await trigger(form, 'email')) {
|
|
505
|
-
proceed(); // 'email' is now guaranteed error-free
|
|
506
|
-
}
|
|
507
|
-
```
|
|
508
|
-
|
|
509
|
-
Without `name` the scope is all fields plus the form-level `validate` result; with `name` only those fields' own errors count and form-level `validate` is skipped (RHF semantics). Fire-and-forget callers may ignore the promise — the validator kicks still happen synchronously.
|
|
510
|
-
|
|
511
|
-
The third argument opts into touched marking: `trigger(form, name, {shouldTouch: true})` marks every path in the triggered scope — the given names, or all registered fields when `name` is omitted — as touched once the round settles, whether validation passed or failed (react-hook-form's `trigger` semantics). Omitted, `trigger` stays validate-only:
|
|
512
|
-
|
|
513
|
-
```jsx
|
|
514
|
-
await trigger(form, 'email', {shouldTouch: true}); // 'email' is now touched, error or not
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
### Async validation
|
|
518
|
-
|
|
519
|
-
Async validators are first-class. Two knobs keep them cheap and race-free:
|
|
520
|
-
|
|
521
|
-
**`validateDebounce`** (on `Field`, `useField` or any bound component) delays a field's validation kicks by the given milliseconds; only the last kick inside the window runs the validator. While the timer is pending the field counts as *validating*, so `trigger` and submit wait the window out instead of racing it. The `required` rule is exempt: it runs synchronously on every kick, so a required failure shows immediately — and while it fails, the field's other validation is skipped. The form-level `validate` gets the same contract through `validateDebounce` on `createForm`/`useForm` (see [Form-level validation](#form-level-validation)).
|
|
522
|
-
|
|
523
|
-
**`meta.signal`** — every validator's second argument carries `{form, path, signal}`. The `AbortSignal` fires as soon as the round is superseded (a newer round started, or the field unregistered), so async validators can cancel their underlying work instead of racing a stale result home:
|
|
524
|
-
|
|
525
|
-
```jsx
|
|
526
|
-
<Field
|
|
527
|
-
name="email"
|
|
528
|
-
validateDebounce={300}
|
|
529
|
-
validate={async (value, {signal}) => {
|
|
530
|
-
const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`, {signal});
|
|
531
|
-
const {taken} = await res.json();
|
|
532
|
-
if (taken) return {type: 'taken', message: 'Email already registered'};
|
|
533
|
-
}}
|
|
534
|
-
/>
|
|
535
|
-
```
|
|
536
|
-
|
|
537
|
-
Stale results are dropped independently of the signal — validators that ignore it stay correct — but passing it to `fetch` (or `AbortSignal.timeout`, timers, …) also cancels the network work itself.
|
|
538
|
-
|
|
539
|
-
### Multiple errors per field
|
|
540
|
-
|
|
541
|
-
Every field stores an ordered `FieldError[]`, not a single error. The first entry is what `error`/`errorObject`/`getError` expose; readers that want all of them use `getFieldErrors(form, name)` or `useFieldErrors(form, name)`:
|
|
542
|
-
|
|
543
|
-
```jsx
|
|
544
|
-
import {getFieldErrors, useFieldErrors, setError} from 'react-f0rm';
|
|
545
|
-
|
|
546
|
-
const all = getFieldErrors(form, 'password');
|
|
547
|
-
// [{type: 'min', message: 'Too short'}, {type: 'pattern', message: 'Needs a digit'}]
|
|
548
|
-
|
|
549
|
-
setError(form, 'password', [
|
|
550
|
-
{type: 'min', message: 'Too short'},
|
|
551
|
-
{type: 'pattern', message: 'Needs a digit'}
|
|
552
|
-
]);
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
`setError` accepts a string, a `FieldError`, an array mixing both, or `undefined` to clear. Schema resolvers pass every issue through — a value breaking several rules collects all of them (Standard Schema/zod by design, yup via `abortEarly: false`) — and `getErrors()` contributes one entry per error. For imperative clears, `clearErrors(form)` wipes every error while `clearErrors(form, name)` — one name or an array of names — clears only those fields.
|
|
556
|
-
|
|
557
|
-
### `setValue` options
|
|
558
|
-
|
|
559
|
-
The fourth argument to `setValue` opts into side effects. `shouldValidate`/`shouldTouch` default to `false`; omitting the object keeps the plain set-value behavior:
|
|
560
|
-
|
|
561
|
-
```jsx
|
|
562
|
-
import {setValue} from 'react-f0rm';
|
|
563
|
-
|
|
564
|
-
setValue(form, 'email', 'a@b.com', {
|
|
565
|
-
shouldValidate: true, // run the field's registered validator after the value lands
|
|
566
|
-
shouldTouch: true, // mark the field as touched
|
|
567
|
-
shouldDirty: false // land the value as a commit: it becomes the field's dirty baseline
|
|
568
|
-
});
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
Dirty state is derived, not marked: a field is dirty while its live value differs from `initialValues` (reverting to the initial value makes it clean again). That makes `shouldDirty` a one-sided flag. `shouldDirty: false` declares this write a **commit instead of an edit** — the written value becomes that field's dirty-comparison baseline, so `getDirtyFields`/`isDirty`/`getFieldState().isDirty` read the field as clean immediately, and a later write dirties it only by differing from the new baseline:
|
|
572
|
-
|
|
573
|
-
```jsx
|
|
574
|
-
setValue(form, 'email', 'normalized@x.com', {shouldDirty: false});
|
|
575
|
-
getDirtyFields(form); // {} — the normalization is not a user edit
|
|
576
|
-
|
|
577
|
-
setValue(form, 'email', 'normalized@x.com'); // still clean: equal to the baseline
|
|
578
|
-
setValue(form, 'email', 'a@b.com'); // dirty: differs from it
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
Use it whenever a programmatic write is not user input — normalized/formatted values, autofill, defaults applied after mount — and you don't want it to trip the "unsaved changes" state. `shouldDirty: true` (or omitting the flag) is the default derived behavior spelled out; unlike react-hook-form, where `setValue` skips dirty marking unless opted in, react-f0rm always derives dirty from the comparison and `false` is the opt-out.
|
|
582
|
-
|
|
583
|
-
Committed baselines follow the form's lifecycle: `reset`, `setInitialValues` and `resetField`/`removeField` drop them (the state they measured against is gone), and a wholesale write at an ancestor path — a `useFieldArray` rewrite, say — drops baselines beneath it, since the subtree they were committed against no longer exists.
|
|
584
|
-
|
|
585
|
-
### Writing as a user change (`changeValue`)
|
|
586
|
-
|
|
587
|
-
`setValue` is the imperative channel — `shouldValidate` kicks the field's validator unconditionally, ignoring any mode. `changeValue` is the user-change channel: the write rides the same core pipeline a user typing into the field would fire (`userChangeByPath` + the field-mode registry — `useField` registers its mode override on mount), so it fires exactly the validation a user typing would fire — the field's effective `mode` (per-field override included) and the form's `reValidateMode`. With no mounted field on the path it degrades to a plain `setValue`.
|
|
588
|
-
|
|
589
|
-
```jsx
|
|
590
|
-
import {changeValue} from 'react-f0rm';
|
|
591
|
-
|
|
592
|
-
// An onSubmit form with the default reValidateMode 'onChange': quiet
|
|
593
|
-
// while the field has no error, re-validates once it does — same as typing.
|
|
594
|
-
changeValue(form, 'email', 'a@b.com');
|
|
595
|
-
```
|
|
596
|
-
|
|
597
|
-
This is the channel component libraries need when they hand a control a plain setter bound to a field (a `Control`/controlled-bridge over `useField`'s value): the mode gating — per-field override and live-error view — lives in the core's user-change pipeline, not in public form state, so the write must route through it rather than through a raw `setValue`.
|
|
598
|
-
|
|
599
|
-
`changeValue` takes the same options object as `setValue` (see [`setValue` options](#setvalue-options)). With a field mounted on the path, `shouldDirty: false` applies — the write lands as a commit while the field's own mode gating keeps driving validation, which is the point of this channel (`shouldValidate`/`shouldTouch` have no meaning there: forcing them would defeat the gating). With no mounted field, the options forward to the plain `setValue` fallback wholesale:
|
|
600
|
-
|
|
601
|
-
```jsx
|
|
602
|
-
changeValue(form, 'email', 'normalized@x.com', {shouldDirty: false});
|
|
603
|
-
```
|
|
604
|
-
|
|
605
|
-
### Field-level validation
|
|
606
|
-
|
|
607
|
-
Pass a `validate` function to `Field` or `useField`. Return an error string, a `FieldError` object or `undefined` — sync or async:
|
|
608
|
-
|
|
609
|
-
```jsx
|
|
610
|
-
<Field
|
|
611
|
-
name="email"
|
|
612
|
-
validate={value => {
|
|
613
|
-
if (!value.includes('@')) return 'Invalid email';
|
|
614
|
-
}}
|
|
615
|
-
/>
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
### Rules
|
|
619
|
-
|
|
620
|
-
For declarative constraints, pass `rules` to `Field` (or any bound component — `Checkbox`, `Select` — or `useField`). Rule failures land in the form's error state as `FieldError`s (`type` is the rule name) carrying your message, so any design system can render them uniformly instead of the browser's validity bubble:
|
|
621
|
-
|
|
622
|
-
```jsx
|
|
623
|
-
<Field
|
|
624
|
-
name="age"
|
|
625
|
-
rules={{
|
|
626
|
-
required: 'Age is required',
|
|
627
|
-
min: 18,
|
|
628
|
-
messages: {min: 'Must be an adult'}
|
|
629
|
-
}}
|
|
630
|
-
/>
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
| Rule | Value | Fails when | Default message |
|
|
634
|
-
|---|---|---|---|
|
|
635
|
-
| `required` | `string \| true` | value is `''`, `undefined` or `null` (`0` and `false` count as filled) | `'This field is required'` |
|
|
636
|
-
| `min` | `number` | `Number(value) < min` — values converting to `NaN` skip the rule | `` `Must be at least ${min}` `` |
|
|
637
|
-
| `max` | `number` | `Number(value) > max` — `NaN` skips | `` `Must be at most ${max}` `` |
|
|
638
|
-
| `minLength` | `number` | a string value is shorter — non-strings skip | `` `Must be at least ${n} characters` `` |
|
|
639
|
-
| `maxLength` | `number` | a string value is longer — non-strings skip | `` `Must be at most ${n} characters` `` |
|
|
640
|
-
| `pattern` | `{value: RegExp, message: string}` | `pattern.value.test(value)` is false | the given `message` |
|
|
641
|
-
|
|
642
|
-
The optional top-level `messages` record overrides messages per rule type (`min`, `max`, `minLength`, `maxLength`, `pattern`) — useful for centralizing or localizing them.
|
|
643
|
-
|
|
644
|
-
Semantics:
|
|
645
|
-
|
|
646
|
-
- A failing `required` short-circuits the rest — an empty value reports only its `required` error, not a full panel — and skips `validate` entirely for that kick: the async check never sees an empty value.
|
|
647
|
-
- `required` runs synchronously on every kick, even under a positive `validateDebounce`: its error shows on the keystroke and clears as soon as the value is filled.
|
|
648
|
-
- Every other failing rule collects into one ordered `FieldError[]` (see [Multiple errors per field](#multiple-errors-per-field)).
|
|
649
|
-
- The other rules compose with `validate`: they run first, then `validate` (awaited when async), merging both sources' errors with rules ahead. They ride the same pipeline as `validate` — `mode`, `reValidateMode`, `validateDebounce` and `meta.signal` all apply unchanged.
|
|
650
|
-
|
|
651
|
-
Rules vs native constraints: HTML attributes (`required`, `type="email"`, `min`, …) keep running through the browser's `checkValidity`, whose bubble remains the pre-submit fallback. `rules` is the state-side alternative — failures are queryable (`getErrors`, `error`, `errors`), renderable by any UI, and carry your own messages. Prefer `rules` whenever the error text must be controlled.
|
|
652
|
-
|
|
653
|
-
### Form-level validation
|
|
654
|
-
|
|
655
|
-
Pass a `validate` function to `createForm`. It receives all values and returns a record of errors. Nested objects are flattened recursively — `{user: {name: 'Required'}}` sets the error at `user.name` — and plain flat results keep working:
|
|
656
|
-
|
|
657
|
-
```jsx
|
|
658
|
-
import {createForm} from 'react-f0rm';
|
|
659
|
-
|
|
660
|
-
const form = createForm({
|
|
661
|
-
initialValues: {password: '', confirm: ''},
|
|
662
|
-
validate: values => {
|
|
663
|
-
if (values.password !== values.confirm) {
|
|
664
|
-
return {confirm: 'Passwords do not match'};
|
|
665
|
-
}
|
|
666
|
-
},
|
|
667
|
-
});
|
|
668
|
-
```
|
|
669
|
-
|
|
670
|
-
The validate function may be async (it is awaited), and its optional second argument carries `{form, signal}` — the same contract as field validators' `meta`. Add `validateDebounce` (milliseconds) to give the whole-form validate the per-field window contract: kicks from `trigger`/submit inside the window merge into one run reading the values current when the window closes, and while the timer is pending the form counts as *validating*, so `trigger` and submit wait the window out instead of racing it:
|
|
671
|
-
|
|
672
|
-
```jsx
|
|
673
|
-
const form = useForm({
|
|
674
|
-
validate: async (values, {signal}) => {
|
|
675
|
-
const res = await fetch('/api/validate', {
|
|
676
|
-
method: 'POST',
|
|
677
|
-
body: JSON.stringify(values),
|
|
678
|
-
signal
|
|
679
|
-
});
|
|
680
|
-
const {errors} = await res.json();
|
|
681
|
-
return errors; // nested error record, flattened like above
|
|
682
|
-
},
|
|
683
|
-
validateDebounce: 300
|
|
684
|
-
});
|
|
685
|
-
```
|
|
686
|
-
|
|
687
|
-
The `AbortSignal` fires as soon as the round is superseded — a newer round started, which under a positive `validateDebounce` means a kick landed during the in-flight round's window — so async validators can cancel their underlying work instead of racing a stale result home. Stale results are dropped independently by the round gate, so validators that ignore the signal stay correct too. Without `validateDebounce` (`0`/omitted) the validate runs once per `trigger`/submit exactly as before; it still receives the meta argument, but nothing supersedes an immediate round, so its signal never fires.
|
|
688
|
-
|
|
689
|
-
#### Reading form-level errors
|
|
690
|
-
|
|
691
|
-
Errors that belong to no single field need a slot to land in: a form-level `validate` record may return a `_form` entry, and the Standard Schema adapter drops every path-less issue there (see [Schema validation](#schema-validation)). That reserved key is exported as `FORM_ERROR`, so the magic string never has to be hand-written:
|
|
692
|
-
|
|
693
|
-
```jsx
|
|
694
|
-
import {FORM_ERROR, useFormError, useFormErrors} from 'react-f0rm';
|
|
695
|
-
|
|
696
|
-
function FormErrorBanner({form}) {
|
|
697
|
-
const error = useFormError(form); // first form-level error's message
|
|
698
|
-
return error ? <p role="alert">{error}</p> : null;
|
|
103
|
+
));
|
|
699
104
|
}
|
|
700
105
|
```
|
|
701
106
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
#### Re-running on dependent field changes (`validateDeps`)
|
|
705
|
-
|
|
706
|
-
By default the form-level `validate` runs on `trigger` and submit only — a cross-field error stays on screen even after the user edits the field that would fix it. `validateDeps` declares the fields whose **user changes re-run the form-level `validate`**:
|
|
107
|
+
Schemas go straight into `validate`:
|
|
707
108
|
|
|
708
109
|
```jsx
|
|
709
|
-
const form = useForm({
|
|
710
|
-
initialValues: {password: '', confirm: ''},
|
|
711
|
-
validate: values =>
|
|
712
|
-
values.password !== values.confirm
|
|
713
|
-
? {confirm: 'Passwords do not match'}
|
|
714
|
-
: {},
|
|
715
|
-
validateDeps: ['password']
|
|
716
|
-
});
|
|
717
|
-
```
|
|
718
|
-
|
|
719
|
-
Now the submit-then-fix flow works: submit lands the mismatch on `confirm`, editing `password` re-runs the validate, and the passing round makes the error disappear. The re-run timing rides the same mode matrix as any field validator, evaluated against the changed field's effective `mode` (per-field override included) and the form's `reValidateMode`:
|
|
720
|
-
|
|
721
|
-
| Situation | Dep change re-runs the form validate? |
|
|
722
|
-
|---|---|
|
|
723
|
-
| `mode: 'onChange'` / `'all'` (form or the dep field) | yes, error state or not |
|
|
724
|
-
| `mode: 'onTouched'`, dep field touched | yes |
|
|
725
|
-
| otherwise, the last round's error is live **and** `reValidateMode: 'onChange'` (default) | yes — the submit-then-fix flow |
|
|
726
|
-
| `reValidateMode: 'onBlur'` / `'onSubmit'` | no — a change is not a blur; re-runs wait for their own trigger |
|
|
727
|
-
|
|
728
|
-
Details that fall out of the plumbing:
|
|
729
|
-
|
|
730
|
-
- **User changes only.** The kick rides the mounted field's own change pipeline, so typing and `changeValue` (component-library bridges) both fire it, while programmatic `setValue` does not — exactly like field validators. A dep path with no mounted field never re-runs the validate.
|
|
731
|
-
- **Round-scoped error ownership.** Opting in changes what a re-run may clear: each round first drops the errors the *previous round* wrote, then lands its own result — so a passing re-run clears the stale mismatch. Errors the round never wrote (field validators', `setServerErrors`, manual `setError`) survive it, and a foreign write onto a round-owned path takes the key out of the round's ownership.
|
|
732
|
-
- **`validateDebounce` applies.** Dep-change kicks are ordinary kicks: they merge inside the debounce window like `trigger`/submit kicks do.
|
|
733
|
-
- Forms that don't set `validateDeps` keep the historical behavior untouched — the form validate runs on `trigger`/submit only, and re-runs never clear earlier errors.
|
|
734
|
-
|
|
735
|
-
TanStack Form's counterpart is `onChangeListenTo` (v1) / validator `triggers` (v2 alpha); both re-run a validator when listed fields change. react-f0rm keeps the declaration at the form level (the validate belongs to the form) and gates the re-run by the library's own `mode`/`reValidateMode` semantics instead of adding an always-on listener.
|
|
736
|
-
|
|
737
|
-
#### Field-to-field linkage (`validateDeps` on `useField`)
|
|
738
|
-
|
|
739
|
-
The same declaration exists per field: `validateDeps` on `useField` lists the **other** fields whose user changes re-run **this field's validator** — the field-level shape of the option above, for cross-field rules you want as field errors (queryable via `getError`, renderable by `renderError`) without a form-level validate:
|
|
740
|
-
|
|
741
|
-
```jsx
|
|
742
|
-
const form = useForm({
|
|
743
|
-
initialValues: {password: '', passwordConfirm: ''}
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
function PasswordConfirmField() {
|
|
747
|
-
const {value, onChange, error} = useField({
|
|
748
|
-
form,
|
|
749
|
-
name: 'passwordConfirm',
|
|
750
|
-
validate: v => (v === getValue(form, 'password') ? undefined : 'Passwords do not match'),
|
|
751
|
-
validateDeps: ['password']
|
|
752
|
-
});
|
|
753
|
-
// ...
|
|
754
|
-
}
|
|
755
|
-
```
|
|
756
|
-
|
|
757
|
-
The re-run semantics mirror the form-level option exactly, because it rides the same channel — the changed field's own onChange pipeline:
|
|
758
|
-
|
|
759
|
-
| Situation | Dep change re-runs this field's validator? |
|
|
760
|
-
|---|---|
|
|
761
|
-
| `mode: 'onChange'` / `'all'` (form or the dep field) | yes, error state or not |
|
|
762
|
-
| `mode: 'onTouched'`, dep field touched | yes |
|
|
763
|
-
| otherwise, this field shows an error **and** `reValidateMode: 'onChange'` (default) | yes — the submit-then-fix flow |
|
|
764
|
-
| `reValidateMode: 'onBlur'` / `'onSubmit'` | no — re-runs wait for their own trigger |
|
|
765
|
-
|
|
766
|
-
- **User changes only.** Typing and `changeValue` fire it; programmatic `setValue` does not. A dep path with no mounted field never re-runs the validator.
|
|
767
|
-
- **A passing re-run clears the error.** A field validator owns its whole error key (every kick's result replaces the previous list), so the submit-then-fix flow needs no footprint bookkeeping: edit `password` until it matches and the confirm error disappears.
|
|
768
|
-
- **`validateDebounce` applies** — the re-run is an ordinary kick of this field's validator, debounce window included.
|
|
769
|
-
- Listing the field's own path is a no-op (its own change already validates it), and unmounting the dependent field drops the linkage.
|
|
770
|
-
|
|
771
|
-
### Schema validation
|
|
772
|
-
|
|
773
|
-
Any library implementing [Standard Schema v1](https://standardschema.dev) — zod v3.24+/v4, valibot v1, arktype and more — works through one adapter, imported from its own tree-shakeable entry point:
|
|
774
|
-
|
|
775
|
-
```jsx
|
|
776
|
-
import {Form, Field, createForm} from 'react-f0rm';
|
|
777
|
-
import {
|
|
778
|
-
standardSchemaFormValidator,
|
|
779
|
-
standardSchemaResolver
|
|
780
|
-
} from 'react-f0rm/resolvers/standard-schema';
|
|
781
110
|
import {z} from 'zod';
|
|
782
111
|
|
|
783
|
-
const schema = z.object({
|
|
784
|
-
email: z.string().email(),
|
|
785
|
-
password: z.string().min(8)
|
|
786
|
-
});
|
|
787
|
-
|
|
788
|
-
// Form-level: validate the whole values object; issue paths map to
|
|
789
|
-
// field errors automatically, issues without a path land on `_form`
|
|
790
|
-
const form = createForm({
|
|
791
|
-
validate: standardSchemaFormValidator(schema)
|
|
792
|
-
});
|
|
793
|
-
|
|
794
|
-
// Field-level: validate a single value
|
|
795
|
-
<Field name="email" validate={standardSchemaResolver(z.string().email())} />
|
|
796
|
-
```
|
|
797
|
-
|
|
798
|
-
Schema errors come back as `{type: 'standard', message}`.
|
|
799
|
-
|
|
800
|
-
On success the adapter returns the schema's parsed output, which the form stores as its `parsedValues` baseline: `getValues()` and submit callbacks (`onSubmit`/`onValidSubmit`) read coerced/transformed values — `z.coerce.number()` hands back a real `number`, not the raw string. The baseline sits between `initialValues` and live edits, so fields the user changes afterwards still win, and dirty state keeps comparing live edits against `initialValues` only — parsing never marks a field dirty. `reset()` and `setInitialValues()` clear the baseline.
|
|
801
|
-
|
|
802
|
-
#### Schema defaults
|
|
803
|
-
|
|
804
|
-
**Standard Schema v1 has no default-value metadata.** The interface carries types and `validate` and nothing else — whether a field declares a default, and how to read it, is vendor territory: zod v3.24 exposes `.getDefault()` per field, zod v4 wraps defaulted fields in a `ZodDefault` whose `.def.defaultValue` is a de-facto-public field rather than a documented accessor, valibot ships a `getDefault` util. None of it is reachable through the standard surface, and react-f0rm reads schemas only through `~standard.validate` — probing `schema.shape`/`.def` internals per vendor is exactly the adapter-per-library tree this library refuses to grow. So `defaultValues` derived from a schema is deliberately **not** a library feature: pass `initialValues` explicitly.
|
|
805
|
-
|
|
806
|
-
What you do get for free: defaults flow through `validate`. A schema's parsed output contains every declared default, so after the first successful validation the `parsedValues` baseline already serves them — `getValues()` reads `z.string().default('anon')` fields as `'anon'` without any seeding. The gap is only the render before the first validation round, and two recipes close it user-side:
|
|
807
|
-
|
|
808
|
-
```jsx
|
|
809
|
-
// 1. Vendor-neutral: one parse of an empty object materializes every
|
|
810
|
-
// default the schema declares (nested ones included).
|
|
811
|
-
const result = schema['~standard'].validate({});
|
|
812
|
-
const initialValues = result.issues ? {} : result.value;
|
|
813
|
-
|
|
814
|
-
const form = useForm({initialValues, validate: standardSchemaFormValidator(schema)});
|
|
815
|
-
```
|
|
816
|
-
|
|
817
|
-
The empty parse succeeds only where defaults cover everything; a required field without a default fails it, and `{}` is the honest seed in that case. For per-field extraction instead of a whole-object parse, do it through the vendor's own API — zod v4:
|
|
818
|
-
|
|
819
|
-
```jsx
|
|
820
|
-
// 2. zod v4: ZodDefault wrappers expose their default on .def
|
|
821
|
-
const defaultValues = Object.fromEntries(
|
|
822
|
-
Object.entries(schema.shape).map(([key, field]) => [
|
|
823
|
-
key,
|
|
824
|
-
field.def?.type === 'default' ? field.def.defaultValue : undefined
|
|
825
|
-
])
|
|
826
|
-
);
|
|
827
|
-
```
|
|
828
|
-
|
|
829
|
-
(zod v3.24: the same loop calling `field.getDefault()`. That this loop is version-specific is the point — it is your schema and your vendor, not the form library's, contract to maintain.)
|
|
830
|
-
|
|
831
|
-
### Delaying error display
|
|
832
|
-
|
|
833
|
-
`delayError` (milliseconds) holds a newly appearing error back from the render for a short window — users typing through a field are not interrupted by an error the next keystroke may already fix:
|
|
834
|
-
|
|
835
|
-
```jsx
|
|
836
|
-
<Field name="username" rules={{minLength: 3}} delayError={300} />
|
|
837
|
-
```
|
|
838
|
-
|
|
839
|
-
The delay is render-layer only: `error`/`errorObject`/`errors` from `useField` (and everything `Field` derives from them — `aria-invalid`, `renderError`) stay `undefined`/empty until the window passes. The form's error state is never delayed — `trigger`, submit and `getError(form, name)` read the error immediately, unlike react-hook-form's formState-level delay. An error that clears inside the window never shows at all; once an error is visible, later changes (a new message, entries added or removed) apply immediately — only the none → some transition waits.
|
|
840
|
-
|
|
841
|
-
## Dirty & Touched Fields
|
|
842
|
-
|
|
843
|
-
```jsx
|
|
844
|
-
import {useDirtyFields, useTouchedFields} from 'react-f0rm';
|
|
845
|
-
|
|
846
|
-
function FormStatus({form}) {
|
|
847
|
-
const dirtyFields = useDirtyFields(form); // {'user.name': true, 'tags.0': true}
|
|
848
|
-
const touched = useTouchedFields(form); // ['user.name', 'tags.0']
|
|
849
|
-
return (
|
|
850
|
-
<p>{Object.keys(dirtyFields).length} dirty, {touched.length} touched</p>
|
|
851
|
-
);
|
|
852
|
-
}
|
|
853
|
-
```
|
|
854
|
-
|
|
855
|
-
Both hooks expose user-facing dotted paths (`'a.b'`, `'a.0.c'`). The imperative counterparts `getDirtyFields(form)` and `getTouchedFields(form)` return the same shapes without subscribing.
|
|
856
|
-
|
|
857
|
-
### Seeding new initial values
|
|
858
|
-
|
|
859
|
-
`setInitialValues(form, values)` swaps the baseline by **content**, not reference: passing a fresh object with equal content (the inline literal a re-render recreates) is a no-op — committed edits survive — while genuinely changed content re-seeds: live values and tombstones are cleared, touched flags and errors survive. Same value semantics `useForm({initialValues})`/`<Form initialValues>` sync with, so the editor-page shape works without memoizing the literal or double-passing it:
|
|
860
|
-
|
|
861
|
-
```jsx
|
|
862
|
-
const article = useData<Article>() ?? undefined;
|
|
863
|
-
// No useMemo needed: a new object per render with equal content never
|
|
864
|
-
// clears what the user typed; switching to another article re-seeds.
|
|
865
|
-
const form = useForm({initialValues: articleToValues(article)});
|
|
866
|
-
```
|
|
867
|
-
|
|
868
|
-
### Async initial values
|
|
869
|
-
|
|
870
|
-
`initialValues` accepts a Promise, or a thunk returning a value or Promise — react-hook-form's async `defaultValues` shape. The form starts empty with `isLoading: true`, and when the source resolves, its values become the baseline (setInitialValues semantics: value subscribers re-sync, dirty/touched start clean, `reset()` returns to the resolved values):
|
|
871
|
-
|
|
872
|
-
```jsx
|
|
873
112
|
const form = useForm({
|
|
874
|
-
initialValues:
|
|
113
|
+
initialValues: {email: '', password: ''},
|
|
114
|
+
validate: z.object({
|
|
115
|
+
email: z.string().email(),
|
|
116
|
+
password: z.string().min(8)
|
|
117
|
+
}) // no resolver import; TValues infers from the schema
|
|
875
118
|
});
|
|
876
|
-
const isLoading = useIsLoading(form); // also on useFormState(form).isLoading
|
|
877
|
-
|
|
878
|
-
if (isLoading) return <Spinner />;
|
|
879
|
-
return <Form form={form}>…</Form>;
|
|
880
|
-
```
|
|
881
|
-
|
|
882
|
-
Notes:
|
|
883
|
-
|
|
884
|
-
- The thunk runs at create time — keep its identity stable (`useMemo`, module scope) when passing it inline; StrictMode double-invokes it in development, like every render-phase call.
|
|
885
|
-
- A rejected source keeps the form empty, flips `isLoading` off and logs the error in DEV — attach a `.catch` on the source to handle it.
|
|
886
|
-
- SSR renders the form empty with `isLoading: true` on both sides, so hydration matches; the values land client-side after the fetch (pass the server-resolved record to hydrate eagerly instead).
|
|
887
|
-
|
|
888
|
-
### Resetting
|
|
889
|
-
|
|
890
|
-
`reset(form, initialValues?)` wipes values, errors, touched, tombstones and the submission flags (`isSubmitting`, `submitCount`, `isSubmitSuccessful`). The second argument installs a fresh baseline; omitted (or `undefined`), the form keeps its current `initialValues` and every field simply returns to its initial value — the plain `reset(form)` "undo everything" shape. The third opts into keeping slices of state through the reset:
|
|
891
|
-
|
|
892
|
-
```jsx
|
|
893
|
-
import {reset} from 'react-f0rm';
|
|
894
|
-
|
|
895
|
-
reset(form); // back to the current initialValues
|
|
896
|
-
reset(form, freshRecord); // full reset to the new baseline
|
|
897
|
-
reset(form, freshRecord, {keepDirtyValues: true}); // dirty drafts survive
|
|
898
|
-
reset(form, undefined, {keepTouched: true}); // reset, keep touched flags
|
|
899
|
-
```
|
|
900
|
-
|
|
901
|
-
`keepDirtyValues` is the refetch shape: reload the record from the server, but fields the user already edited keep their live values (dirtiness is measured against the pre-reset initialValues; clean fields fall back to the new baseline):
|
|
902
|
-
|
|
903
|
-
```jsx
|
|
904
|
-
const {data} = useQuery(['user', id], () => fetchUser(id));
|
|
905
|
-
// data changed (refetch, different user) — replace the draft,
|
|
906
|
-
// but never clobber fields the user is mid-edit on
|
|
907
|
-
useEffect(() => {
|
|
908
|
-
if (data) reset(form, data, {keepDirtyValues: true});
|
|
909
|
-
}, [data]);
|
|
910
|
-
```
|
|
911
|
-
|
|
912
|
-
The other flags — `keepTouched`, `keepErrors`, `keepIsSubmitted`, `keepSubmitCount`, `keepIsSubmitting` — all default to `false`; omitting the object keeps the plain full-reset behavior.
|
|
913
|
-
|
|
914
|
-
### Resetting a single field
|
|
915
|
-
|
|
916
|
-
`resetField(form, name, options?)` resets one field and leaves the rest of the form alone: the field's live value is dropped (reads fall back to `initialValues` — when a schema's `parsedValues` baseline exists, its path is removed so the coerced output stops shadowing the initial value), and the field's touched flag and errors are cleared:
|
|
917
|
-
|
|
918
|
-
```jsx
|
|
919
|
-
import {resetField, getFieldState} from 'react-f0rm';
|
|
920
|
-
|
|
921
|
-
resetField(form, 'email'); // back to initialValues
|
|
922
|
-
resetField(form, 'email', {keepTouched: true}); // keep the touched flag
|
|
923
|
-
resetField(form, 'email', {value: ''}); // explicit value, no fallback
|
|
924
|
-
```
|
|
925
|
-
|
|
926
|
-
| Option | Default | Effect |
|
|
927
|
-
| ------------- | ------- | ------------------------------------------------------------- |
|
|
928
|
-
| `keepTouched` | `false` | Keep the field's touched flag |
|
|
929
|
-
| `keepErrors` | `false` | Keep the field's errors |
|
|
930
|
-
| `value` | — | Explicit post-reset value; never falls back to `initialValues` |
|
|
931
|
-
|
|
932
|
-
Its read-side sibling `getFieldState(form, name)` returns one field's aggregated state — `{value, error, errors, isDirty, isTouched, isValidating}` — where `isDirty` applies the same rule as `getDirtyFields` (a live value differing from `initialValues`; parsing never counts) and `errors` is the stored array shared with `getFieldErrors`, so treat it as read-only:
|
|
933
|
-
|
|
934
|
-
```jsx
|
|
935
|
-
const {value, error, isDirty} = getFieldState(form, 'email');
|
|
936
|
-
```
|
|
937
|
-
|
|
938
|
-
## Accessibility
|
|
939
|
-
|
|
940
|
-
Bound fields (`Field`, `Checkbox`, `Select`) wire the error chain automatically: whenever the field has an error, the control gets `aria-invalid="true"` and its `aria-describedby` gains `fieldErrorId(name)` — the id the error message element is expected to carry. `fieldErrorId` is exported, so a custom error component only needs to render that id with `role="alert"` to complete the chain for screen readers:
|
|
941
|
-
|
|
942
|
-
```jsx
|
|
943
|
-
import {useFormContext, useError, fieldErrorId} from 'react-f0rm';
|
|
944
|
-
|
|
945
|
-
function FieldMessage({name}) {
|
|
946
|
-
const form = useFormContext();
|
|
947
|
-
const error = useError(form, name);
|
|
948
|
-
return error ? (
|
|
949
|
-
<span id={fieldErrorId(name)} role="alert" className="field-error">
|
|
950
|
-
{error}
|
|
951
|
-
</span>
|
|
952
|
-
) : null;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
<Field name="email" />
|
|
956
|
-
<FieldMessage name="email" />
|
|
957
|
-
```
|
|
958
|
-
|
|
959
|
-
For the built-in path, provide a `renderError(error, id)` function instead: `Field` renders `<span id={id} role="alert">{renderError(error, id)}</span>` next to the input — same id, same wiring, no extra component:
|
|
960
|
-
|
|
961
|
-
```jsx
|
|
962
|
-
<Field
|
|
963
|
-
name="email"
|
|
964
|
-
renderError={error => <em>{error}</em>}
|
|
965
|
-
/>
|
|
966
|
-
```
|
|
967
|
-
|
|
968
|
-
A user-provided `aria-describedby` survives: on error, the field's id is appended after yours (`"hint email"`). Without an error, no `aria-describedby` is added.
|
|
969
|
-
|
|
970
|
-
Native constraint validation (`required`, `type=email`, `minLength`, …) gates submission: `<Form>` runs the browser's `checkValidity()` before custom validators, and failing constraints surface as native validation bubbles via `reportValidity()`.
|
|
971
|
-
|
|
972
|
-
## Server-side errors
|
|
973
|
-
|
|
974
|
-
Server 422s land on the same channel client-side validation uses. `setServerErrors(form, errors)` takes the flat `Record<string, string | string[]>` shape REST APIs commonly return — RealWorld's `422 {errors: {email: ['has already been taken']}}` needs no hand-rolled `Object.entries` + `setError` loop — and stores each entry as the field's error(s) with `type: 'server'`:
|
|
975
|
-
|
|
976
|
-
```jsx
|
|
977
|
-
import {setServerErrors} from 'react-f0rm';
|
|
978
|
-
|
|
979
|
-
async function onSubmit(values) {
|
|
980
|
-
try {
|
|
981
|
-
await api.post('/users', {user: values});
|
|
982
|
-
} catch (e) {
|
|
983
|
-
// e.data.errors: {email: ['has already been taken'], ...}
|
|
984
|
-
setServerErrors(form, e.data.errors);
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
```
|
|
988
|
-
|
|
989
|
-
The message then renders under the field through the same error machinery (`renderError`, `useError` — see [Accessibility](#accessibility)), and the field's `aria-invalid`/`aria-describedby` wiring kicks in automatically. Existing errors are cleared first — a fresh response describes the current state; pass `{keepExisting: true}` to layer instead. String values land as one error, string arrays as several; an empty array clears that field.
|
|
990
|
-
|
|
991
|
-
## Server-side validation
|
|
992
|
-
|
|
993
|
-
The client-side gate is UX, not security — payloads must be re-validated where they arrive. `react-f0rm/server` is the entry for that: a separate module graph with zero React, safe to import from Server Actions, RSC and plain Node, and — like the resolvers and devtools — never re-exported from the main entry, so client builds that never validate server-side stay at baseline size. Its export is one function:
|
|
994
|
-
|
|
995
|
-
```jsx
|
|
996
|
-
import {validateValues} from 'react-f0rm/server';
|
|
997
|
-
|
|
998
|
-
// A Server Action — or any handler that receives a payload
|
|
999
|
-
export async function saveProfile(values) {
|
|
1000
|
-
const {valid, values: parsed, errors} = await validateValues(values, {
|
|
1001
|
-
validate: values =>
|
|
1002
|
-
values.email.includes('@') ? undefined : {email: 'Invalid email'}
|
|
1003
|
-
});
|
|
1004
|
-
if (!valid) return {errors};
|
|
1005
|
-
return save(parsed);
|
|
1006
|
-
}
|
|
1007
|
-
```
|
|
1008
|
-
|
|
1009
|
-
`validateValues(values, options?)` spins up one throwaway form from `options` (its `initialValues` forced to `values`), runs a whole-form `trigger`, and reads the outcome back — async validators and `validateDebounce` windows are awaited, so the result is settled, never a mid-flight snapshot. The rules come from `options.validate`, the form-level validator: field validators register through mounted fields and nothing is mounted on the server, so pass `standardSchemaFormValidator(schema)` (from `react-f0rm/resolvers/standard-schema`) or a hand-written `validate`. The result carries:
|
|
1010
|
-
|
|
1011
|
-
- `valid` — `trigger`'s boolean. An invalid payload is a normal outcome, never a rejection: both branches are interesting on the server (persist vs. bounce back).
|
|
1012
|
-
- `values` — the tree after the round. A schema validator's parsed output (coerce/transform included) becomes the baseline, so this is the tree to persist, not necessarily the object passed in.
|
|
1013
|
-
- `errors` — the flat `{path, type, message}` entries, the same list `getErrors` hands out on the client. A one-liner lands a failed round back on the client form through the [Server-side errors](#server-side-errors) channel:
|
|
1014
|
-
|
|
1015
|
-
```jsx
|
|
1016
|
-
setServerErrors(form, Object.fromEntries(errors.map(e => [e.path, e.message])));
|
|
1017
|
-
```
|
|
1018
|
-
|
|
1019
|
-
The entry also re-exports `VALIDATION_OUTCOME`/`ValidationOutcome` for building branded validator results server-side without importing the package root (which would drag the React graph back in).
|
|
1020
|
-
|
|
1021
|
-
TanStack Form's counterpart is `createServerValidate`; theirs wraps the round inside a generated server action, while `validateValues` stays a plain function over values. That is the same stance as the client bridge — no `action` prop, no generated handler ("Server Actions: bridge, not first-class" in [Which one should you use?](#which-one-should-you-use), and the [React 19 Server Actions guide](docs-site/docs/guides/react19-server-actions.md) for the why): react-f0rm composes into your framework's handler instead of owning it.
|
|
1022
|
-
|
|
1023
|
-
## TypeScript
|
|
1024
|
-
|
|
1025
|
-
`FieldPath<T>` and `PathValue<T, P>` make field names and value types compile-time checked:
|
|
1026
|
-
|
|
1027
|
-
```tsx
|
|
1028
|
-
import {FieldPath, PathValue, useField} from 'react-f0rm';
|
|
1029
|
-
|
|
1030
|
-
interface Values {
|
|
1031
|
-
user: {name: string};
|
|
1032
|
-
tags: string[];
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
// 'user' | 'user.name' | 'tags' | `tags[0]` | ...
|
|
1036
|
-
type ValuesPath = FieldPath<Values>;
|
|
1037
|
-
|
|
1038
|
-
// string
|
|
1039
|
-
type UserName = PathValue<Values, 'user.name'>;
|
|
1040
|
-
|
|
1041
|
-
function UserNameField() {
|
|
1042
|
-
// value is inferred as string; a path outside FieldPath<Values> — a
|
|
1043
|
-
// typo or an untyped string variable — is a compile error on these
|
|
1044
|
-
// generic APIs (react-hook-form parity). Segment arrays
|
|
1045
|
-
// (['user', 'name']) stay accepted and read as `any`.
|
|
1046
|
-
const {value, onChange} = useField<Values, 'user.name'>({name: 'user.name'});
|
|
1047
|
-
return <input value={value} onChange={e => onChange(e.target.value)} />;
|
|
1048
|
-
}
|
|
1049
119
|
```
|
|
1050
120
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
The strictness is on the generic path APIs (`useField`, `setValue`, `getValue`, `useValue`, …): an unknown path fails there. Two escape hatches stay deliberately wide, because their names are runtime-computed by nature:
|
|
1054
|
-
|
|
1055
|
-
- **Segment arrays** (`['items', 0]`): accepted everywhere, value reads as `any` — the dynamic-path escape hatch.
|
|
1056
|
-
- **Runtime-dynamic entry points**: `useFieldArray`, `useFieldArrayItem`, `removeField`, `setTouched`, `setFocus`, `trigger` and `clearErrors` take the wide `Name` type, so `name={dynamicString}` keeps compiling without casts.
|
|
121
|
+
Try the components without writing an app: `npm run storybook` (this repo) serves the Storybook gallery — every bound component, rules, field arrays, devtools and the uncontrolled mode are live-editable there. The [docs site](https://wmzy.github.io/react-f0rm/) carries the full guides.
|
|
1057
122
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
```tsx
|
|
1061
|
-
import {useFormContext, useValue} from 'react-f0rm';
|
|
1062
|
-
|
|
1063
|
-
function EmailError() {
|
|
1064
|
-
const form = useFormContext<Values>();
|
|
1065
|
-
const email = useValue(form, 'email'); // string
|
|
1066
|
-
return email === '' ? <p>Email is required</p> : null;
|
|
1067
|
-
}
|
|
1068
|
-
```
|
|
1069
|
-
|
|
1070
|
-
And a `Field` tied to a typed form infers its `validate` argument from the path — `PathValueOf<Values, P>` — via the `form` prop (a plain `string` name keeps the old permissive `any`, matching `useField`):
|
|
123
|
+
## Benchmarks
|
|
1071
124
|
|
|
1072
|
-
|
|
1073
|
-
const form = useForm<Values>();
|
|
125
|
+
tinybench, run on a desktop-class machine (AMD Ryzen 7 8745HS). Measured rme varies by run — the µs means wobble between runs and under load, so treat them as one significant figure (collected 2026-09: two render runs 130µs/138µs for f0rm vs 128µs/124µs for RHF `Controller` — the 100-field controlled row flip-flops inside noise).
|
|
1074
126
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
validate={value => (value === undefined ? 'Tell us something' : undefined)}
|
|
1086
|
-
/>
|
|
1087
|
-
</Form>
|
|
1088
|
-
```
|
|
127
|
+
| Scenario | react-f0rm | Baseline | Speedup |
|
|
128
|
+
|---|---|---|---|
|
|
129
|
+
| Change one of 100 controlled fields | 138µs/change (~7,500 ops/s) | RHF `Controller`: 124µs (~8,200 ops/s) | parity (± noise; the row flips run to run) |
|
|
130
|
+
| Change one of 100 controlled fields | 138µs/change (~7,500 ops/s) | TanStack `form.Field`: 335µs (~3,000 ops/s) | ~2.4× |
|
|
131
|
+
| Components re-rendered per change | 1 of 100 `Field`s | — | — |
|
|
132
|
+
| Change one of 100 uncontrolled fields | 12.5µs/change (~80,700 ops/s) | RHF `register`: 11.7µs (~86,200 ops/s) | parity (~7% apart) |
|
|
133
|
+
| `getValues()`, 100 fields × depth 3 (cold compute; DEV snapshot guard on both paths) | 55.9µs (ownership merge) | legacy chained `set`: 95.9µs | 1.7× |
|
|
134
|
+
| Change one of 1000 controlled fields | 0.652ms/change (~1,550 ops/s) | RHF `Controller`: 1.22ms (~840 ops/s) | ~1.9× |
|
|
135
|
+
| Async validation storm — burst of 3 changes × 50 debounced async validators, settled via `trigger` | 21.4ms/burst (~47 ops/s) | — | — |
|
|
136
|
+
| `await trigger(form)` — 100 mixed validators (50 sync + 50 async) settle | 5.33ms (~196 ops/s) | — | — |
|
|
1089
137
|
|
|
1090
|
-
|
|
138
|
+
Notes:
|
|
1091
139
|
|
|
1092
|
-
|
|
140
|
+
- The uncontrolled row is the apples-to-apples `register` comparison: react-f0rm's `uncontrolled: true` runs at RHF-`register` parity while keeping errors/touched/disabled/validating reactive, which raw `register` does not. The controlled comparison uses `Controller`, RHF's per-field-subscribed counterpart, and `form.Field` is TanStack's same-model counterpart.
|
|
141
|
+
- The 100-field controlled row is genuinely within noise of parity (two runs landed on opposite sides); the 1000-field row is the reliable separation — field-level subscriptions scale better than `Controller`'s per-change work.
|
|
1093
142
|
|
|
1094
|
-
|
|
1095
|
-
function TextArea({value, onChange, ...props}) {
|
|
1096
|
-
return <textarea {...props} value={value} onChange={e => onChange(e.target.value)} />;
|
|
1097
|
-
}
|
|
143
|
+
Reproduce with:
|
|
1098
144
|
|
|
1099
|
-
|
|
145
|
+
```sh
|
|
146
|
+
npx vitest bench --run test/bench/render.bench.ts test/bench/getValues.bench.ts
|
|
147
|
+
npx vitest bench --run test/bench/scale.bench.ts # the three scale scenarios above
|
|
1100
148
|
```
|
|
1101
149
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
```jsx
|
|
1105
|
-
import {Select} from 'react-f0rm';
|
|
150
|
+
## Comparison
|
|
1106
151
|
|
|
1107
|
-
|
|
1108
|
-
<option value="cn">China</option>
|
|
1109
|
-
<option value="jp">Japan</option>
|
|
1110
|
-
</Select>
|
|
152
|
+
react-f0rm vs React Hook Form, TanStack Form and Formik — rendering model, schema adapters, path typing, async validation, bundle size, ecosystem maturity — lives on the [Comparison page](https://wmzy.github.io/react-f0rm/comparison) of the docs site.
|
|
1111
153
|
|
|
1112
|
-
|
|
1113
|
-
<option value="a">Tag A</option>
|
|
1114
|
-
<option value="b">Tag B</option>
|
|
1115
|
-
</Select>
|
|
1116
|
-
```
|
|
154
|
+
Short version: **pick react-f0rm** for controlled components with true per-field subscriptions (design systems, editor-like forms), one Standard Schema adapter, compile-time-checked paths, `register`-style bindings when you want them, and a core at RHF's size — and you are comfortable with a young library. **Pick React Hook Form** for the mature ecosystem today (its performance edge is gone at the rendering level — see the bench notes). **TanStack Form** sits in between: the deepest possible type inference, at a larger core.
|
|
1117
155
|
|
|
1118
|
-
|
|
156
|
+
## Docs Map
|
|
1119
157
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
return <input type="file" accept="image/*" onChange={e => onChange(e.target.files?.[0])} />;
|
|
1124
|
-
}
|
|
1125
|
-
// getValues(form).avatar is now the File itself — on submit it is
|
|
1126
|
-
// ready for the request body (FormData/multipart), no DOM read needed.
|
|
1127
|
-
```
|
|
158
|
+
- Guides: [Validation](https://wmzy.github.io/react-f0rm/guides/validation) · [Field Arrays](https://wmzy.github.io/react-f0rm/guides/field-arrays) · [Submission](https://wmzy.github.io/react-f0rm/guides/submission) · [Sub-forms](https://wmzy.github.io/react-f0rm/guides/sub-forms) · [React 19 Server Actions](https://wmzy.github.io/react-f0rm/guides/react19-server-actions) · [SSR](https://wmzy.github.io/react-f0rm/guides/ssr) · [TypeScript](https://wmzy.github.io/react-f0rm/guides/typescript) · [Custom components](https://wmzy.github.io/react-f0rm/guides/custom-components) · [UI-kit integration](https://wmzy.github.io/react-f0rm/guides/ui-integration) · [Testing](https://wmzy.github.io/react-f0rm/guides/testing) · [Hooks Reference](https://wmzy.github.io/react-f0rm/guides/hooks-reference) · [Headless & React Native](https://wmzy.github.io/react-f0rm/guides/headless-react-native)
|
|
159
|
+
- Examples: [Basic](https://wmzy.github.io/react-f0rm/examples/basic) · [Dynamic fields](https://wmzy.github.io/react-f0rm/examples/dynamic) · [Real-world form](https://wmzy.github.io/react-f0rm/examples/real-world-form) (wizard + cross-field validation + server backfill)
|
|
160
|
+
- Migration: [from Formik](https://wmzy.github.io/react-f0rm/migration/from-formik) · [from React Hook Form](https://wmzy.github.io/react-f0rm/migration/from-react-hook-form) · [from TanStack Form](https://wmzy.github.io/react-f0rm/migration/from-tanstack-form) · [Breaking changes](https://wmzy.github.io/react-f0rm/migration/breaking-changes)
|
|
1128
161
|
|
|
1129
|
-
##
|
|
162
|
+
## Development
|
|
1130
163
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
164
|
+
```bash
|
|
165
|
+
npm test # 858 tests, vmThreads pool (~3s)
|
|
166
|
+
npm run test:watch # watch mode
|
|
167
|
+
npm run coverage # coverage report (95/90/95/95 thresholds enforced)
|
|
168
|
+
npx vitest bench --run test/bench/ # benchmarks
|
|
169
|
+
npm run bench:report # bench suite -> docs-site/docs/benchmarks.md
|
|
170
|
+
npm run build # production build (UMD + ESM + CJS)
|
|
171
|
+
npm run lint # ESLint
|
|
172
|
+
npm run storybook # Storybook dev server
|
|
173
|
+
npm run docs:build # Docusaurus build (docs-site/)
|
|
1138
174
|
```
|
|
1139
175
|
|
|
1140
|
-
##
|
|
1141
|
-
|
|
1142
|
-
Coming from another library? [Migrating from TanStack Form](./docs/from-tanstack-form.md) is the repo-level concept map — core mapping table, known differences, common pitfalls. Step-by-step migration guides live in the docs site:
|
|
1143
|
-
|
|
1144
|
-
- [Migrating from Formik](docs-site/docs/migration/from-formik.md)
|
|
1145
|
-
- [Migrating from React Hook Form](docs-site/docs/migration/from-react-hook-form.md)
|
|
1146
|
-
- [Migrating from TanStack Form](docs-site/docs/migration/from-tanstack-form.md)
|
|
1147
|
-
|
|
1148
|
-
## Breaking changes in 0.2
|
|
176
|
+
## License
|
|
1149
177
|
|
|
1150
|
-
|
|
178
|
+
ISC
|