formstand 0.13.0 → 0.14.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 +40 -0
- package/README.md +86 -386
- package/dist/chunk-N7WPXYCU.js +144 -0
- package/dist/chunk-N7WPXYCU.js.map +1 -0
- package/dist/createForm-C9hZffCQ.d.cts +190 -0
- package/dist/createForm-C9hZffCQ.d.ts +190 -0
- package/dist/devtools/index.cjs +313 -0
- package/dist/devtools/index.cjs.map +1 -0
- package/dist/devtools/index.d.cts +27 -0
- package/dist/devtools/index.d.ts +27 -0
- package/dist/devtools/index.js +276 -0
- package/dist/devtools/index.js.map +1 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -188
- package/dist/index.d.ts +4 -188
- package/dist/index.js +3 -141
- package/dist/index.js.map +1 -1
- package/package.json +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0 — 2026-07-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`formstand/devtools`**: a form-aware debugging panel, `<FormstandDevtools
|
|
8
|
+
form={form} />`, shipped as a subpath of the existing package so there is
|
|
9
|
+
nothing extra to install and no version to keep in step. It renders one row
|
|
10
|
+
per leaf path with the value, error, and touched/dirty/validating flags;
|
|
11
|
+
surfaces the errors that belong to no field (the root `""` key, array-level
|
|
12
|
+
messages, server verdicts on paths that hold no value yet); shows the two
|
|
13
|
+
error channels separately, including whether a server verdict is visible or
|
|
14
|
+
sitting behind a schema message at the same key; renders `diff()` as a live
|
|
15
|
+
PATCH preview; and wires `snapshot()`/`restore()` to buttons for time
|
|
16
|
+
travel. Renders nothing in production builds, on the same `NODE_ENV` gate
|
|
17
|
+
`createForm`'s `devtools` option uses.
|
|
18
|
+
|
|
19
|
+
It complements rather than replaces that option: the Redux DevTools
|
|
20
|
+
extension gives a cross-store action log but only ever sees the merged error
|
|
21
|
+
map, which is exactly the distinction this panel exists to show. Dirtiness
|
|
22
|
+
comes from `getFieldState` so the panel can't disagree with `useIsDirty`,
|
|
23
|
+
and styling is inline so no host stylesheet can restyle it into lying.
|
|
24
|
+
|
|
25
|
+
## formstand-cli 0.10.1 — 2026-07-30
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **The template-hole regex is linear again** (CodeQL `js/polynomial-redos`,
|
|
30
|
+
high). `pathSegmentCount` strips holes like `${index}` before counting dot
|
|
31
|
+
segments, and its pattern excluded only the closing brace, so an
|
|
32
|
+
unterminated run such as `"${{${{${{..."` rescanned to the end of the
|
|
33
|
+
string from every `${` start: 1.8 seconds at 32k repetitions, growing
|
|
34
|
+
quadratically. Excluding both braces bounds each scan at the next brace
|
|
35
|
+
and makes the same input flat. A hole is always a plain identifier, so
|
|
36
|
+
real paths are unaffected and every committed demo regenerates
|
|
37
|
+
byte-identical. Matters because `formstand-cli/codegen` is the documented
|
|
38
|
+
browser-safe surface: the playground's Schema builder runs it on pasted
|
|
39
|
+
input, and anything built on it may accept a schema it did not author. The
|
|
40
|
+
binary alone was never really exposed, since generating from a hostile
|
|
41
|
+
schema already executes that module.
|
|
42
|
+
|
|
3
43
|
## 0.13.0 — 2026-07-29
|
|
4
44
|
|
|
5
45
|
### Added
|
package/README.md
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
Zod-schema-first form state for React 19, backed by zustand.
|
|
6
6
|
|
|
7
|
-
**[Documentation](https://scrumrot.github.io/formstand/)** · **[Live playground](https://scrumrot.github.io/formstand/examples/)** · **[API reference](https://scrumrot.github.io/formstand/api/)**
|
|
7
|
+
**[Documentation](https://scrumrot.github.io/formstand/documentation/)** · **[Live playground](https://scrumrot.github.io/formstand/examples/)** · **[CLI](https://scrumrot.github.io/formstand/documentation/cli/)** · **[API reference](https://scrumrot.github.io/formstand/documentation/api/)**
|
|
8
8
|
|
|
9
|
-
- **Typed paths
|
|
10
|
-
- **Per-field subscriptions
|
|
11
|
-
- **
|
|
12
|
-
- **Field arrays** with stable IDs that survive reorders.
|
|
13
|
-
- **Bound input components**
|
|
9
|
+
- **Typed paths**: `useField(form, "users.0.email")` infers the value type from the schema. Writes are typed too, so a typo'd path is a compile error.
|
|
10
|
+
- **Per-field subscriptions**: fields re-render only when their own slice changes, and validation parses one field's subschema per keystroke instead of the whole form.
|
|
11
|
+
- **Two error channels**: validation owns one, your app owns the other, so a background pass cannot wipe a server message.
|
|
12
|
+
- **Field arrays** with stable IDs that survive reorders, inserts, and removes.
|
|
13
|
+
- **Bound input components** with the accessibility wiring already done.
|
|
14
|
+
- **A generator**: [`formstand-cli`](https://scrumrot.github.io/formstand/documentation/cli/) writes the whole component from your schema, for six UI kits.
|
|
14
15
|
|
|
15
16
|
## Install
|
|
16
17
|
|
|
@@ -24,12 +25,7 @@ Peer-dep ranges: `zod ^4.2`, `zustand ^5.0`, `react ^19.0`.
|
|
|
24
25
|
|
|
25
26
|
```tsx
|
|
26
27
|
import { z } from "zod";
|
|
27
|
-
import {
|
|
28
|
-
TextField,
|
|
29
|
-
NumberField,
|
|
30
|
-
useForm,
|
|
31
|
-
useIsSubmitting,
|
|
32
|
-
} from "formstand";
|
|
28
|
+
import { TextField, NumberField, useForm, useIsSubmitting } from "formstand";
|
|
33
29
|
|
|
34
30
|
const schema = z.object({
|
|
35
31
|
name: z.string().min(2, "min 2 chars"),
|
|
@@ -44,11 +40,7 @@ const SignUpForm = () => {
|
|
|
44
40
|
const submitting = useIsSubmitting(form);
|
|
45
41
|
|
|
46
42
|
return (
|
|
47
|
-
<form
|
|
48
|
-
onSubmit={form.handleSubmit((data) => {
|
|
49
|
-
console.log("submit", data);
|
|
50
|
-
})}
|
|
51
|
-
>
|
|
43
|
+
<form onSubmit={form.handleSubmit((data) => console.log("submit", data))}>
|
|
52
44
|
<TextField form={form} path="name" label="Name" />
|
|
53
45
|
<NumberField form={form} path="age" label="Age" />
|
|
54
46
|
<button type="submit" disabled={submitting}>
|
|
@@ -59,386 +51,94 @@ const SignUpForm = () => {
|
|
|
59
51
|
};
|
|
60
52
|
```
|
|
61
53
|
|
|
62
|
-
|
|
54
|
+
`handleSubmit` calls `preventDefault` for you and only runs your handler with parsed,
|
|
55
|
+
`z.output`-typed data. The bound components render the label, wire the accessibility
|
|
56
|
+
attributes, and show the field's error.
|
|
63
57
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
Creates a form instance. React users typically use `useForm` instead, which wraps this.
|
|
67
|
-
|
|
68
|
-
```ts
|
|
69
|
-
const form = createForm(schema, {
|
|
70
|
-
initialValues,
|
|
71
|
-
mode: "onBlur", // "onChange" | "onBlur" | "onSubmit"
|
|
72
|
-
reValidateMode: "onChange", // mode used after the first submit attempt
|
|
73
|
-
validateOnMount: false, // run a validation pass at creation (see below)
|
|
74
|
-
});
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
### `Form<TSchema>` methods
|
|
78
|
-
|
|
79
|
-
| Method | Notes |
|
|
80
|
-
|---|---|
|
|
81
|
-
| `getState()` / `subscribe(listener)` | the underlying zustand store |
|
|
82
|
-
| `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) |
|
|
83
|
-
| `setValues(next)` | replace the entire values object; server errors release only where a value slice actually changed |
|
|
84
|
-
| `setTouched(path, touched?)` | marks a path touched |
|
|
85
|
-
| `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 |
|
|
86
|
-
| `setMode` / `setReValidateMode` | switch modes at runtime |
|
|
87
|
-
| `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) |
|
|
88
|
-
| `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); a path with no initial counterpart (an appended array row) keeps its value and only clears field state |
|
|
89
|
-
| `adoptValues(values)` | mid-session rebase: replaces `values` + `initialValues` and clears `errors` and in-flight validation flags (`isValidating`/`isValidatingForm` — the rebase disowns in-flight passes), but **preserves** interaction state (`touched`, `submitCount`, `isSubmitting`, `mode`). Use `reset()` for a full wipe |
|
|
90
|
-
| `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 |
|
|
91
|
-
| `validate()` / `validateField(path)` / `validateFields(paths)` | sync validation; on an async schema all three transparently start the async pass instead and return `{ kind: "pending", promise }` |
|
|
92
|
-
| `validateAsync()` / `validateFieldAsync(path)` / `validateFieldsAsync(paths)` | async; supports `async .refine` |
|
|
93
|
-
| `submit(onValid, onInvalid?, { force? })` → `Promise<SubmitResult>` | full submit flow; resolves `{ kind: "valid", data }`, `{ kind: "invalid", errors }` (errored fields are also marked touched), `{ kind: "skipped" }` when another submit is in flight, or `{ kind: "error", error }` when `onValid` throws/rejects — submit resolves instead of rejecting, so `handleSubmit` never produces an unhandled rejection from an `onValid` throw |
|
|
94
|
-
| `handleSubmit(onValid, onInvalid?)(event?)` | event handler wrapper that calls `preventDefault` |
|
|
95
|
-
| `getField(path)` | typed one-shot value read |
|
|
96
|
-
| `getFieldState(path)` | typed one-shot read of a field's full slice (value/error/touched/dirty/isValidating) |
|
|
97
|
-
| `watchField` / `watchValue` / `watchValues` | subscriptions; see below |
|
|
98
|
-
| `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 |
|
|
99
|
-
| `snapshot()` / `restore(snap)` | full state capture/restore for undo/rollback |
|
|
100
|
-
| `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 |
|
|
101
|
-
|
|
102
|
-
### Validation modes
|
|
103
|
-
|
|
104
|
-
- `mode: "onBlur"` (default): validate on blur and on submit.
|
|
105
|
-
- `mode: "onChange"`: validate on every change and on submit (not on blur).
|
|
106
|
-
- `mode: "onSubmit"`: validate only on submit.
|
|
107
|
-
- `mode: "onTouched"`: validate on blur always; on change only once the field has been touched.
|
|
108
|
-
- `mode: "all"`: validate on every change and blur.
|
|
109
|
-
- `reValidateMode` (default `"onChange"`) kicks in once `submitCount > 0`.
|
|
110
|
-
|
|
111
|
-
For schemas with `async .refine`, sync `validate()` / `validateField()` / `validateFields()` don't throw: they detect the async requirement, start the async pass themselves, and hand you the in-flight work as `{ kind: "pending", promise }` (never a bare truthy `Promise` — a boolean gate can't silently pass). The requirement is also remembered per scope, so later sync calls skip straight to the async pass instead of re-paying a doomed sync parse per keystroke. 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).
|
|
112
|
-
|
|
113
|
-
## React hooks
|
|
114
|
-
|
|
115
|
-
### `useForm(schema, options)`
|
|
116
|
-
|
|
117
|
-
Lazy-creates a `Form<TSchema>` and holds it for the component's lifetime. Stable reference across renders.
|
|
118
|
-
|
|
119
|
-
### `useField(form, path, options?)`
|
|
120
|
-
|
|
121
|
-
Subscribes to one field's slice (value/error/touched/dirty/isValidating) and returns helpers:
|
|
122
|
-
|
|
123
|
-
```ts
|
|
124
|
-
const name = useField(form, "name");
|
|
125
|
-
name.value; // typed via FieldPath
|
|
126
|
-
name.error; // readonly string[] | undefined
|
|
127
|
-
name.touched;
|
|
128
|
-
name.dirty;
|
|
129
|
-
name.isValidating;
|
|
130
|
-
name.setValue(v);
|
|
131
|
-
name.setTouched();
|
|
132
|
-
name.setError("taken"); // or ["taken", "reserved"]
|
|
133
|
-
name.clearError();
|
|
134
|
-
name.validate();
|
|
135
|
-
name.validateAsync();
|
|
136
|
-
name.onBlur();
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Options:
|
|
140
|
-
|
|
141
|
-
```ts
|
|
142
|
-
useField(form, "username", { debounceMs: 300 }); // debounces validation
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
Path can also be a selector:
|
|
146
|
-
|
|
147
|
-
```ts
|
|
148
|
-
useField(form, (state) => `users.${state.values.selectedIdx}.email`);
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
### `useFieldArray(form, path)`
|
|
152
|
-
|
|
153
|
-
Array operations + stable IDs for React keys:
|
|
154
|
-
|
|
155
|
-
```ts
|
|
156
|
-
const users = useFieldArray<UserItem>(form, "users");
|
|
157
|
-
users.fields.map((f, i) => (
|
|
158
|
-
<UserRow key={f.id} item={f.value} index={i} />
|
|
159
|
-
));
|
|
160
|
-
users.push({...});
|
|
161
|
-
users.remove(i);
|
|
162
|
-
users.move(from, to);
|
|
163
|
-
users.swap(a, b);
|
|
164
|
-
users.insert(i, item);
|
|
165
|
-
users.error; // array-level error (e.g. min(1))
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
`fields` IDs are shared per `(form, path)` — every hook on the same array sees
|
|
169
|
-
the same ids. Array ops (hook-issued or imperative `form.arrayMove` etc.)
|
|
170
|
-
record their exact index mapping and id derivation replays it, so even rows
|
|
171
|
-
with equal values keep the right ids through ops; whole-array writes
|
|
172
|
-
(`setValue`, `restore`) reconcile by item identity with a positional fallback,
|
|
173
|
-
so editing a field keeps its row's `id` (the row updates instead of
|
|
174
|
-
remounting). A genuinely new item gets a fresh `id`.
|
|
175
|
-
|
|
176
|
-
### `useFormSelector(form, selector)` / `useFormSelectorShallow(form, selector)`
|
|
177
|
-
|
|
178
|
-
Selector-style subscription. Use `useFormSelectorShallow` for selectors that return objects/arrays.
|
|
179
|
-
|
|
180
|
-
> Formerly `useFormState` / `useFormStateShallow` — renamed because React DOM
|
|
181
|
-
> ships its own (deprecated) `useFormState` and auto-imports regularly grabbed
|
|
182
|
-
> the wrong one. The old names were removed in 0.4.0.
|
|
183
|
-
|
|
184
|
-
### `useFormValues(form)`
|
|
185
|
-
|
|
186
|
-
The whole values object, reactively — sugar for `useFormSelector(form, (s) => s.values)`, reference-compared so it re-renders exactly when some value changes (never on touched/error churn); the render-side sibling of `form.watchValues`.
|
|
187
|
-
|
|
188
|
-
### `useFormError(form)`
|
|
189
|
-
|
|
190
|
-
Shortcut for the root-level error (errors at the `""` key from a schema-level `.refine`).
|
|
191
|
-
|
|
192
|
-
### Flag hooks
|
|
193
|
-
|
|
194
|
-
```ts
|
|
195
|
-
useIsDirty(form); // any field dirty
|
|
196
|
-
useIsValid(form); // no errors currently in the error map
|
|
197
|
-
useIsSubmitting(form);
|
|
198
|
-
useSubmitCount(form);
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
> **`useIsValid` reflects the error map, not a fresh validation.** It returns
|
|
202
|
-
> `true` when no errors are currently recorded — covering both schema errors and
|
|
203
|
-
> server errors set via `setError`. But the error map is empty until validation
|
|
204
|
-
> runs, so a never-validated form reads as valid even if its initial values
|
|
205
|
-
> would fail the schema. If you gate a submit button on `!useIsValid(form)`,
|
|
206
|
-
> pass `validateOnMount: true` to `useForm`/`createForm` so the initial values
|
|
207
|
-
> are checked up front. (`submit`/`handleSubmit` always re-validate regardless,
|
|
208
|
-
> so an invalid form can't actually be submitted either way.) `validateOnMount`
|
|
209
|
-
> surfaces errors for untouched fields immediately — gate error display on
|
|
210
|
-
> `touched` if you don't want them shown before interaction.
|
|
211
|
-
|
|
212
|
-
### Bound input components
|
|
213
|
-
|
|
214
|
-
```tsx
|
|
215
|
-
<TextField form={form} path="email" label="Email" type="email" />
|
|
216
|
-
<NumberField form={form} path="age" label="Age" />
|
|
217
|
-
<CheckboxField form={form} path="agree" label="I agree" />
|
|
218
|
-
<SelectField form={form} path="theme" label="Theme" placeholder="Pick a theme"
|
|
219
|
-
options={[{ value: "light", label: "Light" }, { value: "dark", label: "Dark" }]} />
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
All bound components ship with accessibility wiring: `name={path}` (autofill,
|
|
223
|
-
password managers, native form posts), `aria-invalid` while errored,
|
|
224
|
-
`aria-describedby` pointing at the error message, and the error rendered with
|
|
225
|
-
`role="alert"` so it's announced when it appears. Each also accepts a `ref`
|
|
226
|
-
to the underlying `<input>`/`<select>`.
|
|
227
|
-
|
|
228
|
-
`SelectField` stays controlled while the field value is `undefined` by rendering
|
|
229
|
-
a disabled empty option (with your `placeholder` text, if given), so the blank
|
|
230
|
-
state is visible instead of the browser silently showing the first option.
|
|
231
|
-
|
|
232
|
-
`NumberField` renders a `type="text"` input with `inputMode="decimal"` and keeps
|
|
233
|
-
the raw text while you type, so partial entries (`-`, `1.`, `1e`) survive instead
|
|
234
|
-
of being coerced away by a controlled `<input type="number">`. Each keystroke
|
|
235
|
-
that parses to a **finite** number is pushed to the form (whitespace counts as
|
|
236
|
-
empty → `undefined`; `Infinity` is rejected), and the display snaps to the
|
|
237
|
-
canonical value on blur. If something else writes the field while you're typing
|
|
238
|
-
(`reset`, `adoptValues`, another component), the external value wins and the
|
|
239
|
-
input updates immediately.
|
|
240
|
-
|
|
241
|
-
Or roll your own with the prop helpers:
|
|
58
|
+
Prefer your own markup? Every binding is also a plain function over a `useField` result:
|
|
242
59
|
|
|
243
60
|
```tsx
|
|
244
61
|
<input {...textInputProps(useField(form, "name"))} />
|
|
245
|
-
<input {...checkboxProps(useField(form, "agree"))} />
|
|
246
|
-
<select {...selectProps(useField(form, "theme"))}>...</select>
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
`numberInputProps` is also exported as a stateless `<input type="number">` binding
|
|
250
|
-
(native stepper + `step`), at the cost of the intermediate-entry behaviour above:
|
|
251
|
-
|
|
252
|
-
```tsx
|
|
253
|
-
<input {...numberInputProps(useField(form, "age"))} type="number" step="1" />
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
### Focus management
|
|
257
|
-
|
|
258
|
-
`focusFirstError(errors, root?)` focuses the first control (in DOM order) whose
|
|
259
|
-
`name` matches an errored path — the bound components set `name={path}` so this
|
|
260
|
-
works out of the box. Wire it into the invalid-submit handler:
|
|
261
|
-
|
|
262
|
-
```tsx
|
|
263
|
-
<form onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors))}>
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
On a page with several forms, pass the form element (e.g. via a ref) as `root`
|
|
267
|
-
so the search stays inside your form — without it the whole document is
|
|
268
|
-
scanned, and a root-`""` error's first-control fallback refuses to guess
|
|
269
|
-
between forms (it returns `false` when the document holds more than one
|
|
270
|
-
`<form>`):
|
|
271
|
-
|
|
272
|
-
```tsx
|
|
273
|
-
<form ref={formRef} onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors, formRef.current ?? undefined))}>
|
|
274
62
|
```
|
|
275
63
|
|
|
276
|
-
|
|
277
|
-
hidden controls, controls inside a closed `<dialog>`, and controls that refuse
|
|
278
|
-
focus (e.g. `display: none`) are passed over for the next match.
|
|
64
|
+
## Or generate the whole thing
|
|
279
65
|
|
|
280
|
-
|
|
66
|
+
[`formstand-cli`](https://scrumrot.github.io/formstand/documentation/cli/) writes the
|
|
67
|
+
component from a schema you already have. It is a dev dependency that produces plain
|
|
68
|
+
code and adds nothing to your bundle.
|
|
281
69
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
- **
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
## Path semantics
|
|
349
|
-
|
|
350
|
-
Paths are dot-separated (`"users.0.email"`). How a segment is interpreted is
|
|
351
|
-
decided by the **existing container**: arrays take numeric segments as indices,
|
|
352
|
-
plain objects take any segment as a string key — so a `z.record` keyed `"0"`
|
|
353
|
-
reads and writes the record key instead of silently becoming an array. Only
|
|
354
|
-
when the container doesn't exist yet does the segment type pick what's created
|
|
355
|
-
(numeric → array, otherwise object). Three limitations: keys containing `.`
|
|
356
|
-
are not addressable; array writes beyond index 100 000 are refused (a typo'd
|
|
357
|
-
index must not allocate gigabytes); and the typed `FieldPath` union stops at
|
|
358
|
-
9 segments by default — the runtime walks any depth, and `createForm`'s
|
|
359
|
-
type-level `pathDepth` option widens the union per form (a deliberate
|
|
360
|
-
compile-time trade; see the [typed paths guide](https://scrumrot.github.io/formstand/guide/typed-paths)).
|
|
361
|
-
|
|
362
|
-
## Subscriptions (non-React)
|
|
363
|
-
|
|
364
|
-
```ts
|
|
365
|
-
form.subscribe((state, prev) => { ... }); // fires on every state change
|
|
366
|
-
form.watchValues((values, prev) => { ... }); // only on values changes
|
|
367
|
-
form.watchValue("users.0.email", (next, prev) => { ... }); // single path's value
|
|
368
|
-
form.watchField("users.0.email", (snapshot) => { ... }); // value + error + touched + dirty + isValidating
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
All return an unsubscribe function.
|
|
372
|
-
|
|
373
|
-
## Async validation
|
|
374
|
-
|
|
375
|
-
```ts
|
|
376
|
-
const schema = z.object({
|
|
377
|
-
username: z.string().refine(
|
|
378
|
-
async (v) => !(await isTaken(v)),
|
|
379
|
-
{ message: "taken" },
|
|
380
|
-
),
|
|
381
|
-
});
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
- `form.submit` uses `safeParseAsync` internally, so async refines work transparently.
|
|
385
|
-
- `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.
|
|
386
|
-
- `validateField("address")` also **sets and clears errors for descendant paths** (`address.city`, …), not just the exact key.
|
|
387
|
-
- Per-path **sequence numbers** ensure stale "username was taken" results don't overwrite a newer "ok" result.
|
|
388
|
-
- 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).
|
|
389
|
-
|
|
390
|
-
For UI, prefer `useField(form, path, { debounceMs: 300 })` to throttle network traffic.
|
|
391
|
-
|
|
392
|
-
## Server-side errors
|
|
393
|
-
|
|
394
|
-
```ts
|
|
395
|
-
form.handleSubmit(async (data) => {
|
|
396
|
-
const res = await api.create(data);
|
|
397
|
-
if (!res.ok) {
|
|
398
|
-
for (const err of res.errors) {
|
|
399
|
-
// setError's path is typed; a server-provided string needs a cast
|
|
400
|
-
form.setError(err.field as FieldPath<z.input<typeof schema>>, [err.message]);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
});
|
|
404
|
-
```
|
|
405
|
-
|
|
406
|
-
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.
|
|
407
|
-
|
|
408
|
-
A server error is released when:
|
|
409
|
-
|
|
410
|
-
- 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),
|
|
411
|
-
- a `setValues` bulk write changes its value slice (entries on untouched fields survive),
|
|
412
|
-
- 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
|
|
413
|
-
- you call `clearErrors` / `reset` / `adoptValues`.
|
|
414
|
-
|
|
415
|
-
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.
|
|
416
|
-
|
|
417
|
-
## Field arrays with nested arrays
|
|
418
|
-
|
|
419
|
-
Both work — `arrayPush("users.0.tags", tag)` mutates the inner array; outer reorders correctly re-key meta for all nested paths. Inner row ids live per `(form, path)` and reconcile by value when the outer index re-seats a different item at the path — pass a `key` from the outer `fields` so each row's whole subtree moves with its item.
|
|
420
|
-
|
|
421
|
-
## Optimistic UI
|
|
422
|
-
|
|
423
|
-
```ts
|
|
424
|
-
form.handleSubmit(async (data) => {
|
|
425
|
-
const snap = form.snapshot();
|
|
426
|
-
form.adoptValues(data); // optimistic: data becomes the new baseline
|
|
427
|
-
try {
|
|
428
|
-
const saved = await api.save(data);
|
|
429
|
-
form.adoptValues(saved); // confirm with server response
|
|
430
|
-
} catch (e) {
|
|
431
|
-
form.restore(snap); // rollback
|
|
432
|
-
}
|
|
433
|
-
});
|
|
434
|
-
```
|
|
70
|
+
```bash
|
|
71
|
+
npm install -D formstand-cli
|
|
72
|
+
npx formstand-gen src/profileSchema.ts --ui mui --sections panel --columns 2 --out src/ProfileForm.tsx
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
You get typed `initialValues`, a bound control per field, sections for nested objects,
|
|
76
|
+
`useFieldArray` blocks with add and remove buttons, and a wired submit. Point it at a
|
|
77
|
+
TypeScript type instead (`--type Profile`) and it generates the zod schema too. `--ui`
|
|
78
|
+
targets plain formstand components, Material UI 5 through 9, shadcn/ui, Chakra 3,
|
|
79
|
+
Mantine 9, or Ant Design 6.
|
|
80
|
+
|
|
81
|
+
Try it without installing anything in the
|
|
82
|
+
[Schema builder](https://scrumrot.github.io/formstand/examples/#/schema-builder), which
|
|
83
|
+
runs the real emitters in your browser.
|
|
84
|
+
|
|
85
|
+
## How it works
|
|
86
|
+
|
|
87
|
+
Your zod schema is the single source of truth. It defines the value types, the set of
|
|
88
|
+
addressable paths, and the validation rules, and `useForm` builds a form whose entire
|
|
89
|
+
state lives in one plain zustand store. Hooks subscribe to slices of that store, so a
|
|
90
|
+
field re-renders only when its own slice changes. Because the store is ordinary zustand,
|
|
91
|
+
everything works outside React too: read it, subscribe to it, or drive it imperatively.
|
|
92
|
+
|
|
93
|
+
Errors live in two channels that cannot clobber each other. Validation owns
|
|
94
|
+
`schemaErrors` and rebuilds it on every pass; your `setError` calls own `serverErrors`
|
|
95
|
+
and validation never touches them. The map your UI reads is derived from both. That is
|
|
96
|
+
why a background `validateAsync()` cannot wipe a "username already taken" message, and
|
|
97
|
+
why the server verdict is released automatically when the user edits that field, with no
|
|
98
|
+
`clearErrors` bookkeeping in your change handlers.
|
|
99
|
+
|
|
100
|
+
Anything that can be derived is. Dirtiness is computed by comparing values against
|
|
101
|
+
initial values rather than tracked as you write, so it cannot drift out of sync,
|
|
102
|
+
`arrayPush` followed by `arrayRemove` reads clean again, and `diff()` gives you a
|
|
103
|
+
PATCH-ready payload of exactly what changed.
|
|
104
|
+
|
|
105
|
+
## The API, at a glance
|
|
106
|
+
|
|
107
|
+
| Area | What's there | Docs |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| Form instance | `createForm`, `setValue`/`setValues`, `reset`/`resetField`/`adoptValues`, `submit`/`handleSubmit`, `diff`/`dirtyFields`, `snapshot`/`restore`, array ops | [createForm & Form](https://scrumrot.github.io/formstand/documentation/api/) |
|
|
110
|
+
| Hooks | `useForm`, `useField`, `useFieldArray`, `useVariantField`, `useFormSelector`, `useFormValues`, the flag hooks, `createFormContext`, `createFormHooks` | [Hooks](https://scrumrot.github.io/formstand/documentation/api/hooks) |
|
|
111
|
+
| Components | `TextField`, `NumberField`, `DateField`, `CheckboxField`, `SelectField`, the prop builders, `useNumberInput`, `focusField`/`focusFirstError` | [Components & bindings](https://scrumrot.github.io/formstand/documentation/api/components) |
|
|
112
|
+
| Validation | five modes plus `reValidateMode`, field-scoped passes, async refines with race handling, per-field `debounceMs` | [Validation](https://scrumrot.github.io/formstand/documentation/validation) |
|
|
113
|
+
| Utilities | `parsePath`/`getAtPath`/`setAtPath`, `validateSync`/`validateAsync`, `persistForm`, and the full type list | [Utilities & types](https://scrumrot.github.io/formstand/documentation/api/utilities) |
|
|
114
|
+
|
|
115
|
+
Coming from react-hook-form? The
|
|
116
|
+
[migration guide](https://scrumrot.github.io/formstand/documentation/migrating-from-react-hook-form)
|
|
117
|
+
maps `register`, `watch`, `setError`, `setFocus`, and `useFieldArray` onto their
|
|
118
|
+
counterparts, and is honest about what has none.
|
|
119
|
+
|
|
120
|
+
## Things that catch people out
|
|
121
|
+
|
|
122
|
+
- **Object-returning selectors need `useFormSelectorShallow`.** A selector returning a
|
|
123
|
+
fresh object every call makes React bail with *"Maximum update depth exceeded"*.
|
|
124
|
+
- **`useIsValid` reflects the error map, not a fresh parse.** A never-validated form
|
|
125
|
+
reads as valid. Pass `validateOnMount: true` if you gate a submit button on it.
|
|
126
|
+
- **Sync `validate`/`validateField` return `{ kind: "pending" }` on async schemas.**
|
|
127
|
+
They start the async pass for you, so check `result.kind` rather than assuming.
|
|
128
|
+
- **`z.coerce.*` collapses typed paths**, because in zod v4 its input type is `unknown`.
|
|
129
|
+
Keep the input type honest and parse in a `.transform`/`.pipe`, or use `NumberField`.
|
|
130
|
+
- **Explicit type arguments are a compile error** on a schema-typed form. The value type
|
|
131
|
+
infers from the path; `useField<Values>(form, "email")` fails on purpose, with a
|
|
132
|
+
message telling you to drop it.
|
|
133
|
+
|
|
134
|
+
Each of these is covered properly in the
|
|
135
|
+
[documentation](https://scrumrot.github.io/formstand/documentation/).
|
|
435
136
|
|
|
436
137
|
## Contributing
|
|
437
138
|
|
|
438
|
-
See [CONTRIBUTING.md](./CONTRIBUTING.md)
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
[ROADMAP.md](./ROADMAP.md).
|
|
139
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md). Note the repo has three independent npm roots
|
|
140
|
+
(`.`, `examples/`, `cli/`), and the root test suite needs `examples/` installed. Where
|
|
141
|
+
the project is headed lives in [ROADMAP.md](./ROADMAP.md).
|
|
442
142
|
|
|
443
143
|
## License
|
|
444
144
|
|