mvc-kit 2.12.0 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,165 @@
1
+ import { useRef, useEffect } from 'react';
2
+ import type { DependencyList } from 'react';
3
+ import type { Subscribable, Disposable } from '../types';
4
+ import { isSubscribable, isSubscribeOnly, isInitializable } from './guards';
5
+ import { useInstance } from './use-instance';
6
+ import { useSubscribeOnly } from './use-subscribe-only';
7
+ import type { StateOf } from './types';
8
+
9
+ function depsChanged(prev: DependencyList | undefined, next: DependencyList): boolean {
10
+ if (prev === undefined) return false;
11
+ if (prev.length !== next.length) return true;
12
+ for (let i = 0; i < prev.length; i++) {
13
+ if (!Object.is(prev[i], next[i])) return true;
14
+ }
15
+ return false;
16
+ }
17
+
18
+ // ── With deps (class + initialState + deps) ────────────────────────
19
+
20
+ /**
21
+ * Create component-scoped Subscribable instance, auto-disposed on unmount.
22
+ * Disposes and recreates when deps change.
23
+ * Returns [state, instance] tuple.
24
+ */
25
+ export function useLocal<T extends Subscribable<any> & Disposable>(
26
+ Class: new (initialState: StateOf<T>) => T,
27
+ initialState: StateOf<T>,
28
+ deps: DependencyList,
29
+ ): [StateOf<T>, T];
30
+
31
+ /**
32
+ * Create component-scoped Subscribable instance via factory, auto-disposed on unmount.
33
+ * Disposes and recreates when deps change.
34
+ * Returns [state, instance] tuple.
35
+ */
36
+ export function useLocal<T extends Subscribable<S> & Disposable, S = StateOf<T>>(
37
+ factory: () => T,
38
+ deps: DependencyList,
39
+ ): [S, T];
40
+
41
+ /**
42
+ * Create component-scoped Disposable instance via factory (non-Subscribable), auto-disposed on unmount.
43
+ * Disposes and recreates when deps change.
44
+ * Returns the instance directly.
45
+ */
46
+ export function useLocal<T extends Disposable>(
47
+ factory: () => T,
48
+ deps: DependencyList,
49
+ ): T;
50
+
51
+ // ── Without deps (existing overloads, unchanged) ───────────────────
52
+
53
+ /**
54
+ * Create component-scoped Subscribable instance, auto-disposed on unmount.
55
+ * Returns [state, instance] tuple.
56
+ */
57
+ export function useLocal<
58
+ T extends Subscribable<S> & Disposable,
59
+ S = StateOf<T>,
60
+ Args extends unknown[] = unknown[]
61
+ >(
62
+ Class: new (...args: Args) => T,
63
+ ...args: Args
64
+ ): [S, T];
65
+
66
+ /**
67
+ * Create component-scoped Disposable instance (non-Subscribable), auto-disposed on unmount.
68
+ * Returns the instance directly.
69
+ */
70
+ export function useLocal<T extends Disposable, Args extends unknown[] = unknown[]>(
71
+ Class: new (...args: Args) => T,
72
+ ...args: Args
73
+ ): T;
74
+
75
+ /**
76
+ * Create component-scoped Subscribable instance via factory, auto-disposed on unmount.
77
+ * Returns [state, instance] tuple.
78
+ */
79
+ export function useLocal<T extends Subscribable<S> & Disposable, S = StateOf<T>>(
80
+ factory: () => T
81
+ ): [S, T];
82
+
83
+ /**
84
+ * Create component-scoped Disposable instance via factory (non-Subscribable), auto-disposed on unmount.
85
+ * Returns the instance directly.
86
+ */
87
+ export function useLocal<T extends Disposable>(factory: () => T): T;
88
+
89
+ // ── Implementation ─────────────────────────────────────────────────
90
+
91
+ export function useLocal<T extends Disposable, S = StateOf<T>>(
92
+ classOrFactory: (new (...args: unknown[]) => T) | (() => T),
93
+ ...rest: unknown[]
94
+ ): [S, T] | T {
95
+ // ── Detect deps: last arg is an array → treat as deps ──
96
+ let args: unknown[];
97
+ let deps: DependencyList | undefined;
98
+
99
+ if (rest.length > 0 && Array.isArray(rest[rest.length - 1])) {
100
+ deps = rest[rest.length - 1] as DependencyList;
101
+ args = rest.slice(0, -1);
102
+ } else {
103
+ args = rest;
104
+ deps = undefined;
105
+ }
106
+
107
+ const instanceRef = useRef<T | null>(null);
108
+ const mountedRef = useRef(false);
109
+ const prevDepsRef = useRef<DependencyList | undefined>(undefined);
110
+
111
+ // ── Render phase: dep-change detection ──
112
+ if (deps !== undefined && depsChanged(prevDepsRef.current, deps)) {
113
+ instanceRef.current?.dispose();
114
+ instanceRef.current = null;
115
+ }
116
+ if (deps !== undefined) {
117
+ prevDepsRef.current = deps;
118
+ }
119
+
120
+ // ── Create instance if needed ──
121
+ if (!instanceRef.current || instanceRef.current.disposed) {
122
+ const isClass =
123
+ typeof classOrFactory === 'function' &&
124
+ classOrFactory.prototype &&
125
+ classOrFactory.prototype.constructor === classOrFactory;
126
+
127
+ if (isClass) {
128
+ instanceRef.current = new (classOrFactory as new (...a: unknown[]) => T)(...args);
129
+ } else {
130
+ instanceRef.current = (classOrFactory as () => T)();
131
+ }
132
+ }
133
+
134
+ // ── Effect: init + deferred cleanup ──
135
+ useEffect(() => {
136
+ const instance = instanceRef.current!; // capture for cleanup closure
137
+ mountedRef.current = true;
138
+ if (isInitializable(instance)) {
139
+ instance.init();
140
+ }
141
+ return () => {
142
+ mountedRef.current = false;
143
+ setTimeout(() => {
144
+ if (!mountedRef.current) {
145
+ instance.dispose();
146
+ }
147
+ }, 0);
148
+ };
149
+ // eslint-disable-next-line react-hooks/exhaustive-deps
150
+ }, deps ?? []);
151
+
152
+ // ── Subscribe to state if Subscribable ──
153
+ if (isSubscribable(instanceRef.current)) {
154
+ const state = useInstance(instanceRef.current as unknown as Subscribable<S>);
155
+ return [state, instanceRef.current];
156
+ }
157
+
158
+ // ── Subscribe for re-renders if subscribe-only (e.g., Trackable) ──
159
+ if (isSubscribeOnly(instanceRef.current)) {
160
+ useSubscribeOnly(instanceRef.current);
161
+ return instanceRef.current;
162
+ }
163
+
164
+ return instanceRef.current;
165
+ }
@@ -0,0 +1,364 @@
1
+ # useModel
2
+
3
+ Bind a [Model](../Model.md) to a React component with automatic lifecycle management. `useModel` creates (or receives) a Model instance, auto-initializes on mount, auto-disposes on unmount, and subscribes to state changes so the component re-renders on every update.
4
+
5
+ Also exports `useModelRef` (lifecycle-only, no subscription) and `useField` (surgical per-field subscriptions).
6
+
7
+ ---
8
+
9
+ ## Signature
10
+
11
+ ```typescript
12
+ function useModel<M extends Model<any>>(factory: () => M): ModelHandle<StateOf<M>, M>;
13
+ ```
14
+
15
+ | Parameter | Type | Description |
16
+ |---|---|---|
17
+ | `factory` | `() => M` | Factory that returns a Model instance. Called once on mount (or if the previous instance was disposed). |
18
+
19
+ ### ModelHandle
20
+
21
+ The return value exposes everything a component needs to render and interact with the form:
22
+
23
+ | Property | Type | Description |
24
+ |---|---|---|
25
+ | `state` | `S` | Current frozen state — read raw field values here. |
26
+ | `errors` | `ValidationErrors<S>` | Validation errors keyed by field name. Empty object when valid. |
27
+ | `valid` | `boolean` | `true` when `errors` has no keys — all fields pass validation. |
28
+ | `dirty` | `boolean` | `true` when `state` differs from the committed baseline (shallow equality). |
29
+ | `model` | `M` | The Model instance itself — call setter methods on this. |
30
+
31
+ ---
32
+
33
+ ## Lifecycle
34
+
35
+ ### Mount
36
+
37
+ 1. The `factory` is called during render to create the Model instance.
38
+ 2. On mount (inside `useEffect`), `init()` is called automatically if the instance has an `init` method.
39
+ 3. `useSyncExternalStore` subscribes to the Model — any `set()` call triggers a React re-render.
40
+
41
+ ### Re-render
42
+
43
+ On subsequent renders, the existing instance is reused. The factory is **not** called again. `init()` is **not** called again — it is idempotent.
44
+
45
+ ### Unmount
46
+
47
+ On unmount, `dispose()` is called after a deferred `setTimeout(0)`. The defer handles React Strict Mode's mount/unmount/remount cycle — if the component remounts immediately, dispose is cancelled.
48
+
49
+ After dispose:
50
+ - `set()` throws
51
+ - `commit()` / `rollback()` throw
52
+ - `subscribe()` returns a no-op
53
+
54
+ ### Passing an External Model
55
+
56
+ When a ViewModel owns the Model (the typical form-page pattern), pass it through the factory:
57
+
58
+ ```tsx
59
+ function EditUserForm({ model }: { model: UserFormModel }) {
60
+ const { state, errors, valid, dirty } = useModel(() => model);
61
+ // ...
62
+ }
63
+ ```
64
+
65
+ The hook subscribes to whichever instance the factory returns. Lifecycle (`init`/`dispose`) still applies — the hook auto-initializes and auto-disposes the instance it holds.
66
+
67
+ ---
68
+
69
+ ## Basic Usage
70
+
71
+ ### Define a Model
72
+
73
+ ```typescript
74
+ import { Model } from 'mvc-kit';
75
+ import type { ValidationErrors } from 'mvc-kit';
76
+
77
+ interface FormState {
78
+ name: string;
79
+ email: string;
80
+ age: number;
81
+ }
82
+
83
+ class FormModel extends Model<FormState> {
84
+ setName(name: string) { this.set({ name }); }
85
+ setEmail(email: string) { this.set({ email }); }
86
+ setAge(age: number) { this.set({ age }); }
87
+
88
+ protected validate(state: Readonly<FormState>): ValidationErrors<FormState> {
89
+ const errors: ValidationErrors<FormState> = {};
90
+ if (!state.name) errors.name = 'Name is required';
91
+ if (!state.email.includes('@')) errors.email = 'Invalid email';
92
+ if (state.age < 0) errors.age = 'Age must be positive';
93
+ return errors;
94
+ }
95
+ }
96
+ ```
97
+
98
+ ### Bind to a Component
99
+
100
+ ```tsx
101
+ import { useModel } from 'mvc-kit/react';
102
+
103
+ function UserForm() {
104
+ const { state, errors, valid, dirty, model } = useModel(
105
+ () => new FormModel({ name: '', email: '', age: 0 })
106
+ );
107
+
108
+ return (
109
+ <form>
110
+ <input value={state.name} onChange={e => model.setName(e.target.value)} />
111
+ {errors.name && <span className="error">{errors.name}</span>}
112
+
113
+ <input value={state.email} onChange={e => model.setEmail(e.target.value)} />
114
+ {errors.email && <span className="error">{errors.email}</span>}
115
+
116
+ <button disabled={!valid || !dirty}>Save</button>
117
+ </form>
118
+ );
119
+ }
120
+ ```
121
+
122
+ `state.x` for raw values. `errors.x` for field errors. `model.setX()` for mutations. `valid` and `dirty` for button/form state.
123
+
124
+ ---
125
+
126
+ ## Model Inside a ViewModel
127
+
128
+ The typical form-page pattern: a [ViewModel](../ViewModel.md) handles async operations (load, save, delete) and coordination, while the Model handles the editing state. The ViewModel owns the Model and disposes it in `onDispose()`.
129
+
130
+ ```typescript
131
+ interface EditState {
132
+ draft: UserState | null;
133
+ }
134
+
135
+ interface EditEvents {
136
+ saved: { id: string };
137
+ }
138
+
139
+ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
140
+ public model!: UserFormModel;
141
+ private service = singleton(UserService);
142
+
143
+ protected async onInit() {
144
+ const user = await this.service.getById(this.userId, this.disposeSignal);
145
+ this.model = new UserFormModel(user);
146
+ this.set({ draft: user });
147
+ }
148
+
149
+ async save() {
150
+ if (!this.model.valid) return;
151
+ await this.service.update(this.userId, this.model.state, this.disposeSignal);
152
+ this.model.commit();
153
+ this.emit('saved', { id: this.userId });
154
+ }
155
+
156
+ protected onDispose() {
157
+ this.model?.dispose();
158
+ }
159
+ }
160
+ ```
161
+
162
+ ```tsx
163
+ function EditUserPage() {
164
+ const [state, vm] = useLocal(EditUserViewModel, { draft: null });
165
+ const loadState = vm.async.onInit;
166
+ const saveState = vm.async.save;
167
+
168
+ if (loadState.loading || !vm.model) return <Spinner />;
169
+ if (loadState.error) return <ErrorBanner message={loadState.error} />;
170
+
171
+ return <EditUserForm model={vm.model} onSave={() => vm.save()} saving={saveState.loading} />;
172
+ }
173
+
174
+ function EditUserForm({ model, onSave, saving }: Props) {
175
+ const { state, errors, valid, dirty } = useModel(() => model);
176
+
177
+ return (
178
+ <form onSubmit={e => { e.preventDefault(); onSave(); }}>
179
+ <input value={state.name} onChange={e => model.setName(e.target.value)} />
180
+ {errors.name && <span>{errors.name}</span>}
181
+ <button disabled={!valid || !dirty || saving}>
182
+ {saving ? 'Saving...' : 'Save'}
183
+ </button>
184
+ </form>
185
+ );
186
+ }
187
+ ```
188
+
189
+ ---
190
+
191
+ ## useModelRef
192
+
193
+ Create a component-scoped Model with lifecycle management (init + dispose) but **no state subscription**. The parent component never re-renders from model state changes.
194
+
195
+ Designed for the per-field isolation pattern: parent creates the model via `useModelRef`, children subscribe to individual fields via `useField`.
196
+
197
+ ### Signature
198
+
199
+ ```typescript
200
+ function useModelRef<M extends Model<any>>(factory: () => M): M;
201
+ ```
202
+
203
+ | Parameter | Type | Description |
204
+ |---|---|---|
205
+ | `factory` | `() => M` | Factory that returns a Model instance. Called once on mount (or if the previous instance was disposed). |
206
+
207
+ **Returns:** `M` — the Model instance directly. No `state`, `errors`, `valid`, or `dirty` — read those in child components via `useField` or `useInstance`.
208
+
209
+ ### Lifecycle
210
+
211
+ Same as `useModel`: auto-calls `init()` on mount, auto-calls `dispose()` on unmount (deferred for StrictMode safety). The only difference is **no `useSyncExternalStore` subscription** — the parent never re-renders from model changes.
212
+
213
+ ### Usage — Per-Field Form
214
+
215
+ ```tsx
216
+ import { Model } from 'mvc-kit';
217
+ import { useModelRef, useField, useInstance } from 'mvc-kit/react';
218
+
219
+ class FormModel extends Model<FormState> {
220
+ protected validate(state: FormState): ValidationErrors<FormState> {
221
+ const errors: ValidationErrors<FormState> = {};
222
+ if (!state.name) errors.name = 'Name is required';
223
+ if (!state.email.includes('@')) errors.email = 'Invalid email';
224
+ return errors;
225
+ }
226
+ }
227
+
228
+ // Parent — creates model, never re-renders from field changes
229
+ function UserForm() {
230
+ const model = useModelRef(() => new FormModel({ name: '', email: '' }));
231
+ return (
232
+ <form>
233
+ <NameField model={model} />
234
+ <EmailField model={model} />
235
+ <FormActions model={model} />
236
+ </form>
237
+ );
238
+ }
239
+
240
+ // Per-field child — only re-renders when its field changes
241
+ function NameField({ model }: { model: FormModel }) {
242
+ const { value, error, set } = useField(model, 'name');
243
+ return (
244
+ <div>
245
+ <input value={value} onChange={e => set(e.target.value)} />
246
+ {error && <span className="error">{error}</span>}
247
+ </div>
248
+ );
249
+ }
250
+
251
+ // Submit button — subscribes to full model for valid/dirty
252
+ function FormActions({ model }: { model: FormModel }) {
253
+ useInstance(model);
254
+ return <button disabled={!model.valid || !model.dirty}>Save</button>;
255
+ }
256
+ ```
257
+
258
+ ### When to Use useModelRef vs useModel
259
+
260
+ | Scenario | Hook | Why |
261
+ |---|---|---|
262
+ | Simple form (few fields, one component) | `useModel` | Component needs `state`, `errors`, `valid`, `dirty` directly |
263
+ | Large form (many fields, per-field components) | `useModelRef` | Parent doesn't subscribe — only changed fields re-render |
264
+ | Submit button / form actions | `useInstance(model)` | Subscribe to full model without owning lifecycle |
265
+
266
+ > **Important:** Don't use `useModel` in the parent when children use `useField` — `useModel` subscribes to all state changes, which defeats per-field isolation. The parent would re-render on every keystroke, negating the benefit.
267
+
268
+ ---
269
+
270
+ ## useField
271
+
272
+ Subscribe to a **single field** with surgical re-renders. The component only re-renders when that specific field's value or error changes — other fields updating on the same Model are ignored.
273
+
274
+ ### Signature
275
+
276
+ ```typescript
277
+ function useField<S extends object, K extends keyof S>(
278
+ model: Model<S>,
279
+ field: K
280
+ ): FieldHandle<S[K]>;
281
+ ```
282
+
283
+ | Parameter | Type | Description |
284
+ |---|---|---|
285
+ | `model` | `Model<S>` | The Model instance (must already be created — typically via `useModelRef`, `useModel`, or a ViewModel). |
286
+ | `field` | `K` | The state key to subscribe to. Type-safe — invalid keys produce a compile-time error. |
287
+
288
+ ### FieldHandle
289
+
290
+ | Property | Type | Description |
291
+ |---|---|---|
292
+ | `value` | `S[K]` | Current value of the field. |
293
+ | `error` | `string \| undefined` | Validation error for this field, or `undefined` if valid. |
294
+ | `set` | `(value: S[K]) => void` | Update the field value directly via `model.set({ [field]: value })`. |
295
+
296
+ ### Usage
297
+
298
+ ```tsx
299
+ import { useField } from 'mvc-kit/react';
300
+
301
+ function NameField({ model }: { model: FormModel }) {
302
+ const { value, error, set } = useField(model, 'name');
303
+
304
+ return (
305
+ <div>
306
+ <input value={value} onChange={e => set(e.target.value)} />
307
+ {error && <span className="error">{error}</span>}
308
+ </div>
309
+ );
310
+ }
311
+ ```
312
+
313
+ ### When to Use useField vs useModel vs useModelRef
314
+
315
+ | Scenario | Hook |
316
+ |---|---|
317
+ | Simple form with a few fields | `useModel` — one component renders everything |
318
+ | Large form with many fields | `useModelRef` in parent + `useField` in children — only changed fields re-render |
319
+ | Reading `valid` / `dirty` for submit button state | `useInstance(model)` in a leaf component — subscribe without owning lifecycle |
320
+ | Individual field input + error display | `useField` — surgical re-renders |
321
+
322
+ ### Selective Re-renders
323
+
324
+ `useField` compares field value and error by reference (`!==`) on every Model notification. If a different field changes, the comparison short-circuits and no re-render occurs:
325
+
326
+ ```tsx
327
+ // Only re-renders when `name` value or `name` error changes.
328
+ // Changing `email` or `age` on the same model does NOT trigger a re-render here.
329
+ const { value, error, set } = useField(model, 'name');
330
+ ```
331
+
332
+ ### Type Safety
333
+
334
+ `useField` rejects invalid field names at compile time:
335
+
336
+ ```typescript
337
+ useField(model, 'name'); // OK
338
+ useField(model, 'email'); // OK
339
+ useField(model, 'invalidField'); // Compile error — 'invalidField' is not a key of FormState
340
+ ```
341
+
342
+ ---
343
+
344
+ ## Best Practices
345
+
346
+ **Use named setters on the Model, not `set()` directly.** `set()` is protected. Expose intent-based setters (`setName`, `setEmail`) on your Model subclass.
347
+
348
+ **Read `state.x` for values, `errors.x` for errors.** Don't derive validation in the component — the Model handles it automatically via `validate()`.
349
+
350
+ **Use `valid` and `dirty` for button state.** Disable submit when `!valid || !dirty`. After a successful save, call `model.commit()` to reset `dirty` to `false`.
351
+
352
+ **Split large forms into per-field components with `useField`.** Each field component only re-renders when its own value or error changes.
353
+
354
+ **Let the ViewModel own the Model for form pages.** The ViewModel handles load/save/delete. The Model handles editing. The component just renders. See [Model inside a ViewModel](#model-inside-a-viewmodel).
355
+
356
+ **Don't put async logic in a Model.** Models are for synchronous editing state (values, validation, dirty tracking). Async operations (save, load, delete) belong in a [ViewModel](../ViewModel.md) that owns the Model.
357
+
358
+ ---
359
+
360
+ ## Related
361
+
362
+ - [Model](../Model.md) — the base class: validation, dirty tracking, commit/rollback
363
+ - [ViewModel](../ViewModel.md) — async tracking, computed getters, events
364
+ - [useLocal](./use-local.ts) — component-scoped ViewModel hook (the ViewModel equivalent of `useModel`)