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,457 @@
1
+ # useLocal
2
+
3
+ Creates a component-scoped instance, auto-initialized on mount and auto-disposed on unmount. This is the primary hook for connecting a [ViewModel](../ViewModel.md) or [Controller](../Controller.md) to a React component.
4
+
5
+ ---
6
+
7
+ ## Signatures
8
+
9
+ ### Subscribable (class + initialState)
10
+
11
+ ```typescript
12
+ function useLocal<T extends Subscribable & Disposable>(
13
+ Class: new (initialState: StateOf<T>) => T,
14
+ initialState: StateOf<T>,
15
+ ): [Readonly<StateOf<T>>, T];
16
+ ```
17
+
18
+ ### Subscribable (factory)
19
+
20
+ ```typescript
21
+ function useLocal<T extends Subscribable<S> & Disposable, S>(
22
+ factory: () => T,
23
+ ): [S, T];
24
+ ```
25
+
26
+ ### Disposable-only (class)
27
+
28
+ ```typescript
29
+ function useLocal<T extends Disposable>(
30
+ Class: new (...args: Args) => T,
31
+ ...args: Args,
32
+ ): T;
33
+ ```
34
+
35
+ ### Disposable-only (factory)
36
+
37
+ ```typescript
38
+ function useLocal<T extends Disposable>(
39
+ factory: () => T,
40
+ ): T;
41
+ ```
42
+
43
+ All four signatures accept an optional `deps: DependencyList` as the final argument. See [Dependencies](#dependencies).
44
+
45
+ ---
46
+
47
+ ## Return Value
48
+
49
+ The return type depends on whether the instance implements `Subscribable`:
50
+
51
+ | Instance type | Return | Example |
52
+ |---|---|---|
53
+ | **Subscribable** (ViewModel, Collection, Model) | `[S, T]` — state tuple | `const [state, vm] = useLocal(MyVM, { ... })` |
54
+ | **Subscribe-only** (Trackable) | `T` — the instance, with automatic re-renders | `const query = useLocal(MyQuery)` |
55
+ | **Disposable-only** (Controller) | `T` — the instance directly | `const ctrl = useLocal(MyController)` |
56
+
57
+ The distinction is duck-typed at runtime: if the instance has a `state` property and a `subscribe` method, it's treated as Subscribable (tuple return). If it has `subscribe` but no `state` (e.g., Trackable), it returns the instance directly but sets up a version-counter subscription so changes trigger re-renders.
58
+
59
+ ---
60
+
61
+ ## Lifecycle
62
+
63
+ ### Mount
64
+
65
+ 1. **Instance creation** — the class is constructed (or factory is called) during the render phase. The instance is created once and reused across re-renders via a `useRef`.
66
+ 2. **Initialization** — `init()` is called in a `useEffect` after React commits the render. If the instance has an `init` method (duck-typed via `isInitializable`), it is called automatically. For ViewModels, this triggers `_trackSubscribables`, `_processMembers` (getter memoization + async method wrapping), and `onInit()`.
67
+ 3. **Subscription** — if the instance is Subscribable, `useSyncExternalStore` subscribes to both state changes and async state changes (via `subscribeAsync` when available).
68
+
69
+ ### Re-render
70
+
71
+ The same instance is reused. No new construction or initialization. Re-renders are triggered by:
72
+
73
+ - `set()` calls that produce a new state reference
74
+ - Subscribable member notifications (Collection, Channel)
75
+ - Async method status changes (loading start, completion, error)
76
+
77
+ ### Unmount
78
+
79
+ Dispose is **deferred** via `setTimeout(0)`. This is critical for React StrictMode compatibility — StrictMode's development-only unmount/remount cycle completes before the timeout fires. If the component remounts (StrictMode), the `mountedRef` check prevents disposal. If the component truly unmounts, `dispose()` runs after the timeout.
80
+
81
+ ```
82
+ mount → init() → ... → unmount → setTimeout → dispose()
83
+ ↳ aborts disposeSignal
84
+ ↳ tears down subscriptions
85
+ ↳ runs addCleanup callbacks
86
+ ↳ disposes event bus
87
+ ↳ calls onDispose()
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Usage
93
+
94
+ ### ViewModel with initial state
95
+
96
+ The most common pattern. Pass the class and initial state as separate arguments.
97
+
98
+ ```tsx
99
+ function LocationsPage() {
100
+ const [state, vm] = useLocal(LocationsViewModel, {
101
+ items: [],
102
+ search: '',
103
+ typeFilter: 'all',
104
+ });
105
+ const { loading, error } = vm.async.load;
106
+
107
+ return (
108
+ <div>
109
+ <input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
110
+ {loading && <Spinner />}
111
+ {error && <ErrorBanner message={error} />}
112
+ <LocationsTable locations={vm.filtered} />
113
+ </div>
114
+ );
115
+ }
116
+ ```
117
+
118
+ - `state` — the frozen state object, updated by `set()` calls inside the ViewModel
119
+ - `vm` — the ViewModel instance, used to access getters (`vm.filtered`), async status (`vm.async.load`), and call actions (`vm.setSearch()`)
120
+
121
+ ### ViewModel with factory
122
+
123
+ Use the factory overload when the constructor has a non-standard signature or when you need to capture closure variables.
124
+
125
+ ```tsx
126
+ function ChatPage({ roomId }: { roomId: string }) {
127
+ const [state, vm] = useLocal(() => new ChatViewModel(roomId));
128
+ return <ChatMessages messages={state.messages} />;
129
+ }
130
+ ```
131
+
132
+ ### Controller (Disposable-only)
133
+
134
+ When the instance doesn't implement `Subscribable` (no `state` property or `subscribe` method), `useLocal` returns the instance directly instead of a tuple.
135
+
136
+ ```tsx
137
+ function WorkflowPage() {
138
+ const ctrl = useLocal(DragDropController);
139
+ const [count, setCount] = useState(0);
140
+
141
+ return <button onClick={() => setCount(ctrl.increment())}>Go</button>;
142
+ }
143
+ ```
144
+
145
+ Controllers still get full lifecycle management — `init()` on mount, `dispose()` on unmount.
146
+
147
+ ### Controller with factory
148
+
149
+ ```tsx
150
+ function WorkflowPage({ config }: { config: string }) {
151
+ const ctrl = useLocal(() => new ConfigurableController(config));
152
+ return <div>{ctrl.config}</div>;
153
+ }
154
+ ```
155
+
156
+ ### Trackable (subscribe-only)
157
+
158
+ Custom reactive objects that extend `Trackable` return the instance directly. Changes trigger re-renders via the subscribe-only version-counter path.
159
+
160
+ ```tsx
161
+ function UserSearch() {
162
+ const query = useLocal(() => new RPCQuery<string, User[]>('Users.Search'));
163
+
164
+ return (
165
+ <div>
166
+ {query.loading && <Spinner />}
167
+ {query.data?.map(user => <UserCard key={user.id} user={user} />)}
168
+ <button onClick={() => query.call('alice')}>Search</button>
169
+ </div>
170
+ );
171
+ }
172
+ ```
173
+
174
+ The instance is disposed on unmount and supports `deps` for recreation. See [Trackable](../Trackable.md) for details.
175
+
176
+ ### Collection
177
+
178
+ Collections implement `Subscribable`, so they return a state tuple. The state is the items array.
179
+
180
+ ```tsx
181
+ function TodoList() {
182
+ const [items, collection] = useLocal(TodoCollection);
183
+ return (
184
+ <div>
185
+ <span>{items.length} items</span>
186
+ <button onClick={() => collection.add({ id: '1', text: 'New', done: false })}>
187
+ Add
188
+ </button>
189
+ </div>
190
+ );
191
+ }
192
+ ```
193
+
194
+ ---
195
+
196
+ ## Dependencies
197
+
198
+ All signatures accept an optional `deps` array as the final argument. When any dependency value changes (compared via `Object.is`), the current instance is **disposed** and a **new instance** is created and initialized.
199
+
200
+ ### Class + initialState + deps
201
+
202
+ ```tsx
203
+ function UserPage({ userId }: { userId: string }) {
204
+ const [state, vm] = useLocal(UserViewModel, { userId, data: null }, [userId]);
205
+ const { loading, error } = vm.async.load;
206
+
207
+ return (
208
+ <div>
209
+ {loading && <Spinner />}
210
+ {error && <ErrorBanner message={error} />}
211
+ {state.data && <UserProfile user={state.data} />}
212
+ </div>
213
+ );
214
+ }
215
+ ```
216
+
217
+ When `userId` changes:
218
+
219
+ 1. The old instance is disposed (render-phase synchronous dispose)
220
+ 2. Its `disposeSignal` is aborted — cancelling in-flight fetches
221
+ 3. A new instance is constructed with the new `userId` in initial state
222
+ 4. The `useEffect` cleanup fires for the old instance, the new effect runs `init()`
223
+ 5. `onInit()` executes on the fresh instance with the new state
224
+
225
+ ### Factory + deps
226
+
227
+ ```tsx
228
+ const [state, vm] = useLocal(() => new ChatViewModel(roomId), [roomId]);
229
+ ```
230
+
231
+ ### Disposable + deps
232
+
233
+ Works with Controllers too — the instance is disposed and recreated.
234
+
235
+ ```tsx
236
+ function Room({ roomId }: { roomId: string }) {
237
+ const ctrl = useLocal(() => new TimerController(roomId), [roomId]);
238
+ return <div>{ctrl.id}</div>;
239
+ }
240
+ ```
241
+
242
+ ### Multiple dependencies
243
+
244
+ Any element changing triggers dispose/recreate.
245
+
246
+ ```tsx
247
+ const [state, vm] = useLocal(MultiVM, { a, b, data: null }, [a, b]);
248
+ ```
249
+
250
+ ### Stable deps
251
+
252
+ When deps haven't changed (by `Object.is`), the same instance is reused — no disposal, no reconstruction.
253
+
254
+ ### Rapid dep changes
255
+
256
+ If deps change multiple times before effects settle, each intermediate instance is disposed synchronously during the render phase. Only the final instance survives. This is safe because:
257
+
258
+ - Render-phase dispose aborts the `disposeSignal` on each stale instance
259
+ - In-flight fetches using `disposeSignal` throw `AbortError` and are swallowed
260
+ - `set()` on a disposed instance is a no-op — stale responses can't corrupt the active instance
261
+
262
+ ```
263
+ userId: 1 → 2 → 3 → 4
264
+
265
+ Instance 1: created → disposed (render phase)
266
+ Instance 2: created → disposed (render phase)
267
+ Instance 3: created → disposed (render phase)
268
+ Instance 4: created → init() → active ✓
269
+ ```
270
+
271
+ ---
272
+
273
+ ## React StrictMode
274
+
275
+ `useLocal` is fully compatible with React StrictMode's development-only double-mount cycle.
276
+
277
+ **How it works:** Dispose is deferred via `setTimeout(0)`. During StrictMode's unmount/remount:
278
+
279
+ 1. First mount: instance created, effect runs `init()`
280
+ 2. StrictMode unmount: cleanup schedules deferred dispose
281
+ 3. StrictMode remount: `mountedRef` is set to `true` before the timeout fires
282
+ 4. Timeout fires: `mountedRef` is `true`, so dispose is skipped
283
+
284
+ The result:
285
+
286
+ - `onInit()` is called exactly once
287
+ - `disposeSignal` is NOT aborted during the fake unmount
288
+ - The instance survives the StrictMode cycle intact
289
+
290
+ On real unmount, `mountedRef` stays `false` and dispose proceeds normally.
291
+
292
+ ---
293
+
294
+ ## Async Status
295
+
296
+ When using `useLocal` with a [ViewModel](../ViewModel.md), async method status is automatically tracked and triggers re-renders.
297
+
298
+ ```tsx
299
+ function ItemPage() {
300
+ const [state, vm] = useLocal(ItemViewModel, { items: [] });
301
+ const loadState = vm.async.load;
302
+ const saveState = vm.async.save;
303
+
304
+ return (
305
+ <div>
306
+ {loadState.loading && <Spinner />}
307
+ {loadState.error && <ErrorBanner message={loadState.error} />}
308
+ <form onSubmit={e => { e.preventDefault(); vm.save(); }}>
309
+ <button disabled={saveState.loading}>
310
+ {saveState.loading ? 'Saving…' : 'Save'}
311
+ </button>
312
+ {saveState.error && <p className="error">{saveState.error}</p>}
313
+ </form>
314
+ </div>
315
+ );
316
+ }
317
+ ```
318
+
319
+ `useLocal` subscribes to async state changes via `subscribeAsync()` (duck-typed). When any async method's `TaskState` changes, React re-renders. See [ViewModel — Async Tracking](../ViewModel.md#async-tracking) for details.
320
+
321
+ ---
322
+
323
+ ## Stale Response Protection
324
+
325
+ Two safety layers prevent disposed instances from corrupting the active UI:
326
+
327
+ ### Layer 1: disposeSignal
328
+
329
+ Pass `this.disposeSignal` to `fetch()` and service calls. When the instance is disposed, the signal aborts, `fetch` throws `AbortError`, and async tracking swallows it silently.
330
+
331
+ ```typescript
332
+ async load() {
333
+ const data = await this.service.getUser(userId, this.disposeSignal);
334
+ this.set({ data }); // never reached if signal was aborted
335
+ }
336
+ ```
337
+
338
+ ### Layer 2: set() no-op after dispose
339
+
340
+ Even without `disposeSignal`, `set()` on a disposed instance is a silent no-op. If a slow response arrives after the instance was disposed, the `set()` call does nothing.
341
+
342
+ ```typescript
343
+ // Even if this fetch completes after dispose, set() is harmless
344
+ async load() {
345
+ const res = await fetch(`/api/users/${userId}`);
346
+ const data = await res.json();
347
+ this.set({ data }); // no-op if disposed
348
+ }
349
+ ```
350
+
351
+ **Best practice:** Always use `disposeSignal` (Layer 1). It cancels the network request early, saving bandwidth and preventing unnecessary work. Layer 2 is defense-in-depth.
352
+
353
+ ---
354
+
355
+ ## Error Isolation
356
+
357
+ When using deps, each instance has independent async tracking. An error on one instance does not bleed into the next.
358
+
359
+ ```
360
+ userId: "bad-user" → "good-user"
361
+
362
+ Instance 1 (bad-user): load() → 500 error → vm.async.load.error = "Internal Server Error"
363
+ ↳ disposed on dep change
364
+ Instance 2 (good-user): load() → success → vm.async.load.error = null (clean slate)
365
+ ```
366
+
367
+ The new instance starts with a fresh `{ loading: false, error: null, errorCode: null }` for every method.
368
+
369
+ ---
370
+
371
+ ## Signal Propagation
372
+
373
+ `disposeSignal` propagates through the entire call chain — from the ViewModel through services to the underlying `fetch()`. When deps change and the old instance is disposed:
374
+
375
+ - All `fetch()` calls using `disposeSignal` abort at the network level
376
+ - Multiple parallel fetches (e.g., `Promise.all`) all abort
377
+ - `AbortError` is swallowed by async tracking — no unhandled rejections
378
+
379
+ ```typescript
380
+ async load() {
381
+ // Both fetches abort when disposeSignal fires
382
+ const [data, profile] = await Promise.all([
383
+ fetch(`/api/users/${userId}`, { signal: this.disposeSignal }).then(r => r.json()),
384
+ fetch(`/api/profiles/${userId}`, { signal: this.disposeSignal }).then(r => r.json()),
385
+ ]);
386
+ this.set({ data, profile });
387
+ }
388
+ ```
389
+
390
+ ---
391
+
392
+ ## Best Practices
393
+
394
+ **One `useLocal` per component.** If a component needs two ViewModels, split it into two components. Each connected component owns exactly one ViewModel.
395
+
396
+ **Don't call `init()` manually.** `useLocal` calls it automatically after mount. Don't double-init.
397
+
398
+ **Don't call `dispose()` manually.** `useLocal` handles teardown on unmount.
399
+
400
+ **Don't put loading/error in state.** Read from `vm.async.methodName` instead. See [ViewModel — Async Tracking](../ViewModel.md#async-tracking).
401
+
402
+ **Don't fetch in `useEffect`.** Use `onInit()` inside the ViewModel instead. `useLocal` calls `init()` which calls `onInit()` — the ViewModel manages its own initialization.
403
+
404
+ **Use deps for route params and dynamic props.** When a prop changes and the ViewModel needs to tear down and reinitialize (new data, new subscriptions), pass that prop in the deps array.
405
+
406
+ **Pass `disposeSignal` to every async call.** Ensures in-flight requests cancel on unmount or dep change.
407
+
408
+ ---
409
+
410
+ ## Anti-Patterns
411
+
412
+ ### Multiple useLocal in one component
413
+
414
+ ```tsx
415
+ // Bad — split into two components
416
+ const [s1, vm1] = useLocal(UsersViewModel, { ... });
417
+ const [s2, vm2] = useLocal(OnDutyViewModel, { ... });
418
+ ```
419
+
420
+ ### Fetching in useEffect
421
+
422
+ ```tsx
423
+ // Bad — ViewModel handles its own initialization
424
+ useEffect(() => { vm.load(); }, []);
425
+
426
+ // Good — onInit() handles it; useLocal calls init() automatically
427
+ ```
428
+
429
+ ### Manual init/dispose
430
+
431
+ ```tsx
432
+ // Bad — useLocal manages the lifecycle
433
+ useEffect(() => {
434
+ vm.init();
435
+ return () => vm.dispose();
436
+ }, []);
437
+ ```
438
+
439
+ ### Derived state in the component
440
+
441
+ ```tsx
442
+ // Bad — filtering belongs in a ViewModel getter
443
+ const filtered = state.items.filter(i => i.active);
444
+
445
+ // Good — read the getter
446
+ <ItemList items={vm.filtered} />
447
+ ```
448
+
449
+ ---
450
+
451
+ ## Related
452
+
453
+ - [ViewModel](../ViewModel.md) — the primary class used with `useLocal`
454
+ - [Controller](../Controller.md) — stateless orchestrator, returns instance directly
455
+ - [Trackable](../Trackable.md) — base class for custom reactive objects, returns instance with auto re-renders
456
+ - [Collection](../Collection.md) — reactive typed array, returns `[items, collection]`
457
+ - [Model](../Model.md) — entity with validation; typically used via `useModel` instead