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,1111 @@
1
+ # ViewModel
2
+
3
+ The core building block for UI state management. A ViewModel holds state, exposes computed getters, tracks async operations, emits typed events, and provides a complete reactive interface for a single component.
4
+
5
+ ---
6
+
7
+ ## Role
8
+
9
+ The ViewModel is the complete interface between a component and everything else. It encapsulates services, collections, subscriptions, async orchestration, and derived computations. The component never imports infrastructure — it reads state, reads getters, reads async status, and calls methods.
10
+
11
+ ```
12
+ Component → ViewModel → Service / Collection / EventBus / Channel
13
+ ```
14
+
15
+ One ViewModel per connected component. If a component needs two ViewModels, split it into two components.
16
+
17
+ ---
18
+
19
+ ## Generic Parameters
20
+
21
+ ```typescript
22
+ class MyViewModel extends ViewModel<S, E = {}>
23
+ ```
24
+
25
+ | Parameter | Purpose |
26
+ |---|---|
27
+ | `S extends object` | The state shape. Must be a plain object. |
28
+ | `E extends Record<string, any>` | Optional typed event map for imperative events. Defaults to `{}` (no events). |
29
+
30
+ ---
31
+
32
+ ## Creating a ViewModel
33
+
34
+ ### Subclass and Define State
35
+
36
+ ```typescript
37
+ interface State {
38
+ items: Item[];
39
+ search: string;
40
+ typeFilter: 'all' | 'office' | 'warehouse';
41
+ }
42
+
43
+ class LocationsViewModel extends ViewModel<State> {
44
+ // ...
45
+ }
46
+ ```
47
+
48
+ ### Construction
49
+
50
+ The constructor accepts the initial state object. State is frozen on construction.
51
+
52
+ ```typescript
53
+ const vm = new LocationsViewModel({
54
+ items: [],
55
+ search: '',
56
+ typeFilter: 'all',
57
+ });
58
+ ```
59
+
60
+ State is always immutable — `Object.freeze` is applied on every state transition.
61
+
62
+ ---
63
+
64
+ ## Properties
65
+
66
+ | Property | Type | Description |
67
+ |---|---|---|
68
+ | `state` | `S` | Current frozen state object |
69
+ | `disposed` | `boolean` | Whether `dispose()` has been called |
70
+ | `initialized` | `boolean` | Whether `init()` has been called |
71
+ | `disposeSignal` | `AbortSignal` | Lazily created; aborted on `dispose()` |
72
+ | `events` | `EventBus<E>` | Lazily created typed event bus |
73
+ | `async` | `AsyncMap<this>` | Proxy returning `TaskState` per async method |
74
+
75
+ ---
76
+
77
+ ## State Management
78
+
79
+ ### set(partial) — protected
80
+
81
+ Merges partial state into current state. Produces a new frozen state object and notifies all subscribers (React re-renders).
82
+
83
+ ```typescript
84
+ // Partial object
85
+ this.set({ search: 'alice' });
86
+
87
+ // Updater function — return a partial
88
+ this.set(prev => ({ count: prev.count + 1 }));
89
+
90
+ // Draft mode — mutate the draft, return nothing
91
+ this.set(draft => { draft.count = draft.count + 1 });
92
+ ```
93
+
94
+ 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, that object is used as the partial (existing updater behavior). If the function returns `void`, the draft mutations are collected and applied. Both styles can be used interchangeably — pick whichever reads better for the update at hand.
95
+
96
+ Draft mode is especially useful for nested state updates:
97
+
98
+ ```typescript
99
+ this.set(draft => { draft.filters.status = 'active' });
100
+ // vs.
101
+ this.set(prev => ({ filters: { ...prev.filters, status: 'active' } }));
102
+ ```
103
+
104
+ Structural sharing is preserved — unchanged subtrees keep their original references. Arrays must be replaced via assignment (in-place `push`/`splice` is not detected).
105
+
106
+ **Shallow equality check:** If no values actually changed by reference, `set()` is a no-op — no new state object, no notification, no re-render.
107
+
108
+ ```typescript
109
+ // Given state { count: 0, name: 'test' }
110
+ this.set({ count: 0 }); // no-op — value unchanged
111
+ ```
112
+
113
+ **After dispose:** `set()` is a silent no-op. This is intentional — in-flight async callbacks that resolve after dispose can safely call `set()` without crashing.
114
+
115
+ ### State is Always Frozen
116
+
117
+ Every state object is `Object.freeze`'d. Direct mutation throws:
118
+
119
+ ```typescript
120
+ vm.state.search = 'test'; // TypeError: Cannot assign to read only property
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Lifecycle
126
+
127
+ ### init()
128
+
129
+ Initializes the ViewModel. Sets up auto-memoized getters, subscribable auto-tracking, method wrapping for async tracking, and calls `onInit()`. Idempotent — calling it multiple times has no effect. No-op if already disposed.
130
+
131
+ ```typescript
132
+ vm.init();
133
+ vm.init(); // no-op
134
+ ```
135
+
136
+ Supports async initialization:
137
+
138
+ ```typescript
139
+ class MyVM extends ViewModel<State> {
140
+ protected async onInit() {
141
+ const data = await this.service.getAll(this.disposeSignal);
142
+ this.collection.reset(data);
143
+ }
144
+ }
145
+
146
+ await vm.init();
147
+ ```
148
+
149
+ When using `useLocal`, `init()` is called automatically after mount — you never call it manually in components.
150
+
151
+ ### Init Sequence
152
+
153
+ When `init()` is called, the following happens in order:
154
+
155
+ 1. `_trackSubscribables()` — detects subscribable members and wires up auto-tracking
156
+ 2. `_installStateProxy()` — installs context-sensitive state getter for dependency tracking
157
+ 3. `_processMembers()` — memoizes getters and wraps methods for async tracking (delegates to shared `wrapAsyncMethods`)
158
+ 4. `onInit()` — user-defined initialization hook
159
+
160
+ ### dispose()
161
+
162
+ Tears down the ViewModel:
163
+
164
+ 1. Sets `disposed` to `true`
165
+ 2. Tears down all subscribable and `subscribeTo` subscriptions
166
+ 3. Aborts `disposeSignal`
167
+ 4. Runs all `addCleanup()` callbacks (including async tracking cleanup)
168
+ 5. Disposes the event bus (if created)
169
+ 6. Calls `onDispose()`
170
+ 7. Clears all state listeners
171
+
172
+ Idempotent — safe to call multiple times. `onDispose()` only runs once.
173
+
174
+ ### reset(newState?)
175
+
176
+ Tears down the current lifecycle and re-initializes without unmounting the component. Accepts an optional new state; defaults to the original state passed to the constructor.
177
+
178
+ ```typescript
179
+ // Reset to initial state
180
+ vm.reset();
181
+
182
+ // Reset with new state
183
+ vm.reset({ userId: newId, data: null });
184
+ ```
185
+
186
+ Reset performs the following in order:
187
+
188
+ 1. Aborts the current `disposeSignal` (nulls the AbortController — a fresh one is lazily created on next access)
189
+ 2. Tears down all subscriptions (subscribable + `subscribeTo`)
190
+ 3. Resets state to `newState` or `initialState`
191
+ 4. Clears all async tracking (states + snapshots), notifies async listeners
192
+ 5. Re-runs `_trackSubscribables()` (fresh subscriptions to subscribable members)
193
+ 6. Notifies state listeners (React re-renders with new state)
194
+ 7. Re-runs `onInit()`
195
+
196
+ **After dispose:** `reset()` is a no-op.
197
+
198
+ **Key detail:** The old `disposeSignal` is aborted, so any in-flight fetch using it will throw `AbortError` and be swallowed. The new `disposeSignal` (accessed after reset) is a fresh, unaborted signal.
199
+
200
+ ```typescript
201
+ const oldSignal = vm.disposeSignal;
202
+ vm.reset();
203
+ oldSignal.aborted; // true
204
+ vm.disposeSignal.aborted; // false (new signal)
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Lifecycle Hooks
210
+
211
+ ### onInit?(): void | Promise<void> — protected
212
+
213
+ Called during `init()` after all internal setup is complete. This is where you kick off initial loads and set up imperative subscriptions.
214
+
215
+ ```typescript
216
+ protected onInit() {
217
+ // Smart init: skip fetch if another ViewModel already loaded the data
218
+ if (this.collection.length === 0) this.load();
219
+ }
220
+ ```
221
+
222
+ Collection data is typically accessed via getters that read from collection members directly — auto-tracking handles reactivity without needing `subscribeTo()` + `set()`. See `BEST_PRACTICES.md` for the full pattern.
223
+
224
+ Can be async — `init()` returns the promise so callers can `await` it.
225
+
226
+ ### onSet?(prev, next): void — protected
227
+
228
+ Called after every successful `set()` with the previous and next state. Useful for side-effect logic that reacts to specific state transitions.
229
+
230
+ ```typescript
231
+ protected onSet(prev: Readonly<State>, next: Readonly<State>) {
232
+ if (prev.search !== next.search) {
233
+ this.analytics.track('search', { query: next.search });
234
+ }
235
+ }
236
+ ```
237
+
238
+ Not called when `set()` is a no-op (values unchanged).
239
+
240
+ ### onDispose?(): void — protected
241
+
242
+ Called once during `dispose()`, after the signal is aborted, cleanups have fired, and the event bus is disposed.
243
+
244
+ ```typescript
245
+ protected onDispose() {
246
+ this.model?.dispose();
247
+ }
248
+ ```
249
+
250
+ ---
251
+
252
+ ## Computed Getters (Auto-Memoized)
253
+
254
+ TypeScript `get` accessors compute derived values from state and subscribable members. After `init()`, all subclass getters are automatically wrapped with a memoization layer that tracks dependencies and caches results.
255
+
256
+ ```typescript
257
+ get filtered(): Item[] {
258
+ const { items, search, statusFilter } = this.state;
259
+ let result = items;
260
+ if (search) {
261
+ const q = search.toLowerCase();
262
+ result = result.filter(i => i.name.toLowerCase().includes(q));
263
+ }
264
+ if (statusFilter !== 'all') {
265
+ result = result.filter(i => i.status === statusFilter);
266
+ }
267
+ return result;
268
+ }
269
+
270
+ get total(): number {
271
+ return this.state.items.length;
272
+ }
273
+
274
+ get hasResults(): boolean {
275
+ return this.filtered.length > 0;
276
+ }
277
+ ```
278
+
279
+ ### Dependency Tracking
280
+
281
+ When a memoized getter executes, a proxy records which `this.state` properties are accessed. These become the getter's dependencies. The getter only recomputes when one of those specific properties changes — not on any state change.
282
+
283
+ ```typescript
284
+ // This getter depends on state.search and state.statusFilter
285
+ get filtered() {
286
+ const { search, statusFilter } = this.state;
287
+ // ...
288
+ }
289
+
290
+ // Changing state.loading does NOT invalidate `filtered`
291
+ this.set({ loading: true }); // filtered returns cached value
292
+ ```
293
+
294
+ ### Subscribable Dependencies
295
+
296
+ Getters can also depend on subscribable members (Collections, Channels, other ViewModels). The memoization system auto-detects subscribable instance properties and tracks their revisions.
297
+
298
+ ```typescript
299
+ class MyVM extends ViewModel<State> {
300
+ collection = new UsersCollection();
301
+
302
+ get filtered(): User[] {
303
+ return this.collection.items.filter(u => u.name.includes(this.state.search));
304
+ }
305
+ }
306
+ ```
307
+
308
+ When `collection` notifies (e.g., after `reset()`), the getter's cache invalidates and it recomputes on next access.
309
+
310
+ ### Three-Tier Cache
311
+
312
+ The memoization system uses a three-tier validation strategy:
313
+
314
+ | Tier | Check | Cost | When |
315
+ |---|---|---|---|
316
+ | **Tier 1** | Global revision unchanged | 1 integer compare | No `set()` or subscribable notification since last access |
317
+ | **Tier 2** | Revision changed but this getter's deps didn't | Check each dep by reference | Unrelated state or subscribable changed |
318
+ | **Tier 3** | At least one dep changed | Full recompute with tracking | Actual dependency changed |
319
+
320
+ This means:
321
+ - Accessing the same getter multiple times in one render is effectively free (Tier 1).
322
+ - Changing unrelated state properties doesn't recompute the getter (Tier 2).
323
+ - Only actual dependency changes trigger recomputation (Tier 3).
324
+
325
+ ### Conditional Dependencies
326
+
327
+ Dependencies are re-tracked on every recomputation. If a getter has conditional branches, its dependencies update based on which branch runs:
328
+
329
+ ```typescript
330
+ get display(): string {
331
+ if (this.state.mode === 'compact') return this.state.title;
332
+ return `${this.state.title} — ${this.state.subtitle}`;
333
+ }
334
+ ```
335
+
336
+ In `compact` mode, changing `subtitle` does NOT invalidate the getter (it wasn't accessed). After switching to `full` mode, `subtitle` becomes a dependency and changes will trigger recomputation.
337
+
338
+ ### Getter Composition
339
+
340
+ Getters can call other getters. Dependency tracking bubbles up correctly:
341
+
342
+ ```typescript
343
+ get filtered(): Item[] { /* depends on state.search, state.statusFilter, collection */ }
344
+ get hasResults(): boolean { return this.filtered.length > 0; } // inherits filtered's deps
345
+ ```
346
+
347
+ When `state.search` changes, both `filtered` and `hasResults` recompute.
348
+
349
+ ### Getter Inheritance
350
+
351
+ Getters from parent classes are memoized. If a subclass overrides a parent getter, only the most-derived version is wrapped.
352
+
353
+ ```typescript
354
+ class BaseVM extends ViewModel<State> {
355
+ get isEmpty(): boolean { return this.state.items.length === 0; }
356
+ }
357
+
358
+ class DerivedVM extends BaseVM {
359
+ get filtered(): Item[] { /* ... */ }
360
+ }
361
+ // Both isEmpty and filtered are memoized
362
+ ```
363
+
364
+ ### Error Handling in Getters
365
+
366
+ If a getter throws, the error propagates and the cache is NOT populated. The next access will recompute (and throw again if the underlying condition hasn't changed).
367
+
368
+ ```typescript
369
+ get data(): Item {
370
+ if (!this.state.item) throw new Error('No item loaded');
371
+ return this.state.item;
372
+ }
373
+ ```
374
+
375
+ ### After Dispose
376
+
377
+ Getter access after dispose returns the last cached value without re-executing the getter body. This prevents errors from stale data access during cleanup.
378
+
379
+ ### Rules
380
+
381
+ - **Never call `set()` inside a getter.** It creates an infinite loop. Dev mode detects this and logs an error (the `set()` call is blocked).
382
+ - **Getters must be pure.** They read state and return a value. No side effects.
383
+ - **Before `init()`**, getters work as plain (unmemoized) accessors.
384
+
385
+ ---
386
+
387
+ ## Subscribable Auto-Detection
388
+
389
+ After `init()`, the ViewModel scans all own instance properties. Any property that has a `subscribe` method (duck-typed) is treated as a subscribable member and auto-tracked.
390
+
391
+ ```typescript
392
+ class MyVM extends ViewModel<State> {
393
+ collection = singleton(UsersCollection); // auto-tracked ✓
394
+ channel = singleton(ChatChannel); // auto-tracked ✓
395
+ plainObject = { value: 42 }; // ignored (no subscribe method)
396
+ }
397
+ ```
398
+
399
+ When a subscribable member notifies:
400
+
401
+ 1. The ViewModel's internal revision counter increments
402
+ 2. Memoized getter caches that depend on that member invalidate
403
+ 3. A new state reference is forced (even though values are unchanged) so React's `useSyncExternalStore` detects the change
404
+ 4. State listeners fire, triggering a re-render
405
+
406
+ If the member also has `subscribeAsync` (ViewModels and Resources), async state changes (loading/error transitions) also trigger getter invalidation. This means a parent ViewModel can have a getter like `get isChildLoading() { return this.childVM.async.loadData.loading; }` and it will update automatically.
407
+
408
+ **EventBus does NOT have `subscribe`** — it won't be auto-tracked (by design). Use `subscribeTo` for explicit subscriptions.
409
+
410
+ ### Multiple Subscribable Members
411
+
412
+ Each subscribable member is tracked independently. A getter that reads `collection1` but not `collection2` won't recompute when `collection2` changes.
413
+
414
+ ---
415
+
416
+ ## Async Tracking
417
+
418
+ After `init()`, all subclass methods are automatically wrapped. When a method returns a Promise, the framework tracks its loading and error state.
419
+
420
+ ### How It Works
421
+
422
+ ```typescript
423
+ async load() {
424
+ const data = await this.service.getAll(this.disposeSignal);
425
+ this.collection.reset(data);
426
+ }
427
+ ```
428
+
429
+ When `load()` is called:
430
+
431
+ 1. `vm.async.load` becomes `{ loading: true, error: null, errorCode: null }`
432
+ 2. Async listeners are notified (React re-renders)
433
+ 3. On resolve: `vm.async.load` becomes `{ loading: false, error: null, errorCode: null }`
434
+ 4. On reject: error is classified via `classifyError()` — `vm.async.load.error` gets the message, `errorCode` gets a discriminant code
435
+ 5. On `AbortError`: silently swallowed — not captured as an error, not re-thrown
436
+
437
+ ### TaskState
438
+
439
+ Each tracked async method has a frozen `TaskState` snapshot:
440
+
441
+ ```typescript
442
+ interface TaskState {
443
+ readonly loading: boolean;
444
+ readonly error: string | null;
445
+ readonly errorCode: AppError['code'] | null;
446
+ // errorCode: 'unauthorized' | 'forbidden' | 'not_found' | 'validation' |
447
+ // 'rate_limited' | 'server_error' | 'network' | 'timeout' |
448
+ // 'abort' | 'unknown' | null
449
+ }
450
+ ```
451
+
452
+ Default (before the method is ever called): `{ loading: false, error: null, errorCode: null }`.
453
+
454
+ ### Reading Async State
455
+
456
+ ```typescript
457
+ const { loading, error, errorCode } = vm.async.load;
458
+ ```
459
+
460
+ The `async` property is a Proxy. Accessing any key returns the `TaskState` for that method, or the default if the method hasn't been called.
461
+
462
+ ### Snapshot Reference Stability
463
+
464
+ Each `TaskState` is a frozen object. The reference changes on every status update (loading start, loading end, error). This enables React's `useSyncExternalStore` to detect changes via reference comparison.
465
+
466
+ ### Concurrent Calls
467
+
468
+ Loading state uses a counter. If the same method is called multiple times concurrently, `loading` stays `true` until ALL calls resolve:
469
+
470
+ ```typescript
471
+ const p1 = vm.load(); // loading: true (count: 1)
472
+ const p2 = vm.load(); // loading: true (count: 2)
473
+ await p1; // loading: true (count: 1)
474
+ await p2; // loading: false (count: 0)
475
+ ```
476
+
477
+ ### Error Clearing
478
+
479
+ When a method is retried after failure, `error` and `errorCode` are cleared at the start of the new call:
480
+
481
+ ```typescript
482
+ await vm.load().catch(() => {});
483
+ vm.async.load.error; // 'something went wrong'
484
+
485
+ vm.load(); // immediately: { loading: true, error: null, errorCode: null }
486
+ ```
487
+
488
+ ### Return Value Preservation
489
+
490
+ Wrapped methods preserve their return values:
491
+
492
+ ```typescript
493
+ async fetchData(): Promise<string> {
494
+ return 'data';
495
+ }
496
+
497
+ const result = await vm.fetchData(); // 'data'
498
+ ```
499
+
500
+ Errors are re-thrown (except AbortError) so standard Promise rejection behavior is preserved:
501
+
502
+ ```typescript
503
+ await expect(vm.failingMethod()).rejects.toThrow('something went wrong');
504
+ ```
505
+
506
+ ### AbortError Handling
507
+
508
+ AbortErrors are special-cased **at the method wrapper level**:
509
+ - **Not captured** as an error in `TaskState` (error stays `null`)
510
+ - **Not re-thrown** by the wrapper (the outer promise resolves to `undefined`)
511
+ - **Loading counter decremented** normally
512
+
513
+ This means navigating away from a component (which aborts in-flight fetches via `disposeSignal`) produces no error state and no unhandled rejections.
514
+
515
+ **For methods without try/catch**, this is all you need — write the happy path and AbortErrors are fully automatic.
516
+
517
+ **For methods with explicit try/catch**, your catch block **does** receive AbortErrors. The wrapper intercepts at the outer promise level (after your method's promise settles), not inside your function body. Use `isAbortError()` in the catch block to guard side effects that shouldn't run on abort — particularly rollback functions and other operations on shared state like Collections. `set()` and `emit()` are already no-ops after dispose and don't need guarding.
518
+
519
+ ```typescript
520
+ // No try/catch — AbortError is fully automatic:
521
+ async load() {
522
+ const data = await this.service.getAll(this.disposeSignal);
523
+ this.collection.reset(data);
524
+ }
525
+
526
+ // Explicit try/catch with shared-state side effects — guard needed:
527
+ async delete(id: string) {
528
+ const rollback = this.collection.optimistic(() => {
529
+ this.collection.remove(id);
530
+ });
531
+ try {
532
+ await this.service.delete(id, this.disposeSignal);
533
+ } catch (e) {
534
+ if (!isAbortError(e)) rollback(); // don't roll back on abort
535
+ throw e;
536
+ }
537
+ }
538
+ ```
539
+
540
+ ### Sync Method Pruning
541
+
542
+ Methods that return synchronous values on first call are **auto-pruned** from async tracking. The wrapper detects the non-thenable return, removes the method from async maps, and replaces itself with a direct `bind` of the original — zero overhead on subsequent calls.
543
+
544
+ ```typescript
545
+ syncMethod(): number {
546
+ return 42;
547
+ }
548
+
549
+ vm.syncMethod(); // first call: detected as sync, pruned
550
+ vm.syncMethod(); // subsequent calls: direct bound call, no wrapping overhead
551
+ ```
552
+
553
+ ### What Gets Wrapped
554
+
555
+ All methods on the subclass prototype chain (up to but not including `ViewModel.prototype`) are wrapped, **except**:
556
+
557
+ - Getters and setters (managed by memoization)
558
+ - `_`-prefixed methods (private convention)
559
+ - Lifecycle hooks: `onInit`, `onSet`, `onDispose`
560
+ - `constructor`
561
+
562
+ ### subscribeAsync(listener)
563
+
564
+ Subscribes to async state changes. Returns an unsubscribe function. Used internally by `useInstance` to trigger React re-renders when async status changes.
565
+
566
+ ```typescript
567
+ const unsub = vm.subscribeAsync(() => {
568
+ // any async method's TaskState changed
569
+ });
570
+ ```
571
+
572
+ Returns a no-op unsubscribe if called after dispose.
573
+
574
+ ---
575
+
576
+ ## Async Tracking Does NOT Invalidate Getters
577
+
578
+ Async state notifications (`_notifyAsync`) are separate from state notifications. They do NOT bump the revision counter. This means:
579
+
580
+ - Getter caches are NOT invalidated when async status changes
581
+ - Only `set()` calls and subscribable member notifications invalidate getters
582
+ - This prevents unnecessary recomputation of derived state when only loading/error status changes
583
+
584
+ ---
585
+
586
+ ## Events
587
+
588
+ ViewModels support typed imperative events via the second generic parameter. Events are for one-shot signals that don't belong in state: toast notifications, navigation redirects, scroll-to-error, shake animations.
589
+
590
+ ### Defining Events
591
+
592
+ ```typescript
593
+ interface Events {
594
+ saved: { id: string };
595
+ deleted: { id: string };
596
+ validationFailed: void;
597
+ }
598
+
599
+ class ItemViewModel extends ViewModel<State, Events> {
600
+ async save() {
601
+ const result = await this.service.save(this.state.draft, this.disposeSignal);
602
+ this.emit('saved', { id: result.id });
603
+ }
604
+ }
605
+ ```
606
+
607
+ ### emit(event, payload) — protected
608
+
609
+ Dispatches a typed event. Only the ViewModel can emit — components cannot.
610
+
611
+ ```typescript
612
+ this.emit('saved', { id: result.id });
613
+ ```
614
+
615
+ **After dispose:** `emit()` is a no-op (after the event bus is disposed). However, cleanup callbacks registered via `addCleanup()` run before the event bus is disposed, so they can still emit:
616
+
617
+ ```typescript
618
+ class MyVM extends ViewModel<State, Events> {
619
+ setup() {
620
+ this.addCleanup(() => {
621
+ this.emit('saved', { id: 'final' }); // works — bus not yet disposed
622
+ });
623
+ }
624
+ }
625
+ ```
626
+
627
+ ### events
628
+
629
+ Lazily-created `EventBus<E>`. Zero cost if the ViewModel never emits. Auto-disposed with the ViewModel.
630
+
631
+ ```typescript
632
+ vm.events.on('saved', ({ id }) => {
633
+ toast.success(`Saved ${id}`);
634
+ });
635
+ ```
636
+
637
+ ### Lazy Allocation
638
+
639
+ If the `events` getter is never accessed and `emit()` is never called, no EventBus is allocated. This keeps non-eventing ViewModels zero-cost.
640
+
641
+ ---
642
+
643
+ ## Subscriptions
644
+
645
+ ### subscribe(listener)
646
+
647
+ Subscribes to state changes. Returns an unsubscribe function. Listeners receive `(nextState, prevState)`.
648
+
649
+ ```typescript
650
+ const unsub = vm.subscribe((next, prev) => {
651
+ console.log(`count: ${prev.count} → ${next.count}`);
652
+ });
653
+ ```
654
+
655
+ Returns a no-op unsubscribe if called after dispose.
656
+
657
+ Used internally by `useInstance` / `useSyncExternalStore` for React integration.
658
+
659
+ ### subscribeTo(source, listener) — protected
660
+
661
+ Subscribes to any `Subscribable` source (Collection, ViewModel, Channel) with automatic cleanup on dispose. Returns an unsubscribe function for manual early cleanup.
662
+
663
+ Use `subscribeTo()` for **imperative reactions** (side effects on change). For deriving values from collections, use getters instead — auto-tracking handles reactivity automatically.
664
+
665
+ ```typescript
666
+ protected onInit() {
667
+ // Imperative reaction: play a sound when messages arrive
668
+ this.subscribeTo(this.messagesCollection, () => {
669
+ this.playNotificationSound();
670
+ });
671
+ }
672
+ ```
673
+
674
+ ### listenTo(source, event, handler) — protected
675
+
676
+ Subscribes to a typed event on a Channel or EventBus with automatic cleanup on dispose and reset. The event counterpart to `subscribeTo`.
677
+
678
+ ```typescript
679
+ protected onInit() {
680
+ this.listenTo(this.channel, 'message', (msg) => {
681
+ this.set({ messages: [...this.state.messages, msg] });
682
+ });
683
+
684
+ this.listenTo(this.bus, 'auth:logout', () => {
685
+ this.set({ messages: [] });
686
+ });
687
+ }
688
+ ```
689
+
690
+ Returns an unsubscribe function for manual early cleanup. Stored in `_subscriptionCleanups` — torn down on both `dispose()` and `reset()`, then re-established when `onInit()` re-runs.
691
+
692
+ ### pipeChannel(channel, type, target) — protected
693
+
694
+ Bridges a Channel event directly into a Collection via `upsert`. Calls `channel.init()` (idempotent) and registers the listener with auto-cleanup on dispose and reset.
695
+
696
+ ```typescript
697
+ protected onInit() {
698
+ this.pipeChannel(this.channel, 'data', this.collection);
699
+
700
+ if (this.appState.state.online) {
701
+ this.channel.connect();
702
+ }
703
+ }
704
+ ```
705
+
706
+ Equivalent to:
707
+
708
+ ```typescript
709
+ this.channel.init();
710
+ this.listenTo(this.channel, 'data', (payload) => {
711
+ this.collection.upsert(payload);
712
+ });
713
+ ```
714
+
715
+ Use `pipeChannel` when every message should be upserted as-is. If you need to transform, filter, or route messages to different targets, use `listenTo` directly.
716
+
717
+ Returns an unsubscribe function, consistent with `subscribeTo` and `listenTo`.
718
+
719
+ ### addCleanup(fn) — protected
720
+
721
+ Registers a cleanup function that runs on dispose. Use this for teardown that isn't covered by `subscribeTo` or `listenTo` (e.g., timers, intervals).
722
+
723
+ ```typescript
724
+ protected onInit() {
725
+ const id = setInterval(() => this.poll(), 5000);
726
+ this.addCleanup(() => clearInterval(id));
727
+ }
728
+ ```
729
+
730
+ ---
731
+
732
+ ## disposeSignal
733
+
734
+ A lazily-created `AbortSignal` that aborts when the ViewModel is disposed. Pass it to `fetch()` or any async API that accepts a signal.
735
+
736
+ ```typescript
737
+ async load() {
738
+ const data = await this.service.getAll(this.disposeSignal);
739
+ this.collection.reset(data);
740
+ }
741
+ ```
742
+
743
+ - **Lazy** — zero cost if never accessed. No AbortController allocated unless you read `disposeSignal`.
744
+ - **Stable** — returns the same signal on every access.
745
+ - **Aborted before `onDispose()` runs** — cleanup code can check it.
746
+
747
+ For per-call cancellation, compose signals:
748
+
749
+ ```typescript
750
+ async loadRoom(roomId: string, callSignal: AbortSignal) {
751
+ const res = await fetch(`/api/rooms/${roomId}`, {
752
+ signal: AbortSignal.any([this.disposeSignal, callSignal]),
753
+ });
754
+ this.set({ messages: await res.json() });
755
+ }
756
+ ```
757
+
758
+ ---
759
+
760
+ ## Reserved Keys
761
+
762
+ The following property names are reserved and cannot be overridden by subclasses:
763
+
764
+ - `async` — used by the async tracking proxy
765
+ - `subscribeAsync` — used by async state subscription
766
+
767
+ Attempting to define these as methods, getters, or class fields throws:
768
+
769
+ ```
770
+ [mvc-kit] "async" is a reserved property on ViewModel and cannot be overridden.
771
+ ```
772
+
773
+ The check runs in the constructor (prototype scan) and during `init()` (instance property scan).
774
+
775
+ ---
776
+
777
+ ## Dev Mode
778
+
779
+ When `__MVC_KIT_DEV__` is enabled, additional safety checks activate:
780
+
781
+ ### set() inside a getter
782
+
783
+ Detects and blocks `set()` calls during getter evaluation. Logs an error and prevents the state mutation:
784
+
785
+ ```
786
+ [mvc-kit] set() called inside a getter. Getters must be pure — they read state
787
+ and return a value. They must never call set(), which would cause an infinite
788
+ render loop. Move this logic to an action method.
789
+ ```
790
+
791
+ ### Method call after dispose
792
+
793
+ Wrapped methods log a warning and return `undefined`:
794
+
795
+ ```
796
+ [mvc-kit] "load" called after dispose — ignored.
797
+ ```
798
+
799
+ ### Method call before init
800
+
801
+ Wrapped methods log a warning (but still execute):
802
+
803
+ ```
804
+ [mvc-kit] "load" called before init(). Async tracking is active only after init().
805
+ ```
806
+
807
+ ### Ghost async operations
808
+
809
+ When a ViewModel is disposed with pending async calls, a delayed warning fires after `GHOST_TIMEOUT` (default 3000ms):
810
+
811
+ ```
812
+ [mvc-kit] Ghost async operation detected: "load" had 1 pending call(s)
813
+ when the ViewModel was disposed. Consider using disposeSignal to cancel
814
+ in-flight work.
815
+ ```
816
+
817
+ Override `static GHOST_TIMEOUT` on your subclass to change the delay:
818
+
819
+ ```typescript
820
+ class MyVM extends ViewModel<State> {
821
+ static override GHOST_TIMEOUT = 5000;
822
+ }
823
+ ```
824
+
825
+ ### static DEFAULT_STATE
826
+
827
+ Declare `static DEFAULT_STATE` on singleton ViewModels so `singleton()` and `useSingleton()` can be called without constructor arguments. The value is used as the initial state when no args are passed:
828
+
829
+ ```typescript
830
+ class AuthViewModel extends ViewModel<AuthState> {
831
+ static DEFAULT_STATE: AuthState = { user: null, isAuthenticated: false };
832
+ }
833
+
834
+ // No args needed — DEFAULT_STATE is used automatically
835
+ private auth = singleton(AuthViewModel);
836
+ const [state, vm] = useSingleton(AuthViewModel);
837
+ ```
838
+
839
+ ---
840
+
841
+ ## React Integration
842
+
843
+ ### useLocal
844
+
845
+ Creates a component-scoped ViewModel, auto-initialized and auto-disposed.
846
+
847
+ ```tsx
848
+ function LocationsPage() {
849
+ const [state, vm] = useLocal(LocationsViewModel, {
850
+ items: [],
851
+ search: '',
852
+ typeFilter: 'all',
853
+ });
854
+ const { loading, error } = vm.async.load;
855
+
856
+ return (
857
+ <div>
858
+ <input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
859
+ {loading && <Spinner />}
860
+ {error && <ErrorBanner message={error} />}
861
+ <LocationsTable locations={vm.filtered} />
862
+ </div>
863
+ );
864
+ }
865
+ ```
866
+
867
+ **With deps:** Dispose and recreate when dependencies change.
868
+
869
+ ```tsx
870
+ const [state, vm] = useLocal(LocationsViewModel, { userId, data: null }, [userId]);
871
+ ```
872
+
873
+ **Factory overload:**
874
+
875
+ ```tsx
876
+ const [state, vm] = useLocal(() => new ChatViewModel(roomId), [roomId]);
877
+ ```
878
+
879
+ ### useEvent
880
+
881
+ Subscribes to ViewModel events.
882
+
883
+ ```tsx
884
+ useEvent(vm, 'saved', ({ id }) => {
885
+ toast.success(`Saved ${id}`);
886
+ });
887
+ ```
888
+
889
+ ### How Re-renders Work
890
+
891
+ `useLocal` uses `useSyncExternalStore` internally via two subscriptions:
892
+
893
+ 1. **Combined subscription** — a single `useSyncExternalStore` with a version counter. Both `subscribe` and `subscribeAsync` (if present) increment the counter to trigger re-renders. `subscribable.state` is read directly in the render body.
894
+
895
+ This means components re-render when:
896
+ - State changes via `set()`
897
+ - A subscribable member notifies (Collection reset, Channel status change)
898
+ - An async method starts, completes, or fails
899
+
900
+ ---
901
+
902
+ ## Inheritance
903
+
904
+ ### Getters
905
+
906
+ Getters from all levels of the class hierarchy are memoized. Most-derived wins:
907
+
908
+ ```typescript
909
+ class ParentVM extends ViewModel<State> {
910
+ get label(): string { return 'parent'; }
911
+ }
912
+
913
+ class ChildVM extends ParentVM {
914
+ override get label(): string { return this.state.name; } // this version is memoized
915
+ }
916
+ ```
917
+
918
+ ### Methods
919
+
920
+ Methods from all levels are wrapped for async tracking. Most-derived wins:
921
+
922
+ ```typescript
923
+ class ParentVM extends ViewModel<State> {
924
+ async load(): Promise<string> { return 'parent'; }
925
+ }
926
+
927
+ class ChildVM extends ParentVM {
928
+ async load(): Promise<string> { return 'child'; } // this version is tracked
929
+ }
930
+ ```
931
+
932
+ ### Multiple Instances
933
+
934
+ Two instances of the same ViewModel class have completely independent state, caches, and async tracking. They do not share anything.
935
+
936
+ ---
937
+
938
+ ## Section Order Convention
939
+
940
+ Organize every ViewModel consistently for scannability:
941
+
942
+ ```typescript
943
+ export class LocationsViewModel extends ViewModel<State, Events> {
944
+ // --- Private fields ---
945
+ private service = singleton(LocationService);
946
+ private collection = singleton(LocationsCollection);
947
+
948
+ // --- Computed getters ---
949
+ get filtered(): Item[] { /* ... */ }
950
+ get total(): number { /* ... */ }
951
+
952
+ // --- Lifecycle ---
953
+ protected onInit() { /* ... */ }
954
+
955
+ // --- Actions ---
956
+ async load() { /* ... */ }
957
+ async refresh() { /* ... */ }
958
+
959
+ // --- Setters ---
960
+ setSearch(search: string) { this.set({ search }); }
961
+ setTypeFilter(typeFilter: State['typeFilter']) { this.set({ typeFilter }); }
962
+ }
963
+ ```
964
+
965
+ **Private fields → Computed getters → Lifecycle → Actions → Setters.**
966
+
967
+ ---
968
+
969
+ ## Best Practices
970
+
971
+ **State holds only source-of-truth values.** User inputs and data from the server. Never derived values (use getters), never loading/error flags (use `vm.async`).
972
+
973
+ **Write the happy path for async methods.** No try/catch, no loading flags, no abort checks. The framework handles all of it.
974
+
975
+ ```typescript
976
+ async load() {
977
+ const data = await this.service.getAll(this.disposeSignal);
978
+ this.collection.reset(data);
979
+ }
980
+ ```
981
+
982
+ **Pass `disposeSignal` to every async call.** Ensures in-flight requests cancel on unmount.
983
+
984
+ **Use `subscribeTo` for Collection subscriptions.** It auto-cleans up on dispose. **Use `listenTo` for Channel/EventBus event subscriptions.** It auto-cleans up on both dispose and reset.
985
+
986
+ **Check `collection.length > 0` in `onInit` before fetching.** Another ViewModel may have already loaded the data (smart init pattern).
987
+
988
+ **Setters do one thing — update a single state value.** Derivation happens in getters, not in setters.
989
+
990
+ ```typescript
991
+ setSearch(search: string) { this.set({ search }); }
992
+ ```
993
+
994
+ **Methods are auto-bound.** All public methods are bound to the instance in the constructor, so they can be passed point-free as callbacks without losing `this` context. Both styles are equivalent:
995
+
996
+ ```tsx
997
+ // Arrow wrapper (always worked)
998
+ <SearchBox onChange={v => vm.setSearch(v)} />
999
+
1000
+ // Point-free (works because methods are auto-bound)
1001
+ <SearchBox onChange={vm.setSearch} />
1002
+ ```
1003
+
1004
+ **Re-throw errors in explicit try/catch blocks.** So async tracking still captures them. Use `isAbortError()` to guard side effects on shared state (like Collection rollbacks). `set()` and `emit()` don't need the guard — they're already no-ops after dispose:
1005
+
1006
+ ```typescript
1007
+ // Shared-state side effects — guard needed:
1008
+ catch (e) {
1009
+ if (!isAbortError(e)) rollback();
1010
+ throw e;
1011
+ }
1012
+
1013
+ // Only set()/emit() — no guard needed (no-ops after dispose):
1014
+ catch (e) {
1015
+ this.emit('error', { message: 'Failed' });
1016
+ throw e;
1017
+ }
1018
+ ```
1019
+
1020
+ **Use `reset()` for form resets or route-param changes.** It tears down and re-initializes without unmounting the component.
1021
+
1022
+ ---
1023
+
1024
+ ## Anti-Patterns
1025
+
1026
+ ### Loading/error in state
1027
+
1028
+ ```typescript
1029
+ // Bad: async tracking handles this
1030
+ interface State {
1031
+ loading: boolean;
1032
+ error: string | null;
1033
+ }
1034
+
1035
+ // Good: read from vm.async.load
1036
+ const { loading, error } = vm.async.load;
1037
+ ```
1038
+
1039
+ ### Derived values in state
1040
+
1041
+ ```typescript
1042
+ // Bad: will desync
1043
+ this.set({ filtered: items.filter(...) });
1044
+
1045
+ // Good: getter recomputes automatically
1046
+ get filtered() { return this.state.items.filter(...); }
1047
+ ```
1048
+
1049
+ ### set() inside a getter
1050
+
1051
+ ```typescript
1052
+ // Bad: infinite loop (blocked in DEV mode)
1053
+ get broken() {
1054
+ this.set({ count: 999 });
1055
+ return 'bad';
1056
+ }
1057
+ ```
1058
+
1059
+ ### Manual try/catch for standard loads
1060
+
1061
+ ```typescript
1062
+ // Bad: unnecessary boilerplate
1063
+ async load() {
1064
+ this.set({ loading: true });
1065
+ try {
1066
+ const data = await this.service.getAll(this.disposeSignal);
1067
+ this.set({ items: data, loading: false });
1068
+ } catch (e) { /* ... */ }
1069
+ }
1070
+
1071
+ // Good: write the happy path
1072
+ async load() {
1073
+ const data = await this.service.getAll(this.disposeSignal);
1074
+ this.collection.reset(data);
1075
+ }
1076
+ ```
1077
+
1078
+ ### Swallowing errors without re-throwing
1079
+
1080
+ ```typescript
1081
+ // Bad: async tracking won't capture the error
1082
+ catch (e) {
1083
+ this.emit('error', { message: 'Failed' });
1084
+ // error swallowed — vm.async.save.error stays null
1085
+ }
1086
+
1087
+ // Good: re-throw so tracking works
1088
+ catch (e) {
1089
+ this.emit('error', { message: 'Failed' });
1090
+ throw e;
1091
+ }
1092
+ ```
1093
+
1094
+ Note: `emit()` is a no-op after dispose, so no `isAbortError()` guard is needed here. Use `isAbortError()` only when the catch block has side effects on shared state (like rolling back optimistic updates on a singleton Collection).
1095
+
1096
+ ### Two ViewModels in one component
1097
+
1098
+ ```tsx
1099
+ // Bad: split into two components
1100
+ const [s1, vm1] = useLocal(UsersViewModel, { ... });
1101
+ const [s2, vm2] = useLocal(OnDutyViewModel, { ... });
1102
+ ```
1103
+
1104
+ ### Fetching in useEffect
1105
+
1106
+ ```tsx
1107
+ // Bad: ViewModel handles its own initialization
1108
+ useEffect(() => { vm.load(); }, []);
1109
+
1110
+ // Good: onInit() handles it automatically via useLocal
1111
+ ```