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.
- package/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- 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
|