react-f0rm 0.2.2 → 0.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.
Files changed (45) hide show
  1. package/README.md +573 -33
  2. package/dist/devtools/index.cjs.js +737 -0
  3. package/dist/devtools/index.cjs.js.map +1 -0
  4. package/dist/devtools/index.d.ts +33 -0
  5. package/dist/devtools/index.mjs +717 -0
  6. package/dist/devtools/index.mjs.map +1 -0
  7. package/dist/form-61297bc0.d.ts +578 -0
  8. package/dist/form-94c70b4b.mjs +378 -0
  9. package/dist/form-94c70b4b.mjs.map +1 -0
  10. package/dist/form-b9441d8c.cjs.js +387 -0
  11. package/dist/form-b9441d8c.cjs.js.map +1 -0
  12. package/dist/index.cjs.js +1265 -157
  13. package/dist/index.cjs.js.map +1 -1
  14. package/dist/index.d.ts +419 -59
  15. package/dist/index.mjs +1684 -0
  16. package/dist/index.mjs.map +1 -0
  17. package/dist/index.umd.js +1265 -157
  18. package/dist/index.umd.js.map +1 -1
  19. package/dist/index.umd.min.js +2 -2
  20. package/dist/index.umd.min.js.map +1 -1
  21. package/dist/resolvers/standard-schema.cjs.js +90 -0
  22. package/dist/resolvers/standard-schema.cjs.js.map +1 -0
  23. package/dist/resolvers/standard-schema.d.ts +66 -0
  24. package/dist/resolvers/standard-schema.mjs +86 -0
  25. package/dist/resolvers/standard-schema.mjs.map +1 -0
  26. package/dist/resolvers/yup.cjs.js +12 -2
  27. package/dist/resolvers/yup.cjs.js.map +1 -1
  28. package/dist/resolvers/yup.d.ts +2 -2
  29. package/dist/resolvers/yup.mjs +23 -0
  30. package/dist/resolvers/yup.mjs.map +1 -0
  31. package/dist/resolvers/zod.cjs.js +14 -1
  32. package/dist/resolvers/zod.cjs.js.map +1 -1
  33. package/dist/resolvers/zod.d.ts +2 -2
  34. package/dist/resolvers/zod.mjs +23 -0
  35. package/dist/resolvers/zod.mjs.map +1 -0
  36. package/dist/validate-148fe167.d.ts +22 -0
  37. package/package.json +34 -8
  38. package/dist/form-d06e6444.d.ts +0 -201
  39. package/dist/index.esm.js +0 -593
  40. package/dist/index.esm.js.map +0 -1
  41. package/dist/resolvers/yup.esm.js +0 -13
  42. package/dist/resolvers/yup.esm.js.map +0 -1
  43. package/dist/resolvers/zod.esm.js +0 -10
  44. package/dist/resolvers/zod.esm.js.map +0 -1
  45. package/dist/validate-0f17f86a.d.ts +0 -8
package/README.md CHANGED
@@ -1,10 +1,26 @@
1
1
  # react-f0rm
2
2
 
3
+ [![CI](https://github.com/wmzy/react-f0rm/actions/workflows/ci.yml/badge.svg)](https://github.com/wmzy/react-f0rm/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/react-f0rm)](https://www.npmjs.com/package/react-f0rm)
5
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/react-f0rm)](https://bundlephobia.com/package/react-f0rm)
6
+ [![License: ISC](https://img.shields.io/npm/l/react-f0rm)](https://opensource.org/licenses/ISC)
7
+
8
+ A headless, event-driven React form library with field-level subscriptions.
9
+
3
10
  ## Features
4
11
 
5
- - Drive by event.
6
- - Refined tree-shaking support. Needn't have to pay for features not used.
7
- - Theoretically more efficient(need a benchmark).
12
+ - **Field-level subscriptions.** Editing one field re-renders exactly that field's component, not the whole form. State is read through `useSyncExternalStore`, so snapshots stay consistent under concurrent rendering (no tearing).
13
+ - **Truly type-safe paths.** `FieldPath<T>` enumerates every valid field name for your values shape and `PathValue<T, P>` resolves the value type at that path — typos in field names fail at compile time, values are inferred.
14
+ - **One schema adapter for every library.** The Standard Schema resolver covers zod (v3.24+/v4), valibot v1, arktype and any other Standard Schema v1 implementation through a single tree-shakeable entry point.
15
+ - **Headless, with accessibility hooks.** You own the markup. When you opt into error rendering via `renderError`, `aria-invalid` and `aria-describedby` are wired up automatically.
16
+ - **Tombstone unregister.** Unmounted fields drop out of `getValues()` instead of silently reviving their initial values on the next read.
17
+ - **Copy-on-write `getValues()`.** An ownership-tracked merge allocates each container once per read instead of re-copying whole branches for every key.
18
+ - **Multiple errors per field.** Each field stores an ordered `FieldError[]` — `getFieldErrors`/`useFieldErrors` read them all, and schema resolvers forward every issue instead of stopping at the first.
19
+ - **Async validation with cancellation.** `validateDebounce` per field plus an `AbortSignal` handed to every validator: a superseded round aborts its in-flight fetch, and pending debounce windows count as validating so submit waits them out.
20
+ - **Precise lifecycle control.** `reset(form, values, {keepDirtyValues, …})` covers refetch-without-clobbering-dirty-drafts, `setFocus(form, name)` focuses programmatically, and `trigger(form, name?)` resolves `Promise<boolean>` once validation settles.
21
+ - **Typed, nestable form contexts.** `createFormContext<Values>()` gives each app area an isolated provider whose `useField`/`useFieldArray` take `FieldPath<Values>` names without hand-written generics.
22
+ - **SSR out of the box.** `renderToString` renders initial values and the server snapshot matches the client's first render, so hydration is consistent.
23
+ - Event-driven core with refined tree-shaking — you don't pay for features you don't use.
8
24
 
9
25
  ## Install
10
26
 
@@ -16,6 +32,57 @@ or
16
32
  yarn add react-f0rm
17
33
  ```
18
34
 
35
+ ## Benchmarks
36
+
37
+ tinybench; relative margin of error ≤ 0.9% for the first three scenarios, ≤ 2.5% for the scale scenarios.
38
+
39
+ | Scenario | react-f0rm | Baseline | Speedup |
40
+ |---|---|---|---|
41
+ | Change one of 100 controlled fields | 113µs/change (~8,880 ops/s) | RHF `Controller`: 200µs (~5,000 ops/s) | ~1.8× |
42
+ | Components re-rendered per change | 1 of 100 `Field`s | — | — |
43
+ | `getValues()`, 100 fields × depth 3 | 42.5µs (ownership merge) | legacy chained `set`: 93.0µs | 2.19× |
44
+ | Change one of 1000 controlled fields | 0.418ms/change (~2,390 ops/s, rme ±1.1%) | RHF `Controller`: 1.662ms (~602 ops/s) | 4.0× |
45
+ | Async validation storm — burst of 3 changes × 50 debounced async validators, settled via `trigger` | 20.5ms/burst (~49 ops/s, rme ±2.4%) | — | — |
46
+ | `await trigger(form)` — 100 mixed validators (50 sync + 50 async) settle | 1.48ms (~676 ops/s, rme ±0.9%) | — | — |
47
+
48
+ Notes:
49
+
50
+ - For reference, RHF's uncontrolled `register` — which has no per-field re-render at all — floors at 21µs/change; the controlled comparison above uses `Controller`, the fair apples-to-apples baseline.
51
+ - In the `getValues()` benchmark, ownership merging also cut container allocations from 300 to 111.
52
+
53
+ Reproduce with:
54
+
55
+ ```sh
56
+ npx vitest bench --run test/bench/render.bench.ts test/bench/getValues.bench.ts
57
+ npx vitest bench --run test/bench/scale.bench.ts # the three scale scenarios above
58
+ ```
59
+
60
+ ## Comparison
61
+
62
+ 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.
63
+
64
+ | | react-f0rm | React Hook Form | TanStack Form | Formik |
65
+ |---|---|---|---|---|
66
+ | Rendering model | Controlled fields with field-level subscriptions (`useSyncExternalStore`): editing one of 100 fields re-renders exactly 1 component | 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 |
67
+ | Unregister on unmount | Unregisters by default — an unmounted field drops out of `getValues()` (tombstone) instead of silently reviving its initial value; `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` |
68
+ | 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` |
69
+ | Path type safety | `FieldPath<T>` / `PathValue<T, P>`: every valid path enumerated, value type resolved, typos fail at compile time | `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 |
70
+ | 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 |
71
+ | 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 | — |
72
+ | SSR / hydration | `renderToString` renders initial values out of the box; server snapshot matches the client's first render | SSR-safe | SSR-safe | SSR-safe |
73
+ | React 19 / Server Actions | Bridge pattern: dispatch the action from `onValidSubmit` via `startTransition`/`useActionState`, passing the values object rather than FormData (see the React 19 Server Actions guide); no submit before JS loads | `<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) | — |
74
+ | Bundle size | 11.18 KB gzip (7.1 KB brotli, minified), full core | ~11 KB gzip | ~17.5 KB gzip | ~12.8 KB gzip |
75
+ | 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) |
76
+ | 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 |
77
+
78
+ 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` (minified, the same file gzips to ~7.88 KB; 7.1 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.
79
+
80
+ ### Which one should you use?
81
+
82
+ **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.18 KB gzip / 7.1 KB brotli) — and you are comfortable with a young 0.x library.
83
+
84
+ **Pick React Hook Form** when uncontrolled inputs are an option: its raw `register` performs no per-field re-render at all and floors at 21µs/change vs our 113µs (see [Benchmarks](#benchmarks)) — uncontrolled is simply a cheaper rendering model. RHF is also the right call when you need its mature ecosystem of resolvers, UI-library integrations and community answers today. TanStack Form sits in between: choose it when the deepest possible type inference (including validator signatures) matters more to you than bundle size.
85
+
19
86
  ## Usage
20
87
 
21
88
  ```jsx
@@ -46,16 +113,52 @@ For full control over field rendering:
46
113
  import {useField} from 'react-f0rm';
47
114
 
48
115
  function CustomField({name}) {
49
- const {value, onChange, onBlur, error} = useField({name});
116
+ const {value, onChange, onBlur, error, errorObject, errors} = useField({name});
50
117
  return (
51
118
  <div>
52
119
  <input value={value} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
53
- {error && <span>{error}</span>}
120
+ {error && <span role="alert">{error}</span>}
54
121
  </div>
55
122
  );
56
123
  }
57
124
  ```
58
125
 
126
+ `error` is the error's message string (or `undefined`); `errorObject` is the full structured error `{type, message}`. `errors` is every error registered for the field (`FieldError[]`, insertion order) — `error`/`errorObject` are its first entry (see [Multiple errors per field](#multiple-errors-per-field)). Pass an explicit `form` to use the hook outside a `<Form>`:
127
+
128
+ ```jsx
129
+ const form = useForm({initialValues: {email: ''}});
130
+ const {value, onChange} = useField({form, name: 'email'});
131
+ ```
132
+
133
+ ### `subscribe`
134
+
135
+ 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:
136
+
137
+ ```jsx
138
+ import {createForm, subscribe, getValue, setValue} from 'react-f0rm';
139
+
140
+ const form = createForm({initialValues: {province: '', city: ''}});
141
+
142
+ const unsubscribe = subscribe(form, {
143
+ name: 'province',
144
+ callback: () => {
145
+ // Read fresh state through the getters inside the callback.
146
+ if (getValue(form, 'city')) setValue(form, 'city', '');
147
+ }
148
+ });
149
+ ```
150
+
151
+ | Option | Type | Default |
152
+ |---|---|---|
153
+ | `name` | field path, or an array of them | omitted — every emission of `event`, payload-less broadcasts (reset, …) included |
154
+ | `event` | `'change'` \| `'errors'` \| `'touched'` \| `'submitting'` \| `'submitCount'` | `'change'` |
155
+ | `scope` | `'leaf'` \| `'branch'` | `'branch'` |
156
+ | `callback` | `() => void`, fired with no arguments | required |
157
+
158
+ 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. `'errors'` and `'touched'` always match the exact key — another field's error never wakes this subscriber. `'submitting'`/`'submitCount'` 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.
159
+
160
+ **`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.
161
+
59
162
  ### `useFieldArray`
60
163
 
61
164
  Manage dynamic lists of fields:
@@ -79,6 +182,87 @@ function Tags() {
79
182
  }
80
183
  ```
81
184
 
185
+ The array only re-renders for changes touching its own branch — typing into unrelated fields does not re-render it.
186
+
187
+ Besides the movers (`append`, `prepend`, `insert`, `remove`, `swap`, `move`), two bulk operations are available:
188
+
189
+ ```jsx
190
+ const {fields, replace, update} = useFieldArray({name: 'tags'});
191
+
192
+ replace(['a', 'b', 'c']); // full swap: every row id is regenerated (length may change)
193
+ update(1, 'B'); // overwrite one value, keeping that row's id — no key churn
194
+ ```
195
+
196
+ `replace(values)` is the refetch shape — a server response replaces the whole list — while `update(index, value)` rewrites a single row in place.
197
+
198
+ ### `createFormContext`
199
+
200
+ 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:
201
+
202
+ ```tsx
203
+ import {createFormContext} from 'react-f0rm';
204
+
205
+ interface Values {
206
+ name: string;
207
+ email: string;
208
+ }
209
+
210
+ const ProfileForm = createFormContext<Values>();
211
+
212
+ function NameField() {
213
+ // name is constrained to FieldPath<Values>; value is inferred as string
214
+ const {value, onChange} = ProfileForm.useField({name: 'name'});
215
+ return <input value={value} onChange={e => onChange(e.target.value)} />;
216
+ }
217
+ ```
218
+
219
+ Each call returns `{context, FormProvider, useFormContext, useField, useFieldArray}` 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.
220
+
221
+ 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:
222
+
223
+ ```tsx
224
+ const ProfileForm = createFormContext<Values>();
225
+
226
+ <Form context={ProfileForm.context} form={form} onValidSubmit={save}>
227
+ <NameField /> {/* ProfileForm.useField resolves the form <Form> manages */}
228
+ <button type="submit">Save</button>
229
+ </Form>
230
+ ```
231
+
232
+ The module-level `useFormContext()`/`useField` do not see that form — that is the isolation working.
233
+
234
+ ## Controlled Forms
235
+
236
+ Pass a `values` prop to `<Form>` (or `values` to `useForm`) to drive the form from outside:
237
+
238
+ ```jsx
239
+ <Form
240
+ values={selectedRecord}
241
+ onValidSubmit={values => save(values)}
242
+ >
243
+ <Field name="email" />
244
+ </Form>
245
+ ```
246
+
247
+ 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.
248
+
249
+ ## Disabled
250
+
251
+ Disable a whole form — during submission, while a record loads, or for read-only views:
252
+
253
+ ```jsx
254
+ const form = useForm({disabled: isReadOnly});
255
+
256
+ // or toggle at runtime — every bound field re-renders:
257
+ setDisabled(form, true);
258
+ ```
259
+
260
+ 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:
261
+
262
+ ```jsx
263
+ const {disabled, value, onChange} = useField({name: 'email'});
264
+ ```
265
+
82
266
  ## Submit Handlers
83
267
 
84
268
  ```jsx
@@ -90,6 +274,7 @@ function Tags() {
90
274
  }}
91
275
  onInvalidSubmit={(errors, values) => {
92
276
  // Called when validation fails
277
+ // errors: [{path: 'email', type: 'custom', message: 'Invalid email'}]
93
278
  console.error(errors);
94
279
  }}
95
280
  >
@@ -98,11 +283,151 @@ function Tags() {
98
283
  </Form>
99
284
  ```
100
285
 
286
+ `onSubmit`/`onValidSubmit` only run once both native constraint validation (see [Accessibility](#accessibility)) and your custom validators pass.
287
+
288
+ ### `handleSubmit`
289
+
290
+ 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, …):
291
+
292
+ ```jsx
293
+ import {useForm, handleSubmit} from 'react-f0rm';
294
+
295
+ function Profile({onSave}) {
296
+ const form = useForm({initialValues: {email: ''}});
297
+ const submit = handleSubmit(form, {
298
+ onSubmit: values => onSave(values), // runs first on success
299
+ onValidSubmit: values => console.log(values), // then this
300
+ onInvalidSubmit: (errors, values) => console.error(errors)
301
+ });
302
+ return <Button title="Save" onPress={submit} />;
303
+ }
304
+ ```
305
+
306
+ 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.
307
+
308
+ `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.
309
+
310
+ ### Focusing the first error
311
+
312
+ 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.
313
+
314
+ The same channel is exposed as an imperative API:
315
+
316
+ ```jsx
317
+ import {setFocus} from 'react-f0rm';
318
+
319
+ setFocus(form, 'email'); // focus the bound field's element
320
+ setFocus(form, 'user.name', {shouldSelect: true}); // focus and select its text
321
+ ```
322
+
323
+ `setFocus` rides the `'focusError'` event, so it is a silent no-op when the field is unmounted or nothing subscribes — unknown names never throw.
324
+
101
325
  ## Validation
102
326
 
327
+ ### Validation modes
328
+
329
+ `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:
330
+
331
+ | Option | Values | Default |
332
+ |---|---|---|
333
+ | `mode` | `'onSubmit'` \| `'onBlur'` \| `'onChange'` \| `'onTouched'` \| `'all'` | `'onSubmit'` |
334
+ | `reValidateMode` | `'onChange'` \| `'onBlur'` \| `'onSubmit'` | `'onChange'` |
335
+
336
+ - `'onSubmit'` — validate only on submit.
337
+ - `'onBlur'` — validate when the field loses focus.
338
+ - `'onChange'` — validate on every change.
339
+ - `'onTouched'` — validate on the first blur, then on every change.
340
+ - `'all'` — validate on both change and blur.
341
+
342
+ ```jsx
343
+ import {createForm} from 'react-f0rm';
344
+
345
+ const form = createForm({
346
+ initialValues: {email: ''},
347
+ mode: 'onBlur', // validate on blur…
348
+ reValidateMode: 'onChange' // …then re-validate on every change once errored
349
+ });
350
+ ```
351
+
352
+ ### Triggering validation manually
353
+
354
+ `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:
355
+
356
+ ```jsx
357
+ import {trigger} from 'react-f0rm';
358
+
359
+ trigger(form); // every registered field validator
360
+ trigger(form, 'email'); // one field
361
+ trigger(form, ['user.name', 'user.email']); // several
362
+ ```
363
+
364
+ `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:
365
+
366
+ ```jsx
367
+ if (await trigger(form, 'email')) {
368
+ proceed(); // 'email' is now guaranteed error-free
369
+ }
370
+ ```
371
+
372
+ 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.
373
+
374
+ ### Async validation
375
+
376
+ Async validators are first-class. Two knobs keep them cheap and race-free:
377
+
378
+ **`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.
379
+
380
+ **`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:
381
+
382
+ ```jsx
383
+ <Field
384
+ name="email"
385
+ validateDebounce={300}
386
+ validate={async (value, {signal}) => {
387
+ const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`, {signal});
388
+ const {taken} = await res.json();
389
+ if (taken) return {type: 'taken', message: 'Email already registered'};
390
+ }}
391
+ />
392
+ ```
393
+
394
+ 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.
395
+
396
+ ### Multiple errors per field
397
+
398
+ 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)`:
399
+
400
+ ```jsx
401
+ import {getFieldErrors, useFieldErrors, setError} from 'react-f0rm';
402
+
403
+ const all = getFieldErrors(form, 'password');
404
+ // [{type: 'min', message: 'Too short'}, {type: 'pattern', message: 'Needs a digit'}]
405
+
406
+ setError(form, 'password', [
407
+ {type: 'min', message: 'Too short'},
408
+ {type: 'pattern', message: 'Needs a digit'}
409
+ ]);
410
+ ```
411
+
412
+ `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.
413
+
414
+ ### `setValue` options
415
+
416
+ The fourth argument to `setValue` opts into side effects. Every flag defaults to `false`; omitting the object keeps the plain set-value behavior:
417
+
418
+ ```jsx
419
+ import {setValue} from 'react-f0rm';
420
+
421
+ setValue(form, 'email', 'a@b.com', {
422
+ shouldValidate: true, // run the field's registered validator after the value lands
423
+ shouldTouch: true, // mark the field as touched
424
+ shouldDirty: true // reserved for a manual dirty marker — currently a no-op
425
+ });
426
+ ```
427
+
103
428
  ### Field-level validation
104
429
 
105
- Pass a `validate` function to `Field` or `useField`. Return an error string or `undefined`:
430
+ Pass a `validate` function to `Field` or `useField`. Return an error string, a `FieldError` object or `undefined` — sync or async:
106
431
 
107
432
  ```jsx
108
433
  <Field
@@ -113,9 +438,44 @@ Pass a `validate` function to `Field` or `useField`. Return an error string or `
113
438
  />
114
439
  ```
115
440
 
441
+ ### Rules
442
+
443
+ 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:
444
+
445
+ ```jsx
446
+ <Field
447
+ name="age"
448
+ rules={{
449
+ required: 'Age is required',
450
+ min: 18,
451
+ messages: {min: 'Must be an adult'}
452
+ }}
453
+ />
454
+ ```
455
+
456
+ | Rule | Value | Fails when | Default message |
457
+ |---|---|---|---|
458
+ | `required` | `string \| true` | value is `''`, `undefined` or `null` (`0` and `false` count as filled) | `'This field is required'` |
459
+ | `min` | `number` | `Number(value) < min` — values converting to `NaN` skip the rule | `` `Must be at least ${min}` `` |
460
+ | `max` | `number` | `Number(value) > max` — `NaN` skips | `` `Must be at most ${max}` `` |
461
+ | `minLength` | `number` | a string value is shorter — non-strings skip | `` `Must be at least ${n} characters` `` |
462
+ | `maxLength` | `number` | a string value is longer — non-strings skip | `` `Must be at most ${n} characters` `` |
463
+ | `pattern` | `{value: RegExp, message: string}` | `pattern.value.test(value)` is false | the given `message` |
464
+
465
+ The optional top-level `messages` record overrides messages per rule type (`min`, `max`, `minLength`, `maxLength`, `pattern`) — useful for centralizing or localizing them.
466
+
467
+ Semantics:
468
+
469
+ - A failing `required` short-circuits the rest — an empty value reports only its `required` error, not a full panel.
470
+ - Every other failing rule collects into one ordered `FieldError[]` (see [Multiple errors per field](#multiple-errors-per-field)).
471
+ - `rules` composes with `validate`: rules run first, then `validate` (awaited when async), merging both sources' errors with rules ahead.
472
+ - Rules ride the exact same pipeline as `validate` — `mode`, `reValidateMode`, `validateDebounce` and `meta.signal` all apply unchanged.
473
+
474
+ 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.
475
+
116
476
  ### Form-level validation
117
477
 
118
- Pass a `validate` function to `createForm`. It receives all values and returns an object of errors:
478
+ 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:
119
479
 
120
480
  ```jsx
121
481
  import {createForm} from 'react-f0rm';
@@ -123,15 +483,193 @@ import {createForm} from 'react-f0rm';
123
483
  const form = createForm({
124
484
  initialValues: {password: '', confirm: ''},
125
485
  validate: values => {
126
- const errors = {};
127
486
  if (values.password !== values.confirm) {
128
- errors.confirm = 'Passwords do not match';
487
+ return {confirm: 'Passwords do not match'};
129
488
  }
130
- return errors;
131
489
  },
132
490
  });
133
491
  ```
134
492
 
493
+ ### Schema validation
494
+
495
+ 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:
496
+
497
+ ```jsx
498
+ import {Form, Field, createForm} from 'react-f0rm';
499
+ import {
500
+ standardSchemaFormValidator,
501
+ standardSchemaResolver
502
+ } from 'react-f0rm/resolvers/standard-schema';
503
+ import {z} from 'zod';
504
+
505
+ const schema = z.object({
506
+ email: z.string().email(),
507
+ password: z.string().min(8)
508
+ });
509
+
510
+ // Form-level: validate the whole values object; issue paths map to
511
+ // field errors automatically, issues without a path land on `_form`
512
+ const form = createForm({
513
+ validate: standardSchemaFormValidator(schema)
514
+ });
515
+
516
+ // Field-level: validate a single value
517
+ <Field name="email" validate={standardSchemaResolver(z.string().email())} />
518
+ ```
519
+
520
+ Schema errors come back as `{type: 'standard', message}`.
521
+
522
+ 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.
523
+
524
+ ### Delaying error display
525
+
526
+ `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:
527
+
528
+ ```jsx
529
+ <Field name="username" rules={{minLength: 3}} delayError={300} />
530
+ ```
531
+
532
+ 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.
533
+
534
+ ## Dirty & Touched Fields
535
+
536
+ ```jsx
537
+ import {useDirtyFields, useTouchedFields} from 'react-f0rm';
538
+
539
+ function FormStatus({form}) {
540
+ const dirtyFields = useDirtyFields(form); // {'user.name': true, 'tags.0': true}
541
+ const touched = useTouchedFields(form); // ['user.name', 'tags.0']
542
+ return (
543
+ <p>{Object.keys(dirtyFields).length} dirty, {touched.length} touched</p>
544
+ );
545
+ }
546
+ ```
547
+
548
+ 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.
549
+
550
+ ### Resetting
551
+
552
+ `reset(form, initialValues?)` wipes values, errors, touched, tombstones and the submission flags (`isSubmitting`, `submitCount`, `isSubmitSuccessful`). The second argument installs a fresh baseline. The third opts into keeping slices of state through the reset:
553
+
554
+ ```jsx
555
+ import {reset} from 'react-f0rm';
556
+
557
+ reset(form, freshRecord); // full reset to the new baseline
558
+ reset(form, freshRecord, {keepDirtyValues: true}); // dirty drafts survive
559
+ ```
560
+
561
+ `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):
562
+
563
+ ```jsx
564
+ const {data} = useQuery(['user', id], () => fetchUser(id));
565
+ // data changed (refetch, different user) — replace the draft,
566
+ // but never clobber fields the user is mid-edit on
567
+ useEffect(() => {
568
+ if (data) reset(form, data, {keepDirtyValues: true});
569
+ }, [data]);
570
+ ```
571
+
572
+ The other flags — `keepTouched`, `keepErrors`, `keepIsSubmitted`, `keepSubmitCount`, `keepIsSubmitting` — all default to `false`; omitting the object keeps the plain full-reset behavior.
573
+
574
+ ### Resetting a single field
575
+
576
+ `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:
577
+
578
+ ```jsx
579
+ import {resetField, getFieldState} from 'react-f0rm';
580
+
581
+ resetField(form, 'email'); // back to initialValues
582
+ resetField(form, 'email', {keepTouched: true}); // keep the touched flag
583
+ resetField(form, 'email', {value: ''}); // explicit value, no fallback
584
+ ```
585
+
586
+ | Option | Default | Effect |
587
+ | ------------- | ------- | ------------------------------------------------------------- |
588
+ | `keepTouched` | `false` | Keep the field's touched flag |
589
+ | `keepErrors` | `false` | Keep the field's errors |
590
+ | `value` | — | Explicit post-reset value; never falls back to `initialValues` |
591
+
592
+ 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:
593
+
594
+ ```jsx
595
+ const {value, error, isDirty} = getFieldState(form, 'email');
596
+ ```
597
+
598
+ ## Accessibility
599
+
600
+ `Field` sets `aria-invalid` on the input whenever it has an error. Provide a `renderError(error, id)` function to render the message, and `Field` wraps it in `<span id={id} role="alert">` next to the input and points the input's `aria-describedby` at it:
601
+
602
+ ```jsx
603
+ <Field
604
+ name="email"
605
+ renderError={error => <span className="field-error">{error}</span>}
606
+ />
607
+ ```
608
+
609
+ Without `renderError`, no extra element is rendered and no `aria-describedby` is attached — the headless default stays clean.
610
+
611
+ 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()`.
612
+
613
+ ## TypeScript
614
+
615
+ `FieldPath<T>` and `PathValue<T, P>` make field names and value types compile-time checked:
616
+
617
+ ```tsx
618
+ import {FieldPath, PathValue, useField} from 'react-f0rm';
619
+
620
+ interface Values {
621
+ user: {name: string};
622
+ tags: string[];
623
+ }
624
+
625
+ // 'user' | 'user.name' | 'tags' | `tags.0` | `tags[0]` | ...
626
+ type ValuesPath = FieldPath<Values>;
627
+
628
+ // string
629
+ type UserName = PathValue<Values, 'user.name'>;
630
+
631
+ function UserNameField() {
632
+ // value is inferred as string; an unknown path degrades to `any`
633
+ // (PathValueOf), keeping dynamic names usable
634
+ const {value, onChange} = useField<Values, 'user.name'>({name: 'user.name'});
635
+ return <input value={value} onChange={e => onChange(e.target.value)} />;
636
+ }
637
+ ```
638
+
639
+ The same generics work on `getValue`/`setValue`/`getError` and the other path-taking helpers.
640
+
641
+ The default context is typed too — `useFormContext<Values>()` returns a `Form<Values>`, so downstream components drop the `any` dances (`eslint-disable no-unsafe-*`, value casts) without buying into `createFormContext`:
642
+
643
+ ```tsx
644
+ import {useFormContext, useValue} from 'react-f0rm';
645
+
646
+ function EmailError() {
647
+ const form = useFormContext<Values>();
648
+ const email = useValue(form, 'email'); // string
649
+ return email === '' ? <p>Email is required</p> : null;
650
+ }
651
+ ```
652
+
653
+ 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`):
654
+
655
+ ```tsx
656
+ const form = useForm<Values>();
657
+
658
+ <Form form={form} initialValues={{email: ''}}>
659
+ <Field
660
+ form={form}
661
+ name="email"
662
+ validate={value => (value.includes('@') ? undefined : 'Invalid email')}
663
+ />
664
+ {/* nested paths resolve through the shape: string | undefined */}
665
+ <Field
666
+ form={form}
667
+ name="user.bio"
668
+ validate={value => (value === undefined ? 'Tell us something' : undefined)}
669
+ />
670
+ </Form>
671
+ ```
672
+
135
673
  ## Custom Components
136
674
 
137
675
  Use the `as` prop to render a custom component instead of `<input>`:
@@ -144,31 +682,33 @@ function TextArea({value, onChange, ...props}) {
144
682
  <Field name="bio" as={TextArea} />
145
683
  ```
146
684
 
147
- ## TypeScript
685
+ `Select` is a controlled `<select>` — pass the options as children. A single select stores the selected option's value as a string; `multiple` stores the values of all selected options as a string array:
148
686
 
149
- ```tsx
150
- import {Form, Field, useForm, createForm} from 'react-f0rm';
687
+ ```jsx
688
+ import {Select} from 'react-f0rm';
151
689
 
152
- interface UserForm {
153
- name: string;
154
- email: string;
155
- }
690
+ <Select name="country">
691
+ <option value="cn">China</option>
692
+ <option value="jp">Japan</option>
693
+ </Select>
156
694
 
157
- const form = createForm<UserForm>();
695
+ <Select name="tags" multiple>
696
+ <option value="a">Tag A</option>
697
+ <option value="b">Tag B</option>
698
+ </Select>
699
+ ```
158
700
 
159
- function MyForm() {
160
- return (
161
- <Form<UserForm>
162
- form={form}
163
- onValidSubmit={values => {
164
- // values is typed as UserForm
165
- console.log(values.name);
166
- }}
167
- >
168
- <Field name="name" />
169
- <Field name="email" />
170
- <button>Submit</button>
171
- </Form>
172
- );
173
- }
701
+ ## Server-side Rendering
702
+
703
+ Form state lives in synchronously readable structures seeded from `initialValues`, and every subscription goes through `useSyncExternalStore` with a `getServerSnapshot` that computes the same snapshot as the client's first render. `renderToString` therefore renders form-driven components with their initial values out of the box, and `hydrateRoot` matches the server markup — no provider shims, no `typeof window` guards:
704
+
705
+ ```jsx
706
+ import {renderToString} from 'react-dom/server';
707
+
708
+ // renders <input value="ada"> — then hydrates on the client without mismatches
709
+ const html = renderToString(<ProfileForm initialValues={{name: 'ada', city: 'london'}} />);
174
710
  ```
711
+
712
+ ## Breaking changes in 0.2
713
+
714
+ v0.2 structures the error model (`FieldError`), changes unregister/reset/native-validation semantics, and more — see the [v0.1 → v0.2 migration guide](docs-site/docs/migration/v0.1-to-v0.2.md).