@homebound/form-state 2.30.0 → 2.31.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.
@@ -127,7 +127,10 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
127
127
  // is exactly acking our change, or
128
128
  // - the user hasn't made any changes since `.changedValue` was put on the wire, i.e. the
129
129
  // server received our value, but wanted to change it.
130
- const keepLocalWipChange = this.value !== value && !isAckingUnset && !acceptServerAck;
130
+ // Compare by content (not `!==`) so array/list-of-primitive values are treated as acked when
131
+ // the server returns the same contents in a new array instance (otherwise the field would
132
+ // stay dirty forever, since reference equality never holds for a fresh array).
133
+ const keepLocalWipChange = !(0, utils_1.areEqual)(this.value, value, strictOrder) && !isAckingUnset && !acceptServerAck;
131
134
  if (keepLocalWipChange)
132
135
  return;
133
136
  }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,6 @@ export { type ListFieldState } from "./fields/listField";
4
4
  export { createObjectState, type Fragment, type ObjectState, fragment } from "./fields/objectField";
5
5
  export { type FieldState, type ValueAdapter } from "./fields/valueField";
6
6
  export { required, type Rule } from "./rules";
7
- export { useFormState } from "./useFormState";
7
+ export { type FormObjectState, useFormState } from "./useFormState";
8
8
  export { useFormStates } from "./useFormStates";
9
9
  export { type ObjectConfig } from "./config";
@@ -286,6 +286,28 @@ describe("useFormState", () => {
286
286
  return { firstName: input.legalFirstName, lastName: input.legalLastName };
287
287
  }
288
288
  });
289
+ it("becomes clean when update acks an array value with the same contents", async () => {
290
+ const config = { colors: { type: "value" } };
291
+ function TestComponent() {
292
+ const form = (0, useFormState_1.useFormState)({ config, init: { input: { colors: ["red", "green", "blue"] } } });
293
+ // Note: we deliberately do not read `form.changedValue` (the way an auto-save would), so the only
294
+ // thing that can let `update` accept the server value is content equality, not the changedValue hint.
295
+ return ((0, jsx_runtime_1.jsx)(mobx_react_1.Observer, { children: () => {
296
+ var _a;
297
+ return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { "data-testid": "colors", children: ((_a = form.colors.value) !== null && _a !== void 0 ? _a : []).join(",") }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "dirty", children: String(form.dirty) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "edit", onClick: () => form.colors.set(["red", "green"]) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "saveAndUpdate", onClick: () => form.update({ colors: ["red", "green"] }) })] }));
298
+ } }));
299
+ }
300
+ const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
301
+ expect(r.dirty.textContent).toEqual("false");
302
+ // When the user edits the array
303
+ (0, rtl_utils_1.click)(r.edit);
304
+ expect(r.dirty.textContent).toEqual("true");
305
+ // And the server acks the same contents in a brand-new array instance
306
+ (0, rtl_utils_1.click)(r.saveAndUpdate);
307
+ // Then the form is clean (we don't get stuck dirty on array reference inequality)
308
+ expect(r.colors.textContent).toEqual("red,green");
309
+ expect(r.dirty.textContent).toEqual("false");
310
+ });
289
311
  it("can accept new data while read only", async () => {
290
312
  // Given a component
291
313
  function TestComponent() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.30.0",
3
+ "version": "2.31.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -10,7 +10,8 @@
10
10
  "url": "https://github.com/homebound-team/form-state"
11
11
  },
12
12
  "files": [
13
- "dist"
13
+ "dist",
14
+ "skills"
14
15
  ],
15
16
  "scripts": {
16
17
  "build": "tspc",
@@ -0,0 +1,347 @@
1
+ ---
2
+ name: form-state
3
+ description: >
4
+ Best practices for @homebound/form-state, a headless MobX-based form library. Use whenever you
5
+ build, edit, or debug a form: defining an ObjectConfig, calling useFormState (single record) or
6
+ useFormStates (one form per table row), wiring Bound* fields, validation (rules / required /
7
+ addRules), saving via changedValue, form lifecycle (canSave / update / commitChanges /
8
+ revertChanges / dirty / valid / touched), readOnly/loading, or MobX reactivity gotchas (Observer /
9
+ useComputed). Triggers: useFormState, useFormStates, ObjectConfig, ObjectState, FieldState,
10
+ BoundTextField/BoundSelectField/etc., changedValue, autoSave, formState.
11
+ ---
12
+
13
+ # form-state
14
+
15
+ `@homebound/form-state` is a headless, MobX-based form library. It buffers between the canonical
16
+ server data and the user's work-in-progress edits, so every field gets `value`/`dirty`/`touched`/
17
+ `errors`/`valid` state and the UI can be "one line per field" via a component library's `Bound*`
18
+ components.
19
+
20
+ Repo & docs: https://github.com/homebound-team/form-state (see `README.md` and the in-repo sample
21
+ app `src/FormStateApp.tsx`). The library is headless — it produces a `FieldState` interface that a
22
+ component library binds to. [Beam](https://github.com/homebound-team/beam) is the reference
23
+ implementation of the `Bound*` components referenced throughout this guide; substitute your own.
24
+
25
+ ## The three shapes (mental model)
26
+
27
+ form-state maps between three types. Getting these right is 80% of using the library well:
28
+
29
+ 1. **Input type `I`** — the query _output_ fragment you load (e.g. an `AuthorFragment` from GraphQL,
30
+ or any REST/store shape).
31
+ 2. **Form type `T`** — the flat, editable shape, kept as close as possible to the mutation _input_
32
+ (`SaveAuthorInput`). This is what you write an `ObjectConfig<T>` for.
33
+ 3. **Wire type** — the mutation payload. You get it from `formState.changedValue` (only dirty
34
+ fields + id) and pass it to the save call.
35
+
36
+ `init.map` converts `I → T`. `changedValue` extracts `T → wire`. Don't hand-map form controls
37
+ back to server data — that's the whole point of the library. (See "The Three Shapes Mental Model"
38
+ in `README.md`.)
39
+
40
+ ## Config style
41
+
42
+ There are two equivalent ways to write a config; pick one and stay consistent within a file:
43
+
44
+ - **Plain `ObjectConfig<T>` object literals** — explicit and dependency-free; used throughout this
45
+ guide. Recommended as the default: it reads top-to-bottom and needs no builder knowledge.
46
+ - **The `f.*` builder DSL** (`f.config()`, `f.value().req()`, `f.list()`, …) — a fluent, Zod-ish
47
+ builder exported as `f`; used by the README and `src/FormStateApp.tsx`. Handy when you like the
48
+ chaining ergonomics.
49
+
50
+ Both compile to the same runtime config. General conventions that apply either way:
51
+
52
+ - **Declare the config as a module-level `const`** (paired with the form type and any `mapToForm`
53
+ helper), typically at the bottom of the file. Reach for `useMemo` **only** when a rule/`readOnly`
54
+ depends on runtime state, and a **factory function** `(opts) => ObjectConfig<T>` when it depends
55
+ on props/permissions. `useFormStates` in particular _requires_ a stable config (see below).
56
+ - **Derive the form type `T` from the mutation input** (`SaveXInput`) — directly, or via
57
+ `Pick`/`Omit`, intersected with FE-only fields:
58
+ `type FormValue = SaveXInput & { attachment?: ...; hasFoo: boolean }`.
59
+ - **Save with `formState.changedValue`**, gated by `if (formState.canSave()) { ... }`.
60
+ - **After a save you usually don't need to mark the form clean** — the store/cache update
61
+ re-triggers `init.input`/`init.map` and resolves dirty state on the next render. Only reach for an
62
+ explicit clear when timing matters (e.g. clearing dirty _synchronously_ before a `navigate()`), and
63
+ when you do, prefer `formState.update(acked)` over deprecated `commitChanges()` (see
64
+ [Lifecycle](#saving--lifecycle)).
65
+ - **Wrap raw `formState.*` reads (`.value`, `.dirty`, `.errors`, `.canSave()`) in `<Observer>`
66
+ or `useComputed`.** `Bound*` components are already reactive and need no wrapper.
67
+
68
+ Import everything from `@homebound/form-state`: `useFormState`, `useFormStates`, `required`, `f`,
69
+ `createObjectState`, and the `ObjectConfig` / `ObjectState` / `FieldState` / `Rule` types.
70
+
71
+ ## ObjectConfig reference
72
+
73
+ ```ts
74
+ type FormValue = Pick<SaveAuthorInput, "id" | "firstName" | "lastName" | "cityId" | "bookIds">;
75
+
76
+ const formConfig: ObjectConfig<FormValue> = {
77
+ id: { type: "value" },
78
+ firstName: { type: "value", rules: [required] },
79
+ lastName: { type: "value", rules: [required] },
80
+ cityId: { type: "value" },
81
+ bookIds: { type: "value" },
82
+ };
83
+ ```
84
+
85
+ ### Field kinds
86
+
87
+ | `type` | when | notes |
88
+ | ------------ | -------------------------------------------------- | ------------------------------------------------------------- |
89
+ | `"value"` | any primitive/enum/id/string[] field | the default |
90
+ | `"list"` | array of child objects (`books: Book[]`) | needs child `config`; see below |
91
+ | `"object"` | nested single object | needs child `config`; `reference: true` to send only its `id` |
92
+ | `"fragment"` | read-only data you want on the form but never send | ⚠️ snapshot bug, see gotchas |
93
+
94
+ Enums and nested ids map to `"value"` fields holding the `.code`/`.id` string — flatten them in
95
+ `init.map` (e.g. `classification: a.classification?.code`, `cityId: a.city?.id`).
96
+
97
+ ### `type: "value"` options (all optional)
98
+
99
+ | option | meaning | notes |
100
+ | ------------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
101
+ | `rules: [required, ...]` | validation rules (see [Validation](#validation)) | the common case |
102
+ | `readOnly: boolean` | field starts read-only; can be flipped at runtime | often `readOnly: !canEdit` |
103
+ | `computed: true` | backed by a MobX computed; excluded from `reset` | for derived/aggregate fields |
104
+ | `isDeleteKey: true` | this bool marks a list row as deleted (excluded from list validity) | e.g. `delete: { type: "value", isDeleteKey: true }` |
105
+ | `isReadOnlyKey: true` | truthy value makes the whole containing entity read-only | rare |
106
+ | `isIdKey: boolean` | marks the id field (auto-true for `id`); always kept in `changedValue` | rely on the default for a field named `id` |
107
+ | `isLocalOnly: true` | ignore in dirty checks / never send | for FE-only fields; alternatively add them to `T` and destructure out of `changedValue` |
108
+ | `strictOrder: false` | array order doesn't affect dirty/changed | rare |
109
+
110
+ ### `type: "list"` (nested collections)
111
+
112
+ ```ts
113
+ attachments: {
114
+ type: "list",
115
+ // update: "incremental" → send only rows the user touched. Default "exhaustive" sends all rows
116
+ // (safest; matches Joist) so unchanged rows aren't orphaned. "deep-exhaustive" also sends all fields.
117
+ update: "incremental",
118
+ config: {
119
+ id: { type: "value" },
120
+ asset: { type: "object", config: { id: { type: "value" }, s3Key: { type: "value" } } },
121
+ op: { type: "value" },
122
+ },
123
+ },
124
+ ```
125
+
126
+ Child config can be inline (above) or a shared named const (`config: booksConfig`). Default the
127
+ `update` mode to exhaustive unless the backend expects only-touched rows. An `op` field (or legacy
128
+ `delete`/`remove`) in the child config implicitly enables incremental mode, following
129
+ [Joist's partial-update-apis](https://joist-orm.io/docs/features/partial-update-apis). See the
130
+ `books` list in `src/FormStateApp.tsx` and `src/useFormState.test.tsx`.
131
+
132
+ ## useFormState (single record)
133
+
134
+ ```ts
135
+ const formState = useFormState({
136
+ config: formConfig,
137
+ readOnly: !canEdit, // often from a permission/`canEdit.allowed`
138
+ init: {
139
+ input: author, // query fragment (or `query` for a QueryAndMap)
140
+ map: (a) => ({
141
+ // I → T; only called when input is defined
142
+ id: a.id,
143
+ firstName: a.firstName,
144
+ cityId: a.city?.id,
145
+ }),
146
+ ifUndefined: { firstName: null }, // defaults for create mode (input undefined)
147
+ onlyOnce: true, // freeze after first map (create modals w/ static seed)
148
+ },
149
+ autoSave: async (fs) => saveAuthor({ variables: { input: fs.changedValue } }),
150
+ });
151
+ ```
152
+
153
+ ### init
154
+
155
+ - **`init: { input, map }`** is the dominant form (existing record → form). `map` flattens the
156
+ fragment; form-state `useMemo`s on `input` identity so `map` needn't be stable and needn't null-check.
157
+ - **`init: { query, map }`** passes the query result; form-state waits on `query.loading`.
158
+ - **`ifUndefined`** supplies create-mode defaults so one config serves add + edit.
159
+ - **`onlyOnce: true`** stops re-mapping when the input changes — use for create modals seeded with
160
+ static data (can be conditional, e.g. only in modal mode).
161
+
162
+ See `src/useFormState.test.tsx` for `input`+`map`, `ifUndefined`, `query`, and `onlyOnce` cases.
163
+
164
+ ### readOnly / loading
165
+
166
+ - `readOnly` from a permission (`!canEdit.allowed`), a precomputed `isReadonly`, or a mode/status
167
+ (`mode === "view"`). It re-flows without rebuilding the form; nested forms can read
168
+ `formState.readOnly` back out.
169
+ - `loading` suppresses the form until data arrives; usually `query.loading` or "edit mode but no
170
+ record yet" (`!isNew && !record`). form-state also infers loading from `input === undefined` /
171
+ `query.loading`, so only set it explicitly otherwise.
172
+
173
+ ## useFormStates (one form per table row)
174
+
175
+ For tables where each row is its own mini-form with independent auto-save. Call the hook once at
176
+ table level; call `getFormState(row)` per row.
177
+
178
+ ```ts
179
+ const { getFormState } = useFormStates<TodoForm, ToDoFragment>({
180
+ config: formConfig, // stable module-level const (required)
181
+ getId: (o) => o.id!, // identity to cache each row's ObjectState on
182
+ map: (frag) => ({ id: frag.id, status: frag.status.code, /* I → T */ }),
183
+ autoSave: async (os) => { await saveTodo(os.changedValue); },
184
+ readOnly: !canEdit, // per-hook default; can be overridden per row
185
+ });
186
+
187
+ // In a table column render:
188
+ todo: (todo) => {
189
+ const os = getFormState(todo);
190
+ return <BoundSelectField field={os.status} options={statuses} />;
191
+ },
192
+ ```
193
+
194
+ - **Config must be stable** (module-level const) — never inline/`useMemo`-less-ly rebuilt; rebuilding
195
+ drops row caches and loses WIP edits.
196
+ - **`getId`** is almost always `(v) => v.id`. When a row has **no server id yet** (new/unsaved) or a
197
+ **composite key**, synthesize a deterministic id from its natural key (e.g. `` `${parentId}:${childId}` ``)
198
+ so form-state re-matches the same row when `init.input` re-maps and WIP edits survive.
199
+ - **Per-row `autoSave`**: read `os.changedValue`, fire a single-row mutation; the server-acked value
200
+ flows back in via `map` to reset dirty. Concurrent row saves are serialized by the library.
201
+ - **Per-row read-only**: `getFormState(row, { readOnly: true })` overrides the hook-level default.
202
+ - Thread `getFormState` into your `createColumns`/`createRows` and list it in the `useMemo` deps.
203
+
204
+ See `src/useFormStates.test.tsx`.
205
+
206
+ ## MobX reactivity (the #1 source of bugs)
207
+
208
+ form-state is MobX. `Bound*` components are self-reactive, but **any raw read of `formState.*`
209
+ in JSX or logic must be inside `<Observer>` or `useComputed`**, or it won't re-render.
210
+
211
+ ```tsx
212
+ // ✅ Bound components — no wrapper needed
213
+ <BoundTextField label="Name" field={formState.firstName} />
214
+ <BoundSelectField field={formState.cityId} options={cities} />
215
+
216
+ // ✅ Raw reads → wrap in Observer (preferred: inline around just the reactive JSX)
217
+ <Observer>{() => <Button label="Save" disabled={!formState.dirty} />}</Observer>
218
+
219
+ // ✅ Deriving a value from fields → useComputed (from your component library, e.g. @homebound/beam)
220
+ const saveDisabled = useComputed(
221
+ () => !formState.dirty || !formState.valid,
222
+ [formState],
223
+ );
224
+ ```
225
+
226
+ - `<Observer>` comes from `mobx-react`. Prefer **inline `<Observer>`** around the specific reactive
227
+ JSX. Reach for the **`observer(function X...)` HOC** only when a whole small component (a card/row)
228
+ reads `formState` in many places — common in tables.
229
+ - Use **`useComputed`** to derive a scalar/aggregate from fields, and (importantly) to keep a
230
+ query **reactive** to form edits: `useComputed(() => [skip, variables], [f.a.value, f.b.value])`.
231
+ - Reads that need reactivity: `.value`, `.dirty`, `.valid`, `.errors`, `.canSave()`, `list.rows`.
232
+ Note `formState.value.firstName` and `formState.firstName.value` are both reactive and return the
233
+ same value (see "Internal Implementation Notes" in `README.md`).
234
+ - Tests configure `mobx` with `configure({ enforceActions: "never" })` (form-state mutates
235
+ observables outside actions) — see `src/setupTests.ts`.
236
+
237
+ Common Bound components (reference implementation): `BoundSelectField`, `BoundNumberField`,
238
+ `BoundTextField`, `BoundMultiSelectField`, `BoundTextAreaField`, `BoundCheckboxField`,
239
+ `BoundDateField`, `BoundSwitchField`, `BoundRichTextField`, `BoundTreeSelectField`,
240
+ `BoundRadioGroupField`. Always pass the field proxy (`field={formState.foo}`), never `.value`.
241
+
242
+ ## Saving & lifecycle
243
+
244
+ ```ts
245
+ async function onSave() {
246
+ if (!formState.canSave()) return; // validates + touches all fields
247
+ const { id, attachments, ...input } = formState.changedValue; // peel off fields saved elsewhere
248
+ const { data } = await saveThing({ variables: { input: { id, ...input } } });
249
+ // No "mark clean" call needed: the store/cache update re-triggers init.input/map with the
250
+ // server's values, resolving dirty state on the next render. Only add formState.update(acked)
251
+ // when you must clear dirty *this* render — e.g. right before a navigate() (see below).
252
+ }
253
+ ```
254
+
255
+ - **`changedValue`** — only dirty fields (+ id). The workhorse. Destructure out any FE-only fields
256
+ and anything handled by a separate mutation before sending.
257
+ - **`canSave()`** — returns validity **and** marks fields touched so errors render. Use to guard
258
+ saves and to disable the Save button.
259
+ - **Do you even need to "mark clean"? Usually not.** After the mutation, the store/cache update flows
260
+ back through `init.input`/`init.map` with the server's latest values, and form-state resolves
261
+ dirty/touched on the next render — no manual call. This is the default; only force an immediate
262
+ clear when the timing matters. (See "Incremental changedValue/dirty Management" in `README.md`.)
263
+ - **`formState.update(ackedServerValue)`** — clears dirty **synchronously, this render** by re-running
264
+ `init.map` on the server ack and reconciling field-by-field (it also picks up server-assigned ids).
265
+ Needed only when you act _before_ the cache-driven re-render can happen — classically right before a
266
+ `navigate()`, so the "unsaved changes?" guard doesn't fire. When you need a synchronous clear, use
267
+ this over `commitChanges()`.
268
+ - **`commitChanges()`** — ⚠️ **deprecated**. It marks _all_ current changes as saved, so it can
269
+ silently drop edits made while a save was in-flight. When you need a synchronous clear use
270
+ `update(acked)` instead; otherwise let the cache refresh handle it.
271
+ - **`revertChanges()`** — discard edits (Cancel buttons, "leave page?" confirm). Also available
272
+ per-field (`formState.foo.revertChanges()`).
273
+ - **`FieldState` / `ListFieldState`**: `field.set(v)`, `field.value`, `field.originalValue`,
274
+ `field.dirty`, `field.blur()`, `list.rows`, `list.add(value, index?)`, `list.remove(indexOrValue)`.
275
+ - **`ValueAdapter` / `field.adapt(adapter)`** — wrap a field to convert its value type (e.g. cents ↔
276
+ dollars) at the binding boundary. An alternative to converting in `init.map` + the save mapper; use
277
+ whichever keeps the config clearest.
278
+
279
+ ## Validation
280
+
281
+ - **`rules: [required]`** — `required` is imported from form-state. It rejects `undefined`, `null`,
282
+ and empty/whitespace-only strings.
283
+ - **Custom rule** — `Rule<V>` = `({ value, key, originalValue }) => string | undefined`:
284
+ ```ts
285
+ totalPercent: { type: "value", rules: [({ value: v }) => (v !== 100 ? "Must equal 100%" : undefined)] }
286
+ ```
287
+ - **`addRules(state)`** — for cross-field / conditional validation that the config DSL can't express.
288
+ Push a rule onto a field that reads other fields' `.value`:
289
+ ```ts
290
+ addRules(state) {
291
+ state.lastName.rules.push(() =>
292
+ state.firstName.value === state.lastName.value ? "Last name cannot equal first name" : undefined,
293
+ );
294
+ }
295
+ ```
296
+ `addRules` doesn't need to be stable. In `useFormStates` it runs once per row (the place to wire
297
+ per-row recalc). See `src/FormStateApp.tsx`.
298
+ - **Conditional required via `useMemo`'d config** — when required-ness depends on state:
299
+ `phase: { type: "value", rules: !globalParentId ? [required] : [] }`.
300
+
301
+ ## Testing & stories
302
+
303
+ - Component tests render the component with mocked data and drive it via `Bound*` fields — the
304
+ form-state is exercised through the UI.
305
+ - For **isolated** field/component stories and tests, build an `ObjectState` directly with
306
+ `createObjectState(config, initialValue)`:
307
+ ```ts
308
+ const fs = createObjectState<{ item: Maybe<string> }>({ item: { type: "value" } }, { item: undefined });
309
+ // then <BoundSelectField field={fs.item} ... />
310
+ ```
311
+
312
+ ## Gotchas & anti-patterns
313
+
314
+ - ❌ **Don't read `formState.foo.value` (or `.dirty`/`.errors`) in JSX without `<Observer>`/`useComputed`.**
315
+ It silently won't update. `Bound*` are the exception.
316
+ - ⚠️ **Don't name a form field with an `ObjectState`/`FieldState` member name** (`value`, `dirty`,
317
+ `errors`, `touched`, `valid`, `changedValue`, `key`, `set`, `rows`, …) — the field access collides
318
+ with the built-in member. For FE-only list-identity fields, use a name like `clientId`.
319
+ - ⚠️ **`type: "fragment"` snapshots on the first `init.map`** (often while loading, so it can freeze
320
+ as empty) and never updates. If the value must refresh, model it as a `type: "list"` (or a plain
321
+ `"value"`) instead.
322
+ - ⚠️ **`addRules` can't read MobX class computeds** — stash the derived value into a real form field
323
+ (in `init.map`) so the rule can read it.
324
+ - ⚠️ **`useFormStates` config must be a stable module const** — rebuilding it drops row caches and
325
+ loses WIP edits.
326
+ - ⚠️ **`revertChanges` on a list needs the full collection including soft-deleted rows** — model
327
+ deletes with an `isDeleteKey` field rather than filtering rows out of the form.
328
+ - ⚠️ **Avoid calling `commitChanges()` manually with `autoSave`** — form-state warns about this
329
+ because it can drop in-flight edits; let `init.map`/`update` reconcile instead.
330
+
331
+ ## Quick reference
332
+
333
+ | I need to... | Use |
334
+ | ------------------------------------------------------ | ------------------------------------------------------------------------ |
335
+ | Define a form's fields | module-level `const config: ObjectConfig<T> = { ... }` |
336
+ | One form for a record | `useFormState({ config, init: { input, map } })` |
337
+ | One form per table row | `useFormStates({ config, getId, map, autoSave })` + `getFormState(row)` |
338
+ | Bind a field to UI | `<BoundTextField field={formState.foo} />` |
339
+ | Read a field value in JSX | wrap in `<Observer>` / derive via `useComputed` |
340
+ | Get the save payload | `formState.changedValue` |
341
+ | Gate a save | `if (formState.canSave()) { ... }` |
342
+ | Clear dirty after save | usually nothing — the store/cache refresh resolves it next render |
343
+ | Clear dirty _synchronously_ (e.g. before `navigate()`) | `formState.update(acked)` (not `commitChanges()`) |
344
+ | Discard edits | `formState.revertChanges()` |
345
+ | Cross-field validation | `addRules: (state) => state.foo.rules.push(...)` |
346
+ | Add/remove list rows | `formState.rows.add(value)` / `.remove(indexOrValue)` |
347
+ | Save-disabled button | `useComputed(() => !formState.dirty \|\| !formState.valid, [formState])` |