formstand 0.2.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/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +414 -0
- package/dist/index.cjs +1418 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +330 -0
- package/dist/index.d.ts +330 -0
- package/dist/index.js +1386 -0
- package/dist/index.js.map +1 -0
- package/package.json +89 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-07-02
|
|
4
|
+
|
|
5
|
+
The result of a full-repo review pass (see `TODO.md` items 1–34).
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- The package is named **formstand** (`zustand-forms` is taken on npm) —
|
|
10
|
+
update imports accordingly.
|
|
11
|
+
- Errors are split into two stored channels: `FormState.schemaErrors`
|
|
12
|
+
(validation-owned, rebuilt every pass) and `FormState.serverErrors`
|
|
13
|
+
(app-owned via `setError`/`setErrors`, invisible to validation).
|
|
14
|
+
`FormState.errors` remains the map hooks read but is now derived from the
|
|
15
|
+
channels (schema wins at a key, server shows where the schema is silent) —
|
|
16
|
+
patch the channels through `updateState` (its patch type omits `errors`),
|
|
17
|
+
not `errors`. Consequences: `setErrors` replaces only the server channel
|
|
18
|
+
(schema errors persist until the next pass), and `restore` re-derives the
|
|
19
|
+
merged map from the snapshot's channels — snapshots persisted under an
|
|
20
|
+
older state shape lose their error state on restore.
|
|
21
|
+
- `FormState.dirty` is removed; dirtiness is derived from `values` vs
|
|
22
|
+
`initialValues` everywhere (`useField().dirty`, `useIsDirty`,
|
|
23
|
+
`dirtyFields()`, `diff()`).
|
|
24
|
+
- `submit` resolves a discriminated `SubmitResult` — `{ kind: "valid", data }`,
|
|
25
|
+
`{ kind: "invalid", errors }`, or `{ kind: "skipped" }` — instead of a
|
|
26
|
+
ran/skipped boolean.
|
|
27
|
+
- The imperative write surface is typed: `setValue`, `setTouched`, `setError`,
|
|
28
|
+
`clearErrors`, `validateField(s)`, and the array ops take `FieldPath`-typed
|
|
29
|
+
paths (and value types are checked). Runtime-built path strings need a cast.
|
|
30
|
+
- Sync `validate` / `validateField` / `validateFields` no longer throw on
|
|
31
|
+
async schemas; they start the async pass and return a
|
|
32
|
+
`{ kind: "pending", promise }` result (`validateFields` returns the
|
|
33
|
+
`Promise<boolean>` itself).
|
|
34
|
+
- Whole-form async validation state moved from the `"__form__"` key in
|
|
35
|
+
`isValidating` to a dedicated `FormState.isValidatingForm` boolean.
|
|
36
|
+
- `useFormState` / `useFormStateShallow` are renamed to `useFormSelector` /
|
|
37
|
+
`useFormSelectorShallow` (old names remain as deprecated aliases).
|
|
38
|
+
- `SelectProps` is no longer generic; `NumberFieldProps` drops the unused
|
|
39
|
+
`step` prop; `UseFieldReturn` gains a `path` property.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Array ops and `setValues` now update the dirty map (`useIsDirty`, `diff()`,
|
|
44
|
+
`dirtyFields()` were blind to them).
|
|
45
|
+
- Field-level validation parses just the field's subschema when possible (no
|
|
46
|
+
more firing every async refine in the form on unrelated keystrokes) and
|
|
47
|
+
writes/clears errors by path prefix.
|
|
48
|
+
- Manual/server errors set via `setError` survive full-form validation passes
|
|
49
|
+
the schema is silent on.
|
|
50
|
+
- Zod `invalid_union` branch issues are flattened to field-level paths.
|
|
51
|
+
- `SelectField` stays controlled while the value is `undefined`.
|
|
52
|
+
- `useFieldArray` id reconciliation is concurrent-render-safe (derived state
|
|
53
|
+
instead of render-phase ref mutation) and keyed on form + path.
|
|
54
|
+
- Failed submits mark errored fields touched; `NumberField` rejects
|
|
55
|
+
`Infinity`, treats whitespace as empty, and reflects external writes while
|
|
56
|
+
focused; Dates compare by timestamp in dirty tracking; array ops validate
|
|
57
|
+
indices; error arrays keep reference identity across passes; paths respect
|
|
58
|
+
the existing container (records with numeric keys are no longer arrayified).
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- Accessibility wiring on all bound components (`name`, `aria-invalid`,
|
|
63
|
+
`aria-describedby`, `role="alert"`) and `ref` support; `focusFirstError`.
|
|
64
|
+
- `reset(nextInitial, { keepErrors, keepTouched, keepSubmitCount })` (no
|
|
65
|
+
`keepDirty` — dirtiness is derived from values vs `initialValues`, which
|
|
66
|
+
reset makes equal), `resetField(path)`, `getFieldState(path)`, `SelectField` `placeholder`,
|
|
67
|
+
`setError` accepts a single string, `"onTouched"` / `"all"` validation
|
|
68
|
+
modes, and `FieldPath` support for optional/nullable object levels.
|
|
69
|
+
- `useForm` warns once when the schema reference changes after mount.
|
|
70
|
+
|
|
71
|
+
### Internal
|
|
72
|
+
|
|
73
|
+
- CI workflow (typecheck + lint + test + build), ESLint flat config with
|
|
74
|
+
typescript-eslint and eslint-plugin-react-hooks, vitest 4 + jsdom 29,
|
|
75
|
+
V8 coverage (`npm run test:coverage`), Testing Library auto-cleanup via a
|
|
76
|
+
vitest setup file, fake-timer debounce tests, StrictMode coverage, and
|
|
77
|
+
publish metadata (`repository`, `author`, `engines`, split `types`
|
|
78
|
+
conditions for ESM/CJS).
|
|
79
|
+
|
|
80
|
+
## 0.1.0
|
|
81
|
+
|
|
82
|
+
Initial release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zustand-forms contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# formstand
|
|
2
|
+
|
|
3
|
+
Zod-schema-first form state for React 19, backed by zustand.
|
|
4
|
+
|
|
5
|
+
**[Documentation](https://scrumrot.github.io/formstand/)** · **[Live playground](https://scrumrot.github.io/formstand/examples/)** · **[API reference](https://scrumrot.github.io/formstand/api/)**
|
|
6
|
+
|
|
7
|
+
- **Typed paths** — `useField(form, "users.0.email")` infers the value type from the schema.
|
|
8
|
+
- **Per-field subscriptions** — fields re-render only when their own slice changes.
|
|
9
|
+
- **Sync and async validation** — with race-handling for async refines.
|
|
10
|
+
- **Field arrays** with stable IDs that survive reorders.
|
|
11
|
+
- **Bound input components** for the common cases.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install formstand zustand zod react
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Peer-dep ranges: `zod ^4.2`, `zustand ^5.0`, `react ^19.0`.
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import {
|
|
26
|
+
TextField,
|
|
27
|
+
NumberField,
|
|
28
|
+
useForm,
|
|
29
|
+
useIsSubmitting,
|
|
30
|
+
} from "formstand";
|
|
31
|
+
|
|
32
|
+
const schema = z.object({
|
|
33
|
+
name: z.string().min(2, "min 2 chars"),
|
|
34
|
+
age: z.int().nonnegative(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const SignUpForm = () => {
|
|
38
|
+
const form = useForm(schema, {
|
|
39
|
+
initialValues: { name: "", age: 0 },
|
|
40
|
+
mode: "onBlur",
|
|
41
|
+
});
|
|
42
|
+
const submitting = useIsSubmitting(form);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<form
|
|
46
|
+
onSubmit={form.handleSubmit((data) => {
|
|
47
|
+
console.log("submit", data);
|
|
48
|
+
})}
|
|
49
|
+
>
|
|
50
|
+
<TextField form={form} path="name" label="Name" />
|
|
51
|
+
<NumberField form={form} path="age" label="Age" />
|
|
52
|
+
<button type="submit" disabled={submitting}>
|
|
53
|
+
{submitting ? "..." : "Submit"}
|
|
54
|
+
</button>
|
|
55
|
+
</form>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Core API
|
|
61
|
+
|
|
62
|
+
### `createForm(schema, options)`
|
|
63
|
+
|
|
64
|
+
Creates a form instance. React users typically use `useForm` instead, which wraps this.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const form = createForm(schema, {
|
|
68
|
+
initialValues,
|
|
69
|
+
mode: "onBlur", // "onChange" | "onBlur" | "onSubmit"
|
|
70
|
+
reValidateMode: "onChange", // mode used after the first submit attempt
|
|
71
|
+
validateOnMount: false, // run a validation pass at creation (see below)
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `Form<TSchema>` methods
|
|
76
|
+
|
|
77
|
+
| Method | Notes |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `getState()` / `subscribe(listener)` | the underlying zustand store |
|
|
80
|
+
| `setValue(path, value)` | updates one field. Dirtiness is derived, not stored: a field reads as dirty while its value differs structurally from `initialValues` at that path (arrays/plain objects compare deep, Dates by timestamp, `Object.is` otherwise) |
|
|
81
|
+
| `setValues(next)` | replace the entire values object; server errors release only where a value slice actually changed |
|
|
82
|
+
| `setTouched(path, touched?)` | marks a path touched |
|
|
83
|
+
| `setError(path, errors)` / `setErrors(map)` / `clearErrors(path?)` | the app-owned **server error channel** (`state.serverErrors`) — validation never touches it; `setError` accepts a single string or an array; `clearErrors(path)` clears both channels at the path and its descendants (`clearErrors("")` clears just the root entry; `clearErrors()` clears everything); `setErrors` replaces the whole server channel |
|
|
84
|
+
| `setMode` / `setReValidateMode` | switch modes at runtime |
|
|
85
|
+
| `reset(nextInitial?, options?)` | reset to initial; optional partial overrides (shallow-merged for record roots, replaced wholesale otherwise) and `{ keepErrors, keepTouched, keepSubmitCount }` (no `keepDirty` — dirtiness derives from values vs initial, and reset makes them equal) |
|
|
86
|
+
| `resetField(path)` | reset one field to its initial value, clearing its (and descendants') error/touched state (dirtiness clears by definition — the value now equals initial) |
|
|
87
|
+
| `adoptValues(values)` | mid-session rebase: replaces `values` + `initialValues` and clears `errors`, but **preserves** interaction state (`touched`, `submitCount`, `isSubmitting`, `isValidating`, `mode`). Use `reset()` for a full wipe |
|
|
88
|
+
| `updateState(updater)` | atomic multi-field patch; `errors` is derived from `schemaErrors`/`serverErrors`, so patch the channels — the patch type omits `errors` entirely, and a plain-JS `errors` patch is warned about and ignored |
|
|
89
|
+
| `validate()` / `validateField(path)` / `validateFields(paths)` | sync validation; on an async schema they transparently start the async pass instead (`validate`/`validateField` return `{ kind: "pending", promise }`, `validateFields` returns the `Promise<boolean>` itself) |
|
|
90
|
+
| `validateAsync()` / `validateFieldAsync(path)` / `validateFieldsAsync(paths)` | async; supports `async .refine` |
|
|
91
|
+
| `submit(onValid, onInvalid?, { force? })` → `Promise<SubmitResult>` | full submit flow; resolves `{ kind: "valid", data }`, `{ kind: "invalid", errors }` (errored fields are also marked touched), or `{ kind: "skipped" }` when another submit is in flight |
|
|
92
|
+
| `handleSubmit(onValid, onInvalid?)(event?)` | event handler wrapper that calls `preventDefault` |
|
|
93
|
+
| `getField(path)` | typed one-shot value read |
|
|
94
|
+
| `getFieldState(path)` | typed one-shot read of a field's full slice (value/error/touched/dirty/isValidating) |
|
|
95
|
+
| `watchField` / `watchValue` / `watchValues` | subscriptions; see below |
|
|
96
|
+
| `diff()` / `dirtyFields()` | PATCH-style helpers, derived by comparing `values` against `initialValues`: minimal divergent paths (objects recurse to the changed leaves; arrays report their base path). Reverting a field drops it |
|
|
97
|
+
| `snapshot()` / `restore(snap)` | full state capture/restore for undo/rollback |
|
|
98
|
+
| `arrayPush` / `arrayRemove` / `arrayInsert` / `arrayMove` / `arraySwap` | array ops with meta-key re-keying (errors/touched/server verdicts follow their rows); dirtiness is derived, so push + remove reads clean again |
|
|
99
|
+
|
|
100
|
+
### Validation modes
|
|
101
|
+
|
|
102
|
+
- `mode: "onBlur"` (default): validate on blur and on submit.
|
|
103
|
+
- `mode: "onChange"`: validate on every change and on submit (not on blur).
|
|
104
|
+
- `mode: "onSubmit"`: validate only on submit.
|
|
105
|
+
- `mode: "onTouched"`: validate on blur always; on change only once the field has been touched.
|
|
106
|
+
- `mode: "all"`: validate on every change and blur.
|
|
107
|
+
- `reValidateMode` (default `"onChange"`) kicks in once `submitCount > 0`.
|
|
108
|
+
|
|
109
|
+
For schemas with `async .refine`, sync `validate()` / `validateField()` / `validateFields()` no longer throw: they detect the async requirement, start the async pass themselves, and hand you the in-flight work (`{ kind: "pending", promise }` from `validate`/`validateField`; the `Promise<boolean>` itself from `validateFields`). If the *field being validated* is itself sync, `validateField` still settles synchronously even when other fields carry async refines — field validation parses just that field's subschema when it can (see below).
|
|
110
|
+
|
|
111
|
+
## React hooks
|
|
112
|
+
|
|
113
|
+
### `useForm(schema, options)`
|
|
114
|
+
|
|
115
|
+
Lazy-creates a `Form<TSchema>` and holds it for the component's lifetime. Stable reference across renders.
|
|
116
|
+
|
|
117
|
+
### `useField(form, path, options?)`
|
|
118
|
+
|
|
119
|
+
Subscribes to one field's slice (value/error/touched/dirty/isValidating) and returns helpers:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const name = useField(form, "name");
|
|
123
|
+
name.value; // typed via FieldPath
|
|
124
|
+
name.error; // readonly string[] | undefined
|
|
125
|
+
name.touched;
|
|
126
|
+
name.dirty;
|
|
127
|
+
name.isValidating;
|
|
128
|
+
name.setValue(v);
|
|
129
|
+
name.setTouched();
|
|
130
|
+
name.setError([...]);
|
|
131
|
+
name.clearError();
|
|
132
|
+
name.validate();
|
|
133
|
+
name.validateAsync();
|
|
134
|
+
name.onBlur();
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Options:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
useField(form, "username", { debounceMs: 300 }); // debounces validation
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Path can also be a selector:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
useField(form, (state) => `users.${state.values.selectedIdx}.email`);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `useFieldArray(form, path)`
|
|
150
|
+
|
|
151
|
+
Array operations + stable IDs for React keys:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const users = useFieldArray<UserItem>(form, "users");
|
|
155
|
+
users.fields.map((f, i) => (
|
|
156
|
+
<UserRow key={f.id} item={f.value} index={i} />
|
|
157
|
+
));
|
|
158
|
+
users.push({...});
|
|
159
|
+
users.remove(i);
|
|
160
|
+
users.move(from, to);
|
|
161
|
+
users.swap(a, b);
|
|
162
|
+
users.insert(i, item);
|
|
163
|
+
users.error; // array-level error (e.g. min(1))
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`fields` IDs are reconciled against item identity each render, so a row's `id`
|
|
167
|
+
stays glued to its item across reorders, inserts, and removes — including
|
|
168
|
+
mutations made outside this hook (`form.arrayMove`, `setValue`, `restore`, or a
|
|
169
|
+
second `useFieldArray` on the same path). Editing a field keeps its row's `id`
|
|
170
|
+
(the row updates instead of remounting); a genuinely new item gets a fresh `id`.
|
|
171
|
+
IDs reset when the hook's `path` changes.
|
|
172
|
+
|
|
173
|
+
### `useFormSelector(form, selector)` / `useFormSelectorShallow(form, selector)`
|
|
174
|
+
|
|
175
|
+
Selector-style subscription. Use `useFormSelectorShallow` for selectors that return objects/arrays.
|
|
176
|
+
|
|
177
|
+
> Formerly `useFormState` / `useFormStateShallow` — renamed because React DOM
|
|
178
|
+
> ships its own (deprecated) `useFormState` and auto-imports regularly grabbed
|
|
179
|
+
> the wrong one. The old names still work as deprecated aliases.
|
|
180
|
+
|
|
181
|
+
### `useFormError(form)`
|
|
182
|
+
|
|
183
|
+
Shortcut for the root-level error (errors at the `""` key from a schema-level `.refine`).
|
|
184
|
+
|
|
185
|
+
### Flag hooks
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
useIsDirty(form); // any field dirty
|
|
189
|
+
useIsValid(form); // no errors currently in the error map
|
|
190
|
+
useIsSubmitting(form);
|
|
191
|
+
useSubmitCount(form);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
> **`useIsValid` reflects the error map, not a fresh validation.** It returns
|
|
195
|
+
> `true` when no errors are currently recorded — covering both schema errors and
|
|
196
|
+
> server errors set via `setError`. But the error map is empty until validation
|
|
197
|
+
> runs, so a never-validated form reads as valid even if its initial values
|
|
198
|
+
> would fail the schema. If you gate a submit button on `!useIsValid(form)`,
|
|
199
|
+
> pass `validateOnMount: true` to `useForm`/`createForm` so the initial values
|
|
200
|
+
> are checked up front. (`submit`/`handleSubmit` always re-validate regardless,
|
|
201
|
+
> so an invalid form can't actually be submitted either way.) `validateOnMount`
|
|
202
|
+
> surfaces errors for untouched fields immediately — gate error display on
|
|
203
|
+
> `touched` if you don't want them shown before interaction.
|
|
204
|
+
|
|
205
|
+
### Bound input components
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
<TextField form={form} path="email" label="Email" type="email" />
|
|
209
|
+
<NumberField form={form} path="age" label="Age" />
|
|
210
|
+
<CheckboxField form={form} path="agree" label="I agree" />
|
|
211
|
+
<SelectField form={form} path="theme" label="Theme" placeholder="Pick a theme"
|
|
212
|
+
options={[{ value: "light", label: "Light" }, { value: "dark", label: "Dark" }]} />
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
All bound components ship with accessibility wiring: `name={path}` (autofill,
|
|
216
|
+
password managers, native form posts), `aria-invalid` while errored,
|
|
217
|
+
`aria-describedby` pointing at the error message, and the error rendered with
|
|
218
|
+
`role="alert"` so it's announced when it appears. Each also accepts a `ref`
|
|
219
|
+
to the underlying `<input>`/`<select>`.
|
|
220
|
+
|
|
221
|
+
`SelectField` stays controlled while the field value is `undefined` by rendering
|
|
222
|
+
a disabled empty option (with your `placeholder` text, if given), so the blank
|
|
223
|
+
state is visible instead of the browser silently showing the first option.
|
|
224
|
+
|
|
225
|
+
`NumberField` renders a `type="text"` input with `inputMode="decimal"` and keeps
|
|
226
|
+
the raw text while you type, so partial entries (`-`, `1.`, `1e`) survive instead
|
|
227
|
+
of being coerced away by a controlled `<input type="number">`. Each keystroke
|
|
228
|
+
that parses to a **finite** number is pushed to the form (whitespace counts as
|
|
229
|
+
empty → `undefined`; `Infinity` is rejected), and the display snaps to the
|
|
230
|
+
canonical value on blur. If something else writes the field while you're typing
|
|
231
|
+
(`reset`, `adoptValues`, another component), the external value wins and the
|
|
232
|
+
input updates immediately.
|
|
233
|
+
|
|
234
|
+
Or roll your own with the prop helpers:
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
<input {...textInputProps(useField(form, "name"))} />
|
|
238
|
+
<input {...checkboxProps(useField(form, "agree"))} />
|
|
239
|
+
<select {...selectProps(useField(form, "theme"))}>...</select>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`numberInputProps` is also exported as a stateless `<input type="number">` binding
|
|
243
|
+
(native stepper + `step`), at the cost of the intermediate-entry behaviour above:
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
<input {...numberInputProps(useField(form, "age"))} type="number" step="1" />
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Focus management
|
|
250
|
+
|
|
251
|
+
`focusFirstError(errors, root?)` focuses the first control (in DOM order) whose
|
|
252
|
+
`name` matches an errored path — the bound components set `name={path}` so this
|
|
253
|
+
works out of the box. Wire it into the invalid-submit handler:
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
<form onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors))}>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
For custom focus logic, every bound component also takes a `ref` to its input.
|
|
260
|
+
|
|
261
|
+
## Sharing a form across components
|
|
262
|
+
|
|
263
|
+
`useForm(schema, options)` creates a form bound to the calling component's lifetime. Calling `useForm` twice — even with the same schema — gives you **two independent forms with two independent stores**. The hook uses `useState(() => createForm(...))` under the hood, and `useState` is per-component-instance.
|
|
264
|
+
|
|
265
|
+
To share one form between components, use one of these patterns:
|
|
266
|
+
|
|
267
|
+
### 1. Lift the form up and pass it down as a prop
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
const Parent = () => {
|
|
271
|
+
const form = useForm(schema, { initialValues });
|
|
272
|
+
return (
|
|
273
|
+
<>
|
|
274
|
+
<NameField form={form} />
|
|
275
|
+
<EmailField form={form} />
|
|
276
|
+
</>
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Each child takes `form: FieldFormApi` (or the typed `Form<typeof schema>`) and calls `useField(form, "...")` on it. Works for small trees; gets noisy if the tree is deep.
|
|
282
|
+
|
|
283
|
+
### 2. `createFormContext` — no prop drilling, full typing
|
|
284
|
+
|
|
285
|
+
```tsx
|
|
286
|
+
import { createFormContext } from "formstand";
|
|
287
|
+
|
|
288
|
+
const { Provider, useFormContext } = createFormContext<typeof schema>();
|
|
289
|
+
|
|
290
|
+
const Parent = () => {
|
|
291
|
+
const form = useForm(schema, { initialValues });
|
|
292
|
+
return (
|
|
293
|
+
<Provider form={form}>
|
|
294
|
+
<NameField />
|
|
295
|
+
<EmailField />
|
|
296
|
+
</Provider>
|
|
297
|
+
);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// In NameField.tsx — no form prop:
|
|
301
|
+
const NameField = () => {
|
|
302
|
+
const form = useFormContext(); // typed Form<typeof schema>
|
|
303
|
+
const name = useField(form, "name");
|
|
304
|
+
return <input {...textInputProps(name)} />;
|
|
305
|
+
};
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
The factory pattern (one `createFormContext` per form shape) preserves the schema's type information through the context. Children can `useField` directly without losing path inference.
|
|
309
|
+
|
|
310
|
+
### 3. Module-scope `createForm` (rare)
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
// formStore.ts
|
|
314
|
+
export const profileForm = createForm(schema, { initialValues });
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Import and use anywhere. The form lives for the lifetime of the module — useful for single-instance "app-wide" forms (e.g., a global filter bar), bad for forms that should reset between page mounts.
|
|
318
|
+
|
|
319
|
+
## Common pitfalls
|
|
320
|
+
|
|
321
|
+
- **Object-returning selectors must use `useFormSelectorShallow`.** `useFormSelector(form, (s) => ({ values: s.values, errors: s.errors }))` returns a fresh object on every call; React's `useSyncExternalStore` will detect snapshot churn and bail with *"Maximum update depth exceeded"*. Use `useFormSelectorShallow` instead — it caches by shallow equality.
|
|
322
|
+
- **Synchronous `validate` / `validateField` return `pending` on async schemas.** They start the async pass for you and return `{ kind: "pending", promise }` — check `result.kind` (or use the `*Async` variants directly) rather than assuming `valid`/`invalid`.
|
|
323
|
+
- **`form.submit()` short-circuits when one is already in flight** — it resolves `{ kind: "skipped" }`. Pass `{ force: true }` as the third argument to bypass.
|
|
324
|
+
- **`z.coerce.*` collapses typed paths.** Form values are typed as `z.input<Schema>`, and in zod v4 the input of `z.coerce.number()` is `unknown` — so `FieldPath`/`FieldValue` can't see through it and path inference degrades for those fields. Prefer keeping the field's input type honest (e.g. `z.string()` in the schema and parse in a `.transform`/`.pipe`, or use `NumberField`, which parses text to `number` for you) over `z.coerce`.
|
|
325
|
+
- **The imperative write surface is typed.** `form.setValue("naem", "x")` and `form.setValue("age", "thirty")` are compile errors, as are `setTouched`/`setError`/`clearErrors`/`validateField(s)`/array ops with bad paths. Dynamic array paths still typecheck (`` `users.${i}.email` `` with `i: number` matches the template-literal path type); for a fully runtime-built string, cast: `form.setValue(path as FieldPath<z.input<typeof schema>>, value as never)`.
|
|
326
|
+
|
|
327
|
+
## Path semantics
|
|
328
|
+
|
|
329
|
+
Paths are dot-separated (`"users.0.email"`). How a segment is interpreted is
|
|
330
|
+
decided by the **existing container**: arrays take numeric segments as indices,
|
|
331
|
+
plain objects take any segment as a string key — so a `z.record` keyed `"0"`
|
|
332
|
+
reads and writes the record key instead of silently becoming an array. Only
|
|
333
|
+
when the container doesn't exist yet does the segment type pick what's created
|
|
334
|
+
(numeric → array, otherwise object). Two limitations: keys containing `.` are
|
|
335
|
+
not addressable, and array writes beyond index 100 000 are refused (a typo'd
|
|
336
|
+
index must not allocate gigabytes).
|
|
337
|
+
|
|
338
|
+
## Subscriptions (non-React)
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
form.subscribe((state, prev) => { ... }); // fires on every state change
|
|
342
|
+
form.watchValues((values, prev) => { ... }); // only on values changes
|
|
343
|
+
form.watchValue("users.0.email", (next, prev) => { ... }); // single path's value
|
|
344
|
+
form.watchField("users.0.email", (snapshot) => { ... }); // value + error + touched + dirty + isValidating
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
All return an unsubscribe function.
|
|
348
|
+
|
|
349
|
+
## Async validation
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
const schema = z.object({
|
|
353
|
+
username: z.string().refine(
|
|
354
|
+
async (v) => !(await isTaken(v)),
|
|
355
|
+
{ message: "taken" },
|
|
356
|
+
),
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
- `form.submit` uses `safeParseAsync` internally, so async refines work transparently.
|
|
361
|
+
- `form.validateFieldAsync(path)` parses **just that field's subschema** when the path is reachable through refinement-free objects/arrays — so an async username check doesn't fire when you validate an unrelated field. When a traversed level carries a refinement (cross-field rules), it falls back to a full-form parse and scopes the written errors to the path and its descendants.
|
|
362
|
+
- `validateField("address")` also **sets and clears errors for descendant paths** (`address.city`, …), not just the exact key.
|
|
363
|
+
- Per-path **sequence numbers** ensure stale "username was taken" results don't overwrite a newer "ok" result.
|
|
364
|
+
- A **values-reference guard** drops the write if `values` mutated during the await (so `reset`/`adoptValues`/`setValue` during an in-flight validate doesn't corrupt the error map).
|
|
365
|
+
|
|
366
|
+
For UI, prefer `useField(form, path, { debounceMs: 300 })` to throttle network traffic.
|
|
367
|
+
|
|
368
|
+
## Server-side errors
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
form.handleSubmit(async (data) => {
|
|
372
|
+
const res = await api.create(data);
|
|
373
|
+
if (!res.ok) {
|
|
374
|
+
for (const err of res.errors) {
|
|
375
|
+
// setError's path is typed; a server-provided string needs a cast
|
|
376
|
+
form.setError(err.field as FieldPath<z.input<typeof schema>>, [err.message]);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Errors live in two channels. `schemaErrors` is owned by validation: every pass rebuilds it (full passes wholesale, field-scoped passes splice their scope). `serverErrors` is owned by you: `setError`/`setErrors` write it and validation never reads or writes it — which is the entire preservation story. A background `validateAsync()` can't wipe a "username taken" message because it physically can't touch that channel; there are no marks or bookkeeping to get wrong. The `errors` map every hook reads is derived from both on each write: the schema's message wins at a key, server entries show where the schema is silent. The merge is order-independent — `setError` on a key the schema currently rejects stores the verdict (visible in `state.serverErrors`) behind the schema message, and if the schema later clears at that key without the value changing, the stored verdict resurfaces, since the value it judged is unchanged.
|
|
383
|
+
|
|
384
|
+
A server error is released when:
|
|
385
|
+
|
|
386
|
+
- the value on its spine changes (`setValue`/`resetField`/array op on the path, a descendant, or an ancestor — editing `address.street` releases a verdict on `address`; array ops release verdicts on the array itself while row-level entries follow their rows through re-indexing),
|
|
387
|
+
- a `setValues` bulk write changes its value slice (entries on untouched fields survive),
|
|
388
|
+
- a field-scoped validation (`validateField`/`validateFieldAsync`/`validateFields`) targets its path (`validateField("")` counts as a full pass and leaves the server channel alone, like `validate()`), or
|
|
389
|
+
- you call `clearErrors` / `reset` / `adoptValues`.
|
|
390
|
+
|
|
391
|
+
Note `submit` still proceeds when the schema is valid even if server errors are present (the server gets to re-judge); they simply remain in the merged map, so `useIsValid` stays `false` until the user edits the field.
|
|
392
|
+
|
|
393
|
+
## Field arrays with nested arrays
|
|
394
|
+
|
|
395
|
+
Both work — `arrayPush("users.0.tags", tag)` mutates the inner array; outer reorders correctly re-key meta for all nested paths. `useFieldArray`'s stable IDs reset when the hook's path changes (so an inner field array inside a reordered outer item gets fresh IDs).
|
|
396
|
+
|
|
397
|
+
## Optimistic UI
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
form.handleSubmit(async (data) => {
|
|
401
|
+
const snap = form.snapshot();
|
|
402
|
+
form.adoptValues(data); // optimistic: data becomes the new baseline
|
|
403
|
+
try {
|
|
404
|
+
const saved = await api.save(data);
|
|
405
|
+
form.adoptValues(saved); // confirm with server response
|
|
406
|
+
} catch (e) {
|
|
407
|
+
form.restore(snap); // rollback
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## License
|
|
413
|
+
|
|
414
|
+
MIT
|