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
package/src/Model.md ADDED
@@ -0,0 +1,524 @@
1
+ # Model
2
+
3
+ Reactive entity with field-level validation, dirty tracking, and commit/rollback semantics. Use `Model` for create and edit forms where you need to know which fields are invalid, whether the user has made changes, and the ability to revert or save those changes.
4
+
5
+ ---
6
+
7
+ ## When to Use Model vs ViewModel
8
+
9
+ | Use a **Model** when... | Use a **ViewModel** when... |
10
+ |---|---|
11
+ | Editing a single entity | Managing UI state, computed properties, and actions for a component |
12
+ | You need field-level validation errors | You need async tracking, derived getters, or collection subscriptions |
13
+ | You need dirty tracking (commit/rollback) | You need imperative events or lifecycle-driven data loading |
14
+
15
+ Models are often **owned by a ViewModel** — the ViewModel handles async operations (save, delete) while the Model handles the form's editing state.
16
+
17
+ ---
18
+
19
+ ## Defining a Model
20
+
21
+ Extend `Model<S>` with your state shape. `Model` is abstract — you must subclass it.
22
+
23
+ ```typescript
24
+ import { Model } from 'mvc-kit';
25
+ import type { ValidationErrors } from 'mvc-kit';
26
+
27
+ interface UserFormState {
28
+ name: string;
29
+ email: string;
30
+ age: number;
31
+ }
32
+
33
+ class UserFormModel extends Model<UserFormState> {
34
+ setName(name: string) { this.set({ name }); }
35
+ setEmail(email: string) { this.set({ email }); }
36
+ setAge(age: number) { this.set({ age }); }
37
+
38
+ protected validate(state: Readonly<UserFormState>): ValidationErrors<UserFormState> {
39
+ const errors: ValidationErrors<UserFormState> = {};
40
+ if (!state.name.trim()) errors.name = 'Name is required';
41
+ if (!state.email.includes('@')) errors.email = 'Invalid email';
42
+ if (state.age < 0 || state.age > 150) errors.age = 'Age must be between 0 and 150';
43
+ return errors;
44
+ }
45
+ }
46
+ ```
47
+
48
+ ---
49
+
50
+ ## State
51
+
52
+ ### Initialization
53
+
54
+ State is passed to the constructor and frozen immediately. Both `state` and `committed` point to the same frozen snapshot.
55
+
56
+ ```typescript
57
+ const model = new UserFormModel({ name: 'Alice', email: 'alice@example.com', age: 30 });
58
+
59
+ model.state; // { name: 'Alice', email: 'alice@example.com', age: 30 }
60
+ model.committed; // same reference as state
61
+ model.dirty; // false
62
+ ```
63
+
64
+ ### Updating State
65
+
66
+ Call `set()` (protected) with a partial object, an updater function, or a draft mutator. The method is protected — expose it through named setter methods on your subclass.
67
+
68
+ ```typescript
69
+ // Partial object
70
+ this.set({ name: 'Bob' });
71
+
72
+ // Updater function — return a partial
73
+ this.set(prev => ({ age: prev.age + 1 }));
74
+
75
+ // Draft mode — mutate the draft, return nothing
76
+ this.set(draft => { draft.age = draft.age + 1 });
77
+ ```
78
+
79
+ When using the function form, the state is wrapped in a copy-on-write draft proxy (see [`produceDraft`](./produceDraft.md)). If the function returns an object, it's used as the partial. If `void`, the draft mutations are applied. Structural sharing is preserved and arrays must be replaced via assignment.
80
+
81
+ `set()` behavior:
82
+ - **Shallow equality check** — if no values actually changed, the update is skipped and no listeners fire.
83
+ - **Immutable** — produces a new frozen object via `Object.freeze({ ...prev, ...partial })`.
84
+ - **Notifies listeners** — all subscribers receive `(nextState, prevState)` after every real change.
85
+ - **Throws after dispose** — calling `set()` on a disposed Model throws `'Cannot set state on disposed Model'`.
86
+
87
+ ### Reading State
88
+
89
+ ```typescript
90
+ model.state; // current frozen state
91
+ model.committed; // baseline state for dirty comparison
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Dirty Tracking
97
+
98
+ A Model is **dirty** when its current `state` differs from its `committed` baseline. The comparison is shallow equality across all keys.
99
+
100
+ ```typescript
101
+ const model = new UserFormModel({ name: 'Alice', email: 'alice@example.com', age: 30 });
102
+
103
+ model.dirty; // false
104
+
105
+ model.setName('Bob');
106
+ model.dirty; // true — name changed from committed
107
+
108
+ model.setName('Alice');
109
+ model.dirty; // false — back to committed values
110
+ ```
111
+
112
+ ### commit()
113
+
114
+ Marks the current state as the new baseline. After `commit()`, `dirty` becomes `false` and `committed` equals `state`.
115
+
116
+ ```typescript
117
+ model.setName('Bob');
118
+ model.dirty; // true
119
+
120
+ model.commit();
121
+ model.dirty; // false
122
+ model.committed.name; // 'Bob'
123
+ ```
124
+
125
+ Use this after a successful save to mark the persisted state as the new clean baseline.
126
+
127
+ ### rollback()
128
+
129
+ Reverts state to the committed baseline. If already at the committed state, it's a no-op (no listeners fire).
130
+
131
+ ```typescript
132
+ model.setName('Bob');
133
+ model.setAge(25);
134
+ model.state.name; // 'Bob'
135
+
136
+ model.rollback();
137
+ model.state.name; // 'Alice' (reverted to committed)
138
+ model.dirty; // false
139
+ ```
140
+
141
+ Rollback **notifies listeners** (unless state was already at committed), making it suitable for a "Discard changes" button.
142
+
143
+ Both `commit()` and `rollback()` throw on a disposed Model.
144
+
145
+ ---
146
+
147
+ ## Validation
148
+
149
+ Override the `validate()` method to provide field-level validation. Return an object mapping field keys to error message strings. An empty object means valid.
150
+
151
+ ```typescript
152
+ protected validate(state: Readonly<UserFormState>): ValidationErrors<UserFormState> {
153
+ const errors: ValidationErrors<UserFormState> = {};
154
+ if (!state.name.trim()) errors.name = 'Name is required';
155
+ if (!state.email.includes('@')) errors.email = 'Invalid email';
156
+ return errors;
157
+ }
158
+ ```
159
+
160
+ ### Reading Validation
161
+
162
+ ```typescript
163
+ model.errors; // { name: 'Name is required', email: 'Invalid email' }
164
+ model.valid; // false (Object.keys(errors).length === 0)
165
+ ```
166
+
167
+ Both `errors` and `valid` are **computed on access** from the current `state`. When state changes, validation re-evaluates automatically — no manual call needed.
168
+
169
+ If you don't override `validate()`, the default returns `{}` (always valid).
170
+
171
+ ---
172
+
173
+ ## Lifecycle
174
+
175
+ ### init()
176
+
177
+ Calling `init()` sets `initialized` to `true` and invokes the `onInit()` hook. It is **idempotent** — subsequent calls are no-ops. It is also a **no-op after dispose**.
178
+
179
+ ```typescript
180
+ class LoadableModel extends Model<UserFormState> {
181
+ protected onInit() {
182
+ // Set initial derived state, subscribe to external sources, etc.
183
+ this.set({ name: 'Initialized' });
184
+ }
185
+ }
186
+
187
+ const model = new LoadableModel({ name: '', email: '', age: 0 });
188
+ model.initialized; // false
189
+ model.init();
190
+ model.initialized; // true
191
+ model.state.name; // 'Initialized'
192
+ ```
193
+
194
+ `onInit()` can be **async** — `init()` returns the promise so callers can await it:
195
+
196
+ ```typescript
197
+ protected async onInit() {
198
+ const data = await fetchInitialData(this.disposeSignal);
199
+ this.set(data);
200
+ }
201
+
202
+ await model.init();
203
+ ```
204
+
205
+ ### dispose()
206
+
207
+ Tears down the Model in this order:
208
+ 1. Sets `disposed` to `true`
209
+ 2. Aborts the `disposeSignal` (if it was accessed)
210
+ 3. Runs all registered cleanup functions
211
+ 4. Calls `onDispose()` hook
212
+ 5. Clears all listeners
213
+
214
+ Dispose is **idempotent** — calling it multiple times is safe.
215
+
216
+ ```typescript
217
+ model.dispose();
218
+ model.disposed; // true
219
+ ```
220
+
221
+ After dispose:
222
+ - `set()` throws
223
+ - `commit()` throws
224
+ - `rollback()` throws
225
+ - `subscribe()` returns a no-op unsubscribe function
226
+
227
+ ### onSet(prev, next)
228
+
229
+ Optional hook called after every state change (from both `set()` and `rollback()`). Receives the previous and next state.
230
+
231
+ ```typescript
232
+ protected onSet(prev: Readonly<State>, next: Readonly<State>) {
233
+ if (prev.email !== next.email) {
234
+ console.log('Email changed to', next.email);
235
+ }
236
+ }
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Subscriptions
242
+
243
+ ### subscribe(listener)
244
+
245
+ Register a listener that fires on every state change with `(nextState, prevState)`. Returns an unsubscribe function.
246
+
247
+ ```typescript
248
+ const unsub = model.subscribe((next, prev) => {
249
+ console.log('Changed from', prev, 'to', next);
250
+ });
251
+
252
+ model.setName('Bob'); // listener fires
253
+
254
+ unsub();
255
+ model.setName('Carol'); // listener does NOT fire
256
+ ```
257
+
258
+ Model implements `Subscribable<S>`, so it can be used with `subscribeTo` on ViewModels and other Models.
259
+
260
+ ### subscribeTo(source, listener)
261
+
262
+ Subscribe to an external `Subscribable` source (Collection, another Model, Channel, etc.) with automatic cleanup on dispose.
263
+
264
+ ```typescript
265
+ class OrderModel extends Model<OrderState> {
266
+ setup(priceCollection: Collection<PriceItem>) {
267
+ this.subscribeTo(priceCollection, (items) => {
268
+ this.set({ prices: items });
269
+ });
270
+ }
271
+ }
272
+ ```
273
+
274
+ Also returns an unsubscribe function for manual early cleanup.
275
+
276
+ ### listenTo(source, event, handler)
277
+
278
+ Subscribe to a typed event on a Channel or EventBus with automatic cleanup on dispose. The event counterpart to `subscribeTo`.
279
+
280
+ ```typescript
281
+ protected onInit() {
282
+ this.listenTo(this.bus, 'fieldUpdate', (update) => {
283
+ this.set({ [update.field]: update.value });
284
+ });
285
+ }
286
+ ```
287
+
288
+ ### addCleanup(fn)
289
+
290
+ Register a cleanup function that runs on `dispose()`. Used internally by `subscribeTo`, but also available for custom teardown.
291
+
292
+ ```typescript
293
+ protected onInit() {
294
+ const interval = setInterval(() => this.tick(), 1000);
295
+ this.addCleanup(() => clearInterval(interval));
296
+ }
297
+ ```
298
+
299
+ ---
300
+
301
+ ## Cancellation with disposeSignal
302
+
303
+ `disposeSignal` is a lazily-created `AbortSignal` that aborts when the Model is disposed. Pass it to `fetch()` or any async API to cancel in-flight operations on teardown.
304
+
305
+ ```typescript
306
+ protected async onInit() {
307
+ const res = await fetch('/api/data', { signal: this.disposeSignal });
308
+ this.set({ data: await res.json() });
309
+ }
310
+ ```
311
+
312
+ - **Lazy** — the AbortController is only created when `disposeSignal` is first accessed. Zero cost if never used.
313
+ - **Stable** — returns the same signal on every access.
314
+ - **Aborted before onDispose** — by the time `onDispose()` runs, the signal is already aborted.
315
+
316
+ ---
317
+
318
+ ## React Integration
319
+
320
+ ### useModel
321
+
322
+ Binds a Model to a component. Creates the instance from a factory, auto-initializes on mount, auto-disposes on unmount, and subscribes to state changes.
323
+
324
+ ```tsx
325
+ import { useModel } from 'mvc-kit/react';
326
+
327
+ function EditUserForm() {
328
+ const { state, errors, valid, dirty, model } = useModel(
329
+ () => new UserFormModel({ name: '', email: '', age: 0 })
330
+ );
331
+
332
+ return (
333
+ <form>
334
+ <input value={state.name} onChange={e => model.setName(e.target.value)} />
335
+ {errors.name && <span className="error">{errors.name}</span>}
336
+
337
+ <input value={state.email} onChange={e => model.setEmail(e.target.value)} />
338
+ {errors.email && <span className="error">{errors.email}</span>}
339
+
340
+ <button disabled={!valid || !dirty}>Save</button>
341
+ </form>
342
+ );
343
+ }
344
+ ```
345
+
346
+ The returned `ModelHandle` provides:
347
+
348
+ | Property | Type | Description |
349
+ |---|---|---|
350
+ | `state` | `S` | Current frozen state |
351
+ | `errors` | `ValidationErrors<S>` | Validation errors keyed by field |
352
+ | `valid` | `boolean` | `true` when no validation errors |
353
+ | `dirty` | `boolean` | `true` when state differs from committed |
354
+ | `model` | `M` | The Model instance for calling setters |
355
+
356
+ ### useField
357
+
358
+ Subscribes to a **single field** with surgical re-renders — the component only re-renders when that specific field's value or error changes. Use this for forms with many fields to avoid re-rendering the entire form on every keystroke.
359
+
360
+ ```tsx
361
+ import { useField } from 'mvc-kit/react';
362
+
363
+ function NameField({ model }: { model: UserFormModel }) {
364
+ const { value, error, set } = useField(model, 'name');
365
+
366
+ return (
367
+ <div>
368
+ <input value={value} onChange={e => set(e.target.value)} />
369
+ {error && <span className="error">{error}</span>}
370
+ </div>
371
+ );
372
+ }
373
+ ```
374
+
375
+ The returned `FieldHandle` provides:
376
+
377
+ | Property | Type | Description |
378
+ |---|---|---|
379
+ | `value` | `S[K]` | Current value of the field |
380
+ | `error` | `string \| undefined` | Validation error for the field |
381
+ | `set` | `(value: S[K]) => void` | Update the field value |
382
+
383
+ `useField` is type-safe — invalid field names produce a compile-time error.
384
+
385
+ ---
386
+
387
+ ## Model Inside a ViewModel
388
+
389
+ The typical pattern for a form page: the ViewModel handles async operations and coordination, the Model handles editing state.
390
+
391
+ ```typescript
392
+ interface EditState {
393
+ draft: UserState | null;
394
+ }
395
+
396
+ interface EditEvents {
397
+ saved: { id: string };
398
+ }
399
+
400
+ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
401
+ public model!: UserFormModel;
402
+ private service = singleton(UserService);
403
+
404
+ protected async onInit() {
405
+ const user = await this.service.getById(this.userId, this.disposeSignal);
406
+ this.model = new UserFormModel(user);
407
+ this.set({ draft: user });
408
+ }
409
+
410
+ async save() {
411
+ if (!this.model.valid) return;
412
+ await this.service.update(this.userId, this.model.state, this.disposeSignal);
413
+ this.model.commit();
414
+ this.emit('saved', { id: this.userId });
415
+ }
416
+
417
+ protected onDispose() {
418
+ this.model?.dispose();
419
+ }
420
+ }
421
+ ```
422
+
423
+ The component:
424
+
425
+ ```tsx
426
+ function EditUserPage() {
427
+ const [state, vm] = useLocal(EditUserViewModel, { draft: null });
428
+ const loadState = vm.async.onInit;
429
+ const saveState = vm.async.save;
430
+
431
+ if (loadState.loading || !vm.model) return <Spinner />;
432
+ if (loadState.error) return <ErrorBanner message={loadState.error} />;
433
+
434
+ return <EditUserForm model={vm.model} onSave={() => vm.save()} saving={saveState.loading} />;
435
+ }
436
+
437
+ function EditUserForm({ model, onSave, saving }: Props) {
438
+ const { state, errors, valid, dirty } = useModel(() => model);
439
+
440
+ return (
441
+ <form onSubmit={e => { e.preventDefault(); onSave(); }}>
442
+ <input value={state.name} onChange={e => model.setName(e.target.value)} />
443
+ {errors.name && <span>{errors.name}</span>}
444
+ <button disabled={!valid || !dirty || saving}>
445
+ {saving ? 'Saving...' : 'Save'}
446
+ </button>
447
+ </form>
448
+ );
449
+ }
450
+ ```
451
+
452
+ ---
453
+
454
+ ## Singleton Usage
455
+
456
+ Models can be registered as singletons for app-wide entity state (rare, but supported):
457
+
458
+ ```typescript
459
+ import { singleton, teardownAll } from 'mvc-kit';
460
+
461
+ const model = singleton(UserFormModel, { name: 'Alice', email: 'alice@example.com', age: 30 });
462
+ ```
463
+
464
+ The initial state is only used on the first call. Subsequent `singleton()` calls return the existing instance regardless of the state argument passed.
465
+
466
+ ---
467
+
468
+ ## API Reference
469
+
470
+ ### Constructor
471
+
472
+ | Signature | Description |
473
+ |---|---|
474
+ | `constructor(initialState: S)` | Creates a new Model with frozen initial state. Sets both `state` and `committed` to the frozen snapshot. |
475
+
476
+ ### Properties
477
+
478
+ | Property | Type | Description |
479
+ |---|---|---|
480
+ | `state` | `S` | Current frozen state |
481
+ | `committed` | `S` | Baseline state for dirty comparison |
482
+ | `dirty` | `boolean` | `true` if `state` differs from `committed` (shallow equality) |
483
+ | `errors` | `ValidationErrors<S>` | Validation errors for current state |
484
+ | `valid` | `boolean` | `true` if `errors` is empty |
485
+ | `disposed` | `boolean` | `true` after `dispose()` |
486
+ | `initialized` | `boolean` | `true` after `init()` |
487
+ | `disposeSignal` | `AbortSignal` | Lazily-created signal, aborted on dispose |
488
+
489
+ ### Methods
490
+
491
+ | Method | Description |
492
+ |---|---|
493
+ | `init()` | Set `initialized = true` and call `onInit()`. Idempotent. No-op after dispose. Returns `void \| Promise<void>`. |
494
+ | `commit()` | Set `committed = state`. Throws if disposed. |
495
+ | `rollback()` | Revert `state` to `committed`. Notifies listeners. No-op if already at committed. Throws if disposed. |
496
+ | `subscribe(listener)` | Register a `(next, prev) => void` listener. Returns unsubscribe function. Returns no-op if disposed. |
497
+ | `dispose()` | Tear down: abort signal, run cleanups, call `onDispose()`, clear listeners. Idempotent. |
498
+
499
+ ### Protected Methods
500
+
501
+ | Method | Description |
502
+ |---|---|
503
+ | `set(partial \| updater \| drafter)` | Update state via object, updater function, or draft mutator. Skips if no values changed. Throws if disposed. |
504
+ | `validate(state)` | Override to return field-level errors. Default returns `{}`. |
505
+ | `addCleanup(fn)` | Register a function to run on dispose. |
506
+ | `subscribeTo(source, listener)` | Subscribe to a `Subscribable` with auto-cleanup. Returns unsubscribe. |
507
+ | `listenTo(source, event, handler)` | Subscribe to a typed event (Channel/EventBus) with auto-cleanup. Returns unsubscribe. |
508
+
509
+ ### Lifecycle Hooks
510
+
511
+ | Hook | Description |
512
+ |---|---|
513
+ | `onInit()` | Called by `init()`. Can be async. |
514
+ | `onSet(prev, next)` | Called after every state change. |
515
+ | `onDispose()` | Called during `dispose()`, after signal abort and cleanups. |
516
+
517
+ ## Method Binding
518
+
519
+ All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
520
+
521
+ ```tsx
522
+ const { commit, rollback } = model;
523
+ <button onClick={commit}>Save</button> // point-free works
524
+ ```