@homebound/form-state 2.30.1 → 2.32.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/package.json +6 -5
- package/skills/form-state/SKILL.md +347 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homebound/form-state",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.32.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",
|
|
@@ -70,8 +71,8 @@
|
|
|
70
71
|
"jest": "^29.7.0",
|
|
71
72
|
"jest-environment-jsdom": "^29.7.0",
|
|
72
73
|
"lint-staged": "^15.2.10",
|
|
73
|
-
"mobx": "^
|
|
74
|
-
"mobx-react": "^
|
|
74
|
+
"mobx": "^7.0.3",
|
|
75
|
+
"mobx-react": "^10.0.2",
|
|
75
76
|
"prettier": "3.3.3",
|
|
76
77
|
"prettier-plugin-organize-imports": "^4.1.0",
|
|
77
78
|
"react": "^18.3.1",
|
|
@@ -87,5 +88,5 @@
|
|
|
87
88
|
"vite": "^5.4.9",
|
|
88
89
|
"vite-tsconfig-paths": "^5.0.1"
|
|
89
90
|
},
|
|
90
|
-
"packageManager": "yarn@
|
|
91
|
+
"packageManager": "yarn@4.18.0"
|
|
91
92
|
}
|
|
@@ -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])` |
|