mvc-kit 2.13.0 → 2.13.1

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 (200) hide show
  1. package/BEST_PRACTICES.md +1390 -0
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
  3. package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
  4. package/agent-config/lib/install-claude.mjs +8 -2
  5. package/examples/primitive/channel.ts +109 -0
  6. package/examples/primitive/collection.ts +118 -0
  7. package/examples/primitive/controller.ts +118 -0
  8. package/examples/primitive/counter.ts +108 -0
  9. package/examples/primitive/env.d.ts +1 -0
  10. package/examples/primitive/eventbus.ts +77 -0
  11. package/examples/primitive/feed.ts +162 -0
  12. package/examples/primitive/model.ts +82 -0
  13. package/examples/primitive/pagination.ts +91 -0
  14. package/examples/primitive/pending.ts +189 -0
  15. package/examples/primitive/persistent-collection.ts +116 -0
  16. package/examples/primitive/resource.ts +114 -0
  17. package/examples/primitive/selection.ts +96 -0
  18. package/examples/primitive/sorting.ts +112 -0
  19. package/examples/primitive/timer.ts +58 -0
  20. package/examples/primitive/trackable.ts +225 -0
  21. package/examples/primitive/tsconfig.json +20 -0
  22. package/examples/primitive/viewmodel-service.ts +161 -0
  23. package/examples/react/AuthExample/index.html +12 -0
  24. package/examples/react/AuthExample/src/App.tsx +29 -0
  25. package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
  26. package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
  27. package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
  28. package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
  29. package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
  30. package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
  31. package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
  32. package/examples/react/AuthExample/src/env.d.ts +10 -0
  33. package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
  34. package/examples/react/AuthExample/src/main.tsx +10 -0
  35. package/examples/react/AuthExample/src/mock/api.ts +78 -0
  36. package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
  37. package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
  38. package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
  39. package/examples/react/AuthExample/src/styles.css +445 -0
  40. package/examples/react/AuthExample/src/types/auth.ts +12 -0
  41. package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
  42. package/examples/react/AuthExample/tsconfig.json +22 -0
  43. package/examples/react/AuthExample/vite.config.ts +18 -0
  44. package/examples/react/ComplexApp/index.html +12 -0
  45. package/examples/react/ComplexApp/src/App.tsx +17 -0
  46. package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
  47. package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
  48. package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
  49. package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
  50. package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
  51. package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
  52. package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
  53. package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
  54. package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
  55. package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
  56. package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
  57. package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
  58. package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
  59. package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
  60. package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
  61. package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
  62. package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
  63. package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
  64. package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
  65. package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
  66. package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
  67. package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
  68. package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
  69. package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
  70. package/examples/react/ComplexApp/src/env.d.ts +10 -0
  71. package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
  72. package/examples/react/ComplexApp/src/main.tsx +10 -0
  73. package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
  74. package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
  75. package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
  76. package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
  77. package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
  78. package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
  79. package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
  80. package/examples/react/ComplexApp/src/styles.css +463 -0
  81. package/examples/react/ComplexApp/src/types/activity.ts +8 -0
  82. package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
  83. package/examples/react/ComplexApp/src/types/social.ts +8 -0
  84. package/examples/react/ComplexApp/src/types/users.ts +6 -0
  85. package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
  86. package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
  87. package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
  88. package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
  89. package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
  90. package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
  91. package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
  92. package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
  93. package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
  94. package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
  95. package/examples/react/ComplexApp/tsconfig.json +22 -0
  96. package/examples/react/ComplexApp/vite.config.ts +18 -0
  97. package/examples/react/FullApp/index.html +12 -0
  98. package/examples/react/FullApp/src/App.tsx +28 -0
  99. package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
  100. package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
  101. package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
  102. package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
  103. package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
  104. package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
  105. package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
  106. package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
  107. package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
  108. package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
  109. package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
  110. package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
  111. package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
  112. package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
  113. package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
  114. package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
  115. package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
  116. package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
  117. package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
  118. package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
  119. package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
  120. package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
  121. package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
  122. package/examples/react/FullApp/src/env.d.ts +10 -0
  123. package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
  124. package/examples/react/FullApp/src/main.tsx +10 -0
  125. package/examples/react/FullApp/src/mock/delay.ts +21 -0
  126. package/examples/react/FullApp/src/mock/locations.ts +76 -0
  127. package/examples/react/FullApp/src/mock/messages.ts +237 -0
  128. package/examples/react/FullApp/src/mock/users.ts +84 -0
  129. package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
  130. package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
  131. package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
  132. package/examples/react/FullApp/src/services/AuthService.ts +18 -0
  133. package/examples/react/FullApp/src/services/LocationService.ts +23 -0
  134. package/examples/react/FullApp/src/services/MessageService.ts +65 -0
  135. package/examples/react/FullApp/src/services/UserService.ts +23 -0
  136. package/examples/react/FullApp/src/styles.css +767 -0
  137. package/examples/react/FullApp/src/types/conversation.ts +7 -0
  138. package/examples/react/FullApp/src/types/location.ts +12 -0
  139. package/examples/react/FullApp/src/types/message.ts +7 -0
  140. package/examples/react/FullApp/src/types/user.ts +10 -0
  141. package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
  142. package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
  143. package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
  144. package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
  145. package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
  146. package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
  147. package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
  148. package/examples/react/FullApp/tsconfig.json +22 -0
  149. package/examples/react/FullApp/vite.config.ts +18 -0
  150. package/examples/react/WorkerApp/index.html +12 -0
  151. package/examples/react/WorkerApp/src/App.tsx +24 -0
  152. package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
  153. package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
  154. package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
  155. package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
  156. package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
  157. package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
  158. package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
  159. package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
  160. package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
  161. package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
  162. package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
  163. package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
  164. package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
  165. package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
  166. package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
  167. package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
  168. package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
  169. package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
  170. package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
  171. package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
  172. package/examples/react/WorkerApp/src/env.d.ts +10 -0
  173. package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
  174. package/examples/react/WorkerApp/src/main.tsx +10 -0
  175. package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
  176. package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
  177. package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
  178. package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
  179. package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
  180. package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
  181. package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
  182. package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
  183. package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
  184. package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
  185. package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
  186. package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
  187. package/examples/react/WorkerApp/src/styles.css +756 -0
  188. package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
  189. package/examples/react/WorkerApp/src/types/message.ts +7 -0
  190. package/examples/react/WorkerApp/src/types/shift.ts +13 -0
  191. package/examples/react/WorkerApp/src/types/site.ts +8 -0
  192. package/examples/react/WorkerApp/src/types/worker.ts +8 -0
  193. package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
  194. package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
  195. package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
  196. package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
  197. package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
  198. package/examples/react/WorkerApp/tsconfig.json +22 -0
  199. package/examples/react/WorkerApp/vite.config.ts +18 -0
  200. package/package.json +4 -2
@@ -0,0 +1,1390 @@
1
+ # mvc-kit Best Practices
2
+
3
+ This guide prescribes how to build clean, testable, maintainable single-page applications with mvc-kit. It is opinionated by design. The patterns here eliminate boilerplate, push logic out of components, and produce applications where every class has one clear job.
4
+
5
+ ---
6
+
7
+ ## Guiding Principles
8
+
9
+ **1. The ViewModel is the complete interface.**
10
+ A component interacts with one ViewModel. That ViewModel exposes everything the component needs — state, computed properties, actions, events — and encapsulates everything the component doesn't need to know about: services, collections, subscriptions, async orchestration. The component never imports infrastructure.
11
+
12
+ **2. Components are declarative.**
13
+ Components read state, read getters, read async status, and call methods. They don't coordinate, derive, subscribe, or fetch. If you're writing logic in a component, it belongs in a ViewModel.
14
+
15
+ **3. One ViewModel per component.**
16
+ A connected component owns one ViewModel via `useLocal`. It may also use `useEvent` on that same ViewModel. If a component needs a second ViewModel, it should be split into two components.
17
+
18
+ **4. Lifecycle drives initialization, not the component.**
19
+ `onInit()` replaces `useEffect` for data loading and subscription setup. The ViewModel knows what it needs and when to get it. The component doesn't tell it.
20
+
21
+ **5. State holds truth, getters derive the rest.**
22
+ State contains only source-of-truth values — what the user typed, what the server returned. Derived values (filtered lists, counts, flags) are getters on the ViewModel. They can't desync because they compute from state on every access.
23
+
24
+ **6. Async status is automatic.**
25
+ Loading flags and error messages for async operations are tracked by the framework, not by you. Don't put `loading` or `error` in state — read them from `vm.async.methodName`.
26
+
27
+ ---
28
+
29
+ ## Class Roles
30
+
31
+ | Question | Answer |
32
+ |---|---|
33
+ | Does it hold UI state, computed properties, and actions for a component? | **ViewModel** |
34
+ | Does it represent a single entity with validation and dirty tracking? | **Model** |
35
+ | Does it hold a list of entities with CRUD operations? | **Collection** |
36
+ | Does it hold a list of entities and persist to browser/device storage? | **PersistentCollection** subclass |
37
+ | Does it hold a list of entities AND load them from an API? | **Resource** |
38
+ | Does it fetch data from an external source? | **Service** |
39
+ | Does it broadcast events across unrelated parts of the app? | **EventBus** |
40
+ | Does it manage a persistent external connection (WebSocket, SSE)? | **Channel** |
41
+ | Does it coordinate multiple ViewModels in a single workflow? | **Controller** (rare) |
42
+ | Does it wrap a third-party SDK or manage custom reactive state for a ViewModel? | **Trackable** subclass |
43
+
44
+ If code doesn't fit any of these, it's a plain utility function. Not everything needs to be a class.
45
+
46
+ ---
47
+
48
+ ## The ViewModel
49
+
50
+ The ViewModel is the core building block. Get this right and everything else follows.
51
+
52
+ ### State Design: Source of Truth Only
53
+
54
+ State holds only the raw values: user inputs and data loaded from the server. Nothing derived. Nothing related to async status.
55
+
56
+ ```typescript
57
+ // ✗ Bad: derived values, collection mirrors, and async status in state
58
+ interface State {
59
+ search: string;
60
+ items: Item[]; // collection data — use a getter reading from collection
61
+ filtered: Item[]; // derived — use a getter
62
+ total: number; // derived — use a getter
63
+ loading: boolean; // async status — use vm.async
64
+ error: string | null; // async status — use vm.async
65
+ }
66
+
67
+ // ✓ Good: only source-of-truth values (user inputs and filters)
68
+ interface State {
69
+ search: string;
70
+ typeFilter: 'all' | 'office' | 'warehouse';
71
+ }
72
+ ```
73
+
74
+ Derived values live on the ViewModel as getters. Async status lives in `vm.async`. State stays lean.
75
+
76
+ ### Computed Getters
77
+
78
+ TypeScript `get` accessors compute derived values from state. They can't desync because they read from `this.state` on every access and recompute during each render.
79
+
80
+ ```typescript
81
+ export class LocationsViewModel extends ViewModel<State> {
82
+ collection = singleton(LocationsCollection);
83
+
84
+ get items(): LocationState[] {
85
+ return this.collection.items as LocationState[];
86
+ }
87
+
88
+ get filtered(): LocationState[] {
89
+ const { search, typeFilter } = this.state;
90
+ let result = this.items;
91
+
92
+ if (search) {
93
+ const q = search.toLowerCase();
94
+ result = result.filter(loc =>
95
+ loc.name.toLowerCase().includes(q) ||
96
+ loc.city.toLowerCase().includes(q)
97
+ );
98
+ }
99
+ if (typeFilter !== 'all') {
100
+ result = result.filter(loc => loc.type === typeFilter);
101
+ }
102
+ return result;
103
+ }
104
+
105
+ get total(): number {
106
+ return this.items.length;
107
+ }
108
+
109
+ get hasResults(): boolean {
110
+ return this.filtered.length > 0;
111
+ }
112
+
113
+ get isEmpty(): boolean {
114
+ return this.total > 0 && !this.hasResults;
115
+ }
116
+ }
117
+ ```
118
+
119
+ In the component, read `state.x` for raw values and `vm.x` for computed values:
120
+
121
+ ```tsx
122
+ function LocationsPage() {
123
+ const [state, vm] = useLocal(LocationsViewModel, { /* ... */ });
124
+
125
+ return (
126
+ <div>
127
+ <input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
128
+ <LocationsTable locations={vm.filtered} />
129
+ <p>Showing {vm.filtered.length} of {vm.total}</p>
130
+ {vm.isEmpty && <p>No results match your filters.</p>}
131
+ </div>
132
+ );
133
+ }
134
+ ```
135
+
136
+ **Important:** Never call `set()` inside a getter. It creates an infinite loop. Dev mode (`__MVC_KIT_DEV__`) detects this and logs a clear error.
137
+
138
+ ### One-Liner Setters
139
+
140
+ With getters handling derivation, setters do one thing — update a single state value:
141
+
142
+ ```typescript
143
+ setSearch(search: string) { this.set({ search }); }
144
+ setTypeFilter(typeFilter: State['typeFilter']) { this.set({ typeFilter }); }
145
+ setStatusFilter(statusFilter: State['statusFilter']) { this.set({ statusFilter }); }
146
+ ```
147
+
148
+ The setter changes state, React re-renders, and getters compute the new result. There is no opportunity for desync.
149
+
150
+ All classes auto-bind methods in the constructor, so they can be passed point-free as callbacks:
151
+
152
+ ```tsx
153
+ // Both work — pick whichever you prefer
154
+ <SearchBox onChange={vm.setSearch} />
155
+ <SearchBox onChange={v => vm.setSearch(v)} />
156
+
157
+ // Works on all classes, not just ViewModel
158
+ <SortHeader onClick={sorting.toggle} />
159
+ <button onClick={pagination.nextPage}>Next</button>
160
+ <button onClick={model.commit}>Save</button>
161
+ ```
162
+
163
+ ### Encapsulating Collections
164
+
165
+ Collections are the shared data cache. Getters on the ViewModel read directly from collection members — the auto-tracking system detects the subscribable dependency and handles reactivity automatically. Components never import or subscribe to a Collection.
166
+
167
+ ```typescript
168
+ export class LocationsViewModel extends ViewModel<State> {
169
+ collection = singleton(LocationsCollection);
170
+ private service = singleton(LocationService);
171
+
172
+ // Getter reads from collection — auto-tracked, no subscribeTo needed
173
+ get items(): LocationState[] {
174
+ return this.collection.items as LocationState[];
175
+ }
176
+
177
+ get filtered(): LocationState[] {
178
+ const { search, typeFilter } = this.state;
179
+ let result = this.items;
180
+ if (search) {
181
+ result = result.filter(loc => loc.name.toLowerCase().includes(search.toLowerCase()));
182
+ }
183
+ if (typeFilter !== 'all') {
184
+ result = result.filter(loc => loc.type === typeFilter);
185
+ }
186
+ return result;
187
+ }
188
+
189
+ get total(): number {
190
+ return this.items.length;
191
+ }
192
+
193
+ protected onInit() {
194
+ // Smart init: skip fetch if another ViewModel already loaded the data
195
+ if (this.collection.length === 0) this.load();
196
+ }
197
+ }
198
+ ```
199
+
200
+ When a getter reads from a collection member, the memoization system auto-tracks it as a dependency. When the collection changes, the getter invalidates and recomputes. No `subscribeTo()` or `set()` wiring needed.
201
+
202
+ ViewModels with no local state at all (e.g. a dashboard that only derives from collections) can omit the state type parameter entirely:
203
+
204
+ ```typescript
205
+ export class DashboardViewModel extends ViewModel {
206
+ private usersCollection = singleton(UsersCollection);
207
+
208
+ get totalUsers(): number {
209
+ return this.usersCollection.length;
210
+ }
211
+
212
+ protected onInit() {
213
+ if (this.usersCollection.length === 0) this.load();
214
+ }
215
+ }
216
+ ```
217
+
218
+ #### When subscribeTo() is still needed
219
+
220
+ `subscribeTo()` is not deprecated. It remains the right tool for **imperative reactions** — side effects that should fire when a collection changes:
221
+
222
+ ```typescript
223
+ protected onInit() {
224
+ // Play a sound when new messages arrive
225
+ this.subscribeTo(this.messagesCollection, () => {
226
+ this.playNotificationSound();
227
+ });
228
+ }
229
+ ```
230
+
231
+ Use getters for deriving values from collections. Use `subscribeTo()` for imperative side effects.
232
+
233
+ ### Async Methods
234
+
235
+ After `init()`, mvc-kit automatically tracks loading and error state for every async method. You write the happy path. The framework handles the rest.
236
+
237
+ ```typescript
238
+ async load() {
239
+ const data = await this.service.getAll(this.disposeSignal);
240
+ this.collection.reset(data);
241
+ }
242
+ ```
243
+
244
+ That's the entire method. No `try/catch`. No `this.set({ loading: true })`. No `this.set({ error: ... })`. No AbortError check. No dispose guard.
245
+
246
+ Here's what happens automatically:
247
+
248
+ - When `load()` is called, `vm.async.load` becomes `{ loading: true, error: null, errorCode: null }`.
249
+ - When it resolves, `vm.async.load` becomes `{ loading: false, error: null, errorCode: null }`.
250
+ - If it throws, the error is classified via `classifyError()` — `vm.async.load.error` gets the message string and `vm.async.load.errorCode` gets a discriminant code (e.g. `'unauthorized'`, `'network'`, `'server_error'`, `'unknown'`). The error is re-thrown (preserving standard Promise behavior).
251
+ - If `disposeSignal` aborts and `fetch` throws an `AbortError`, it's silently swallowed — not captured as an error, not re-thrown.
252
+ - If `set()` is called after dispose (in-flight callback resolves late), it's a no-op — no crash, no guard needed.
253
+
254
+ The component reads async status directly from `vm.async`:
255
+
256
+ ```tsx
257
+ function LocationsPage() {
258
+ const [state, vm] = useLocal(LocationsViewModel, { /* ... */ });
259
+ const { loading, error } = vm.async.load;
260
+
261
+ return (
262
+ <div>
263
+ {loading && <p>Loading…</p>}
264
+ {error && <p className="error">{error}</p>}
265
+ <LocationsTable locations={vm.filtered} />
266
+ </div>
267
+ );
268
+ }
269
+ ```
270
+
271
+ For error-type branching, use `errorCode`:
272
+
273
+ ```tsx
274
+ const { loading, error, errorCode } = vm.async.load;
275
+ if (errorCode === 'unauthorized') return <Redirect to="/login" />;
276
+ if (errorCode === 'network') return <NetworkError onRetry={() => vm.load()} />;
277
+ ```
278
+
279
+ **When you still need try/catch:** Only when you want to do something specific beyond what async tracking provides — emitting an imperative event on error, rolling back optimistic updates, or running custom logic on success before the method returns:
280
+
281
+ ```typescript
282
+ async save() {
283
+ try {
284
+ const result = await this.service.save(this.state.draft, this.disposeSignal);
285
+ this.collection.update(result.id, result);
286
+ this.emit('saved', { id: result.id });
287
+ } catch (e) {
288
+ this.emit('error', { message: classifyError(e).message });
289
+ throw e; // re-throw so async tracking still captures it
290
+ }
291
+ }
292
+ ```
293
+
294
+ Note the `throw e` at the end of the catch block. If you swallow the error, async tracking won't see it. Re-throw to let the framework track it.
295
+
296
+ **`isAbortError` is only needed when the catch block affects shared state.** The async tracking wrapper swallows AbortErrors at the outer promise level, but your internal catch block does receive them. `set()` and `emit()` are already no-ops after dispose, so they don't need guarding. Use `isAbortError()` to guard operations on objects that outlive the ViewModel — like rolling back optimistic updates on a singleton Collection:
297
+
298
+ ```typescript
299
+ async delete(id: string) {
300
+ const rollback = this.collection.optimistic(() => {
301
+ this.collection.remove(id);
302
+ });
303
+ try {
304
+ await this.service.delete(id, this.disposeSignal);
305
+ } catch (e) {
306
+ if (!isAbortError(e)) rollback(); // don't roll back on abort
307
+ throw e;
308
+ }
309
+ }
310
+ ```
311
+
312
+ ### Cancellation with disposeSignal
313
+
314
+ Pass `this.disposeSignal` to every async call. When the component unmounts, `dispose()` fires, the signal aborts, `fetch()` throws `AbortError`, and async tracking swallows it.
315
+
316
+ ```typescript
317
+ async load() {
318
+ const data = await this.service.getAll(this.disposeSignal);
319
+ this.collection.reset(data);
320
+ }
321
+ ```
322
+
323
+ For per-call cancellation (e.g. user switches rooms rapidly), compose signals with `AbortSignal.any()`:
324
+
325
+ ```typescript
326
+ async loadRoom(roomId: string, callSignal: AbortSignal) {
327
+ const res = await fetch(`/api/rooms/${roomId}`, {
328
+ signal: AbortSignal.any([this.disposeSignal, callSignal]),
329
+ });
330
+ this.set({ messages: await res.json() });
331
+ }
332
+ ```
333
+
334
+ ### Imperative Events
335
+
336
+ Some actions produce one-shot signals that don't belong in state: toast notifications, navigation redirects, scroll-to-error, shake animations. ViewModels have built-in typed events for this via the second generic parameter.
337
+
338
+ ```typescript
339
+ interface SaveEvents {
340
+ saved: { id: string };
341
+ deleted: { id: string };
342
+ validationFailed: void;
343
+ }
344
+
345
+ export class ItemViewModel extends ViewModel<State, SaveEvents> {
346
+ private service = singleton(ItemService);
347
+ private collection = singleton(ItemsCollection);
348
+
349
+ async save() {
350
+ const result = await this.service.save(this.state.draft, this.disposeSignal);
351
+ this.collection.update(result.id, result);
352
+ this.emit('saved', { id: result.id }); // protected, type-safe
353
+ }
354
+
355
+ async delete(id: string) {
356
+ await this.service.delete(id, this.disposeSignal);
357
+ this.collection.remove(id);
358
+ this.emit('deleted', { id });
359
+ }
360
+
361
+ submit() {
362
+ if (!this.isValid) {
363
+ this.emit('validationFailed');
364
+ return;
365
+ }
366
+ this.save();
367
+ }
368
+ }
369
+ ```
370
+
371
+ The component subscribes with `useEvent` directly on the ViewModel:
372
+
373
+ ```tsx
374
+ function ItemPage() {
375
+ const [state, vm] = useLocal(ItemViewModel, { /* ... */ });
376
+
377
+ useEvent(vm, 'saved', ({ id }) => {
378
+ toast.success(`Item ${id} saved`);
379
+ });
380
+
381
+ useEvent(vm, 'validationFailed', () => {
382
+ scrollToFirstError();
383
+ });
384
+
385
+ return <div>{/* ... */}</div>;
386
+ }
387
+ ```
388
+
389
+ `emit()` is **protected** — only the ViewModel can emit. The event bus is lazy (zero cost if never used) and auto-disposes with the ViewModel.
390
+
391
+ ### Exposing Actions
392
+
393
+ Public methods are the component's API. Name them as user-intent verbs:
394
+
395
+ ```typescript
396
+ // ✓ Good: user-intent verbs
397
+ vm.load()
398
+ vm.refresh()
399
+ vm.setSearch(query)
400
+ vm.toggleStatus(id)
401
+ vm.remove(id)
402
+ vm.submit()
403
+
404
+ // ✗ Bad: implementation-detail names
405
+ vm.fetchDataFromApi()
406
+ vm.updateCollectionAndRefilter()
407
+ vm.setStateLoading()
408
+ ```
409
+
410
+ ### ViewModel Section Order
411
+
412
+ Organize every ViewModel consistently:
413
+
414
+ ```typescript
415
+ export class LocationsViewModel extends ViewModel<State, Events> {
416
+ // --- Private fields ---
417
+ private service = singleton(LocationService);
418
+ private collection = singleton(LocationsCollection);
419
+
420
+ // --- Computed getters ---
421
+ get filtered(): LocationState[] { /* ... */ }
422
+ get total(): number { /* ... */ }
423
+
424
+ // --- Lifecycle ---
425
+ protected onInit() { /* ... */ }
426
+
427
+ // --- Actions ---
428
+ async load() { /* ... */ }
429
+ async refresh() { /* ... */ }
430
+
431
+ // --- Setters ---
432
+ setSearch(search: string) { this.set({ search }); }
433
+ setTypeFilter(typeFilter: State['typeFilter']) { this.set({ typeFilter }); }
434
+ }
435
+ ```
436
+
437
+ **Private fields → Computed getters → Lifecycle → Actions → Setters.** This order makes every ViewModel scannable.
438
+
439
+ ### Resetting a ViewModel
440
+
441
+ `reset(newState?)` tears down the current lifecycle and re-initializes without unmounting the component. It aborts in-flight async, resets subscriptions and state, clears async tracking, and re-runs `onInit()`. See `src/ViewModel.md` for full API details.
442
+
443
+ ```typescript
444
+ vm.reset(); // reset to initial state
445
+ vm.reset({ userId: newId, data: null }); // reset with new state
446
+ ```
447
+
448
+ ---
449
+
450
+ ## Components
451
+
452
+ ### One ViewModel, Minimal Hooks
453
+
454
+ A connected component owns one ViewModel via `useLocal`. It may add `useEvent` for imperative events — but all on the same ViewModel. Async status is read directly from `vm.async`. That's the full toolkit.
455
+
456
+ ```tsx
457
+ export function LocationsPage() {
458
+ const [state, vm] = useLocal(LocationsViewModel, {
459
+ search: '',
460
+ typeFilter: 'all',
461
+ });
462
+ const { loading, error } = vm.async.load;
463
+
464
+ return (
465
+ <div>
466
+ <LocationFilters
467
+ search={state.search}
468
+ typeFilter={state.typeFilter}
469
+ onSearchChange={v => vm.setSearch(v)}
470
+ onTypeFilterChange={v => vm.setTypeFilter(v)}
471
+ />
472
+
473
+ {loading && <p>Loading…</p>}
474
+ {error && <p className="error">{error}</p>}
475
+
476
+ <LocationsTable locations={vm.filtered} />
477
+ <p>Showing {vm.filtered.length} of {vm.total}</p>
478
+ </div>
479
+ );
480
+ }
481
+ ```
482
+
483
+ No `useEffect`. No `useState`. No `useMemo`. No `useCallback`. The ViewModel is the hook.
484
+
485
+ ```tsx
486
+ // ✗ Bad: multiple useLocal — split into two components
487
+ const [usersState, usersVM] = useLocal(UsersViewModel, { ... });
488
+ const [onDutyState, onDutyVM] = useLocal(OnDutyViewModel, { ... });
489
+
490
+ // ✓ Good: each component owns one ViewModel
491
+ <UsersTable />
492
+ <OnDutySidebar />
493
+ ```
494
+
495
+ ```tsx
496
+ // ✗ Bad: logic in the component
497
+ const filtered = state.items.filter(i => i.status === 'active');
498
+
499
+ // ✓ Good: getter on the ViewModel
500
+ <ItemTable items={vm.filtered} />
501
+ ```
502
+
503
+ ### Connected vs Presentational
504
+
505
+ **Connected components** call `useLocal` and own a ViewModel — pages, sidebars, cards, self-contained widgets. **Presentational components** receive props and render UI with zero knowledge of mvc-kit. Keep the ratio high: many presentational, few connected.
506
+
507
+ ### No useEffect for Data Loading
508
+
509
+ The ViewModel's `onInit()` handles initialization. `useLocal` calls `init()` automatically after mount.
510
+
511
+ ```tsx
512
+ // ✗ Bad: component orchestrates loading
513
+ function UsersPage() {
514
+ const [state, vm] = useLocal(UsersViewModel, { ... });
515
+ useEffect(() => { vm.load(); }, []);
516
+ return <div>...</div>;
517
+ }
518
+
519
+ // ✓ Good: ViewModel handles its own initialization via onInit()
520
+ function UsersPage() {
521
+ const [state, vm] = useLocal(UsersViewModel, { ... });
522
+ return <div>...</div>;
523
+ }
524
+ ```
525
+
526
+ ### Multiple Async Operations
527
+
528
+ When a component needs loading/error state for more than one async method, destructure each from `vm.async`:
529
+
530
+ ```tsx
531
+ const loadState = vm.async.load;
532
+ const saveState = vm.async.save;
533
+
534
+ // Use loadState.loading, loadState.error, saveState.loading, etc.
535
+ ```
536
+
537
+ ### useLocal with Dependencies
538
+
539
+ `useLocal` accepts an optional `deps` array as its final argument. When any dep changes, the current instance is disposed and a new one is created and initialized. Use this when a ViewModel is tied to a route param or prop that can change without unmounting.
540
+
541
+ ```tsx
542
+ function UserPage({ userId }: { userId: string }) {
543
+ const [state, vm] = useLocal(UserViewModel, { userId, data: null }, [userId]);
544
+ const { loading, error } = vm.async.onInit;
545
+
546
+ return (
547
+ <div>
548
+ {loading && <Spinner />}
549
+ {error && <ErrorBanner message={error} />}
550
+ {state.data && <UserProfile user={state.data} />}
551
+ </div>
552
+ );
553
+ }
554
+ ```
555
+
556
+ When `userId` changes, `UserViewModel` is disposed (aborting in-flight requests via `disposeSignal`) and a fresh instance is created with the new `userId` in its initial state. The factory overload also supports deps: `useLocal(() => new ChatViewModel(roomId), [roomId])`.
557
+
558
+ ---
559
+
560
+ ## Shared State Across Components
561
+
562
+ When sibling or parent-child components need to share state, use one of these patterns in order of preference.
563
+
564
+ ### Pattern A: Parent ViewModel with Presentational Children
565
+
566
+ The parent creates the ViewModel and passes state down as props. Children are presentational. This is the default.
567
+
568
+ ```tsx
569
+ function OrderPage() {
570
+ const [state, vm] = useLocal(OrderViewModel, { /* ... */ });
571
+ const { loading, error } = vm.async.load;
572
+
573
+ return (
574
+ <div>
575
+ <OrderHeader status={state.status} />
576
+ <OrderItems
577
+ items={vm.visibleItems}
578
+ onRemove={id => vm.removeItem(id)}
579
+ />
580
+ <OrderSummary
581
+ subtotal={vm.subtotal}
582
+ total={vm.total}
583
+ onSubmit={() => vm.submit()}
584
+ canSubmit={vm.canSubmit}
585
+ />
586
+ </div>
587
+ );
588
+ }
589
+ ```
590
+
591
+ ### Pattern B: Singleton ViewModel
592
+
593
+ When components are far apart in the tree, use a singleton ViewModel via `useSingleton`. Multiple components subscribe to the same instance. Define `static DEFAULT_STATE` so every call site is arg-free:
594
+
595
+ ```typescript
596
+ class CartViewModel extends ViewModel<CartState> {
597
+ static DEFAULT_STATE: CartState = { items: [] };
598
+ // ...methods
599
+ }
600
+ ```
601
+
602
+ ```tsx
603
+ function CartIcon() {
604
+ const [state, vm] = useSingleton(CartViewModel);
605
+ return <span className="badge">{vm.itemCount}</span>;
606
+ }
607
+
608
+ function CartDrawer() {
609
+ const [state, vm] = useSingleton(CartViewModel);
610
+ return (
611
+ <aside>
612
+ {state.items.map(item => (
613
+ <CartItem key={item.id} item={item} onRemove={() => vm.removeItem(item.id)} />
614
+ ))}
615
+ <p>Total: ${vm.total}</p>
616
+ </aside>
617
+ );
618
+ }
619
+ ```
620
+
621
+ Use this for app-wide concerns — cart, auth, theme — where state must survive route changes.
622
+
623
+ #### Convenience Hooks (Optional)
624
+
625
+ For singleton ViewModels that appear in many components (auth, theme, cart), you can co-export a convenience hook from the ViewModel file to reduce imports at each call site:
626
+
627
+ ```typescript
628
+ // viewmodels/AuthViewModel.ts
629
+ import { ViewModel, singleton } from 'mvc-kit';
630
+ import { useSingleton } from 'mvc-kit/react';
631
+
632
+ export class AuthViewModel extends ViewModel<AuthState> {
633
+ static DEFAULT_STATE: AuthState = { user: null, accessToken: null };
634
+ // ...getters, actions
635
+ }
636
+
637
+ export const useAuth = () => useSingleton(AuthViewModel);
638
+ ```
639
+
640
+ ```tsx
641
+ // components/Header.tsx — single import
642
+ import { useAuth } from '../viewmodels/AuthViewModel';
643
+
644
+ function Header() {
645
+ const [state, vm] = useAuth();
646
+ return <span>{vm.displayName}</span>;
647
+ }
648
+ ```
649
+
650
+ This is a user-land convention, not a framework feature. The ViewModel class itself stays framework-agnostic — testable without React via `new AuthViewModel(state)`. Use this sparingly for the 2–3 singletons that appear in many files, not for every ViewModel. Component-scoped ViewModels (which use `useLocal` with constructor args) don't benefit from this pattern.
651
+
652
+ ### Pattern C: Separate Components with Shared Collection
653
+
654
+ When two components show different views of the same data, give each its own ViewModel and let them share a singleton Collection. The Collection is the synchronization point.
655
+
656
+ ```tsx
657
+ function UsersPage() {
658
+ return (
659
+ <div className="users-layout">
660
+ <UsersTable /> {/* UsersViewModel → getters read from UsersCollection */}
661
+ <OnDutySidebar /> {/* OnDutyViewModel → getters read from UsersCollection */}
662
+ </div>
663
+ );
664
+ }
665
+ ```
666
+
667
+ When `UsersViewModel` calls `collection.reset(data)`, auto-tracking detects the change and `OnDutyViewModel`'s derived getters recompute. Both stay in sync from a single data fetch.
668
+
669
+ ### Which Pattern to Choose
670
+
671
+ "Can the parent own one ViewModel and pass props?" → **Pattern A**.
672
+ "Should state survive route changes and be accessible from anywhere?" → **Pattern B**.
673
+ "Do the components have different concerns but share underlying data?" → **Pattern C**.
674
+
675
+ ---
676
+
677
+ ## Services
678
+
679
+ Services are singleton, stateless infrastructure adapters. They wrap external dependencies behind a clean interface. See `src/Service.md` for full API reference.
680
+
681
+ **Rules:**
682
+ - **Stateless** — don't cache data between calls (that's a Collection's job).
683
+ - **Accept `AbortSignal`** — lets ViewModels cancel in-flight requests via `disposeSignal`.
684
+ - **Throw `HttpError`** — carries the HTTP status code for `classifyError()` to produce canonical error codes.
685
+ - **No knowledge of ViewModels or Collections** — Services sit at the bottom of the dependency graph.
686
+
687
+ ```typescript
688
+ import { Service, HttpError } from 'mvc-kit';
689
+
690
+ export class UserService extends Service {
691
+ async getAll(signal?: AbortSignal): Promise<UserState[]> {
692
+ const res = await fetch('/api/users', { signal });
693
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
694
+ return res.json();
695
+ }
696
+ }
697
+ ```
698
+
699
+ Resolved via `singleton()` inside ViewModels as property initializers:
700
+
701
+ ```typescript
702
+ private service = singleton(LocationService);
703
+ ```
704
+
705
+ ---
706
+
707
+ ## Collections
708
+
709
+ Collections are reactive typed arrays — the shared in-memory data cache. See `src/Collection.md` for full API reference.
710
+
711
+ **Rules:**
712
+ - **One Collection per entity** — thin subclasses for singleton identity. Don't add methods; query logic belongs in ViewModel getters.
713
+ - **Never component-facing** — components don't import, subscribe to, or know about Collections.
714
+ - **Getters read from collections directly** — auto-tracking handles reactivity. Use `subscribeTo` only for imperative side effects.
715
+ - **Use `upsert()` for paginated/incremental loads** — accumulates data without destroying what other ViewModels depend on. Use `reset()` only for full replacement.
716
+ - **Use `collection.optimistic()`** for instant UI feedback — don't manually snapshot and restore.
717
+
718
+ ```typescript
719
+ export class UsersCollection extends Collection<UserState> {}
720
+ ```
721
+
722
+ ### Optimistic Updates
723
+
724
+ ```typescript
725
+ async toggleStatus(id: string) {
726
+ const rollback = this.collection.optimistic(() => {
727
+ this.collection.update(id, { status: 'done' });
728
+ });
729
+ try {
730
+ await this.service.update(id, { status: 'done' }, this.disposeSignal);
731
+ } catch (e) {
732
+ if (!isAbortError(e)) rollback(); // guard needed — rollback affects shared Collection
733
+ throw e;
734
+ }
735
+ }
736
+ ```
737
+
738
+ Snapshot is a reference capture (items are always frozen), rollback is idempotent and no-op when disposed, nesting works naturally. Re-throw the error so async tracking still captures it. The `isAbortError()` guard is needed here because `rollback()` operates on the singleton Collection which outlives the ViewModel — on abort (component unmount), you don't want to undo the optimistic update.
739
+
740
+ ### Eviction & TTL
741
+
742
+ For long-lived singleton Collections that accumulate unbounded data (chat messages, notifications, activity feeds), configure `MAX_SIZE` and/or `TTL` via static overrides:
743
+
744
+ ```typescript
745
+ class MessagesCollection extends Collection<Message> {
746
+ static MAX_SIZE = 500; // FIFO eviction when exceeded
747
+ static TTL = 5 * 60_000; // Auto-expire after 5 minutes
748
+ }
749
+ ```
750
+
751
+ Both features are zero-cost when not configured — no timers, no timestamp maps. Override `onEvict` to control which items get evicted:
752
+
753
+ ```typescript
754
+ class ActiveOrdersCollection extends Collection<Order> {
755
+ static MAX_SIZE = 200;
756
+
757
+ protected onEvict(items: Order[], reason: 'capacity' | 'ttl') {
758
+ return items.filter(o => o.status !== 'in_progress');
759
+ }
760
+ }
761
+ ```
762
+
763
+ ---
764
+
765
+ ## Persistence
766
+
767
+ For Collections that need to cache/repopulate from storage across sessions, use a `PersistentCollection` subclass. See `src/PersistentCollection.md` for the full API.
768
+
769
+ **When to use which adapter:**
770
+ - **`WebStorageCollection`** (`mvc-kit/web`) — Small datasets (<5MB), sync auto-hydration, localStorage/sessionStorage
771
+ - **`IndexedDBCollection`** (`mvc-kit/web`) — Large datasets, per-item storage, requires `hydrate()`
772
+ - **`NativeCollection`** (`mvc-kit/react-native`) — React Native, configurable backend, requires `hydrate()`
773
+
774
+ **Rules:**
775
+ - **Persist entity caches, not ephemeral UI state.** Persistence adds I/O overhead on every mutation. Don't persist search results, selections, or high-churn data.
776
+ - **Use `singleton()` for persistent collections.** Non-singleton persistence triggers a full read on every mount and writes on every mutation.
777
+ - **Call `hydrate()` in `onInit()` for async adapters.** IndexedDB and NativeCollection require manual hydration.
778
+ - **Tune `WRITE_DELAY` for your use case.** Default is `0` (immediate writes). Set `static override WRITE_DELAY = 100` to coalesce rapid mutations (useful for drag-and-drop, real-time collaboration, or high-frequency updates).
779
+
780
+ ```typescript
781
+ // Web — localStorage (auto-hydrates)
782
+ import { WebStorageCollection } from 'mvc-kit/web';
783
+
784
+ class CartCollection extends WebStorageCollection<CartItem> {
785
+ protected readonly storageKey = 'cart';
786
+ }
787
+
788
+ // Web — IndexedDB (requires hydrate)
789
+ import { IndexedDBCollection } from 'mvc-kit/web';
790
+
791
+ class MessagesCollection extends IndexedDBCollection<Message> {
792
+ protected readonly storageKey = 'messages';
793
+ static MAX_SIZE = 500;
794
+ }
795
+
796
+ // React Native — configured backend (requires hydrate)
797
+ import { NativeCollection } from 'mvc-kit/react-native';
798
+
799
+ class TodosCollection extends NativeCollection<Todo> {
800
+ protected readonly storageKey = 'todos';
801
+ }
802
+ ```
803
+
804
+ ---
805
+
806
+ ## Resources
807
+
808
+ Resources extend Collection with lifecycle management and automatic async tracking. Use a Resource when you need a data cache with built-in loading/error state for API calls. See `src/Resource.md` for full API reference.
809
+
810
+ **When to use Resource vs Collection:**
811
+ - **Collection** — shared data cache, no async loading needed
812
+ - **PersistentCollection** — shared data cache + browser/device storage persistence
813
+ - **Resource** — shared data cache + API loading + `resource.async.methodName.loading/error`
814
+
815
+ ### Define Your Own Async Methods
816
+
817
+ Resource has no prescribed fetcher. Define your own methods and use inherited Collection mutations. If you have a typed API client, call it directly — no Service wrapper needed:
818
+
819
+ ```typescript
820
+ import { Resource } from 'mvc-kit';
821
+ import { apiClient } from '@/api';
822
+
823
+ class UsersResource extends Resource<UserState> {
824
+ async loadAll() {
825
+ const data = await apiClient.users.list({ signal: this.disposeSignal });
826
+ this.reset(data);
827
+ }
828
+
829
+ async loadOnDuty(filter: string) {
830
+ const data = await apiClient.users.getOnDuty(filter, { signal: this.disposeSignal });
831
+ this.reset(data);
832
+ }
833
+ }
834
+ ```
835
+
836
+ Use a Service only when wrapping raw `fetch()` with `HttpError`, composing multiple HTTP calls, or managing auth/retries:
837
+
838
+ ```typescript
839
+ class UsersResource extends Resource<UserState> {
840
+ private api = singleton(UserService); // Service earns its place here
841
+
842
+ async loadAll() {
843
+ const data = await this.api.getAll(this.disposeSignal);
844
+ this.reset(data);
845
+ }
846
+ }
847
+ ```
848
+
849
+ Each method gets independent async tracking: `resource.async.loadAll.loading`, `resource.async.loadOnDuty.error`.
850
+
851
+ ### External Collection Injection
852
+
853
+ When multiple sources feed the same data (Resource for REST + Channel for WebSocket), inject a shared Collection:
854
+
855
+ ```typescript
856
+ class UsersResource extends Resource<UserState> {
857
+ constructor() {
858
+ super(singleton(SharedUsersCollection));
859
+ }
860
+
861
+ async loadAll() {
862
+ const data = await this.api.getAll(this.disposeSignal);
863
+ this.reset(data); // Mutates the shared collection
864
+ }
865
+ }
866
+ ```
867
+
868
+ All Collection methods transparently delegate to the external collection. Resource disposal does NOT dispose the shared collection.
869
+
870
+ ### ViewModel Integration
871
+
872
+ ViewModels access Resources the same way as Collections — via `singleton()` with getter auto-tracking:
873
+
874
+ ```typescript
875
+ class UsersViewModel extends ViewModel<{ search: string }> {
876
+ private users = singleton(UsersResource);
877
+
878
+ get filtered() {
879
+ return this.users.filter(u => u.name.includes(this.state.search));
880
+ }
881
+
882
+ onInit() {
883
+ if (this.users.length === 0) this.users.loadAll();
884
+ }
885
+ }
886
+ ```
887
+
888
+ ---
889
+
890
+ ## EventBus
891
+
892
+ The EventBus connects parts of the app that have no direct reference to each other. See `src/EventBus.md` for full API reference.
893
+
894
+ **Two scopes:**
895
+ - **ViewModel events** — one-shot imperative signals from a ViewModel to its component, built-in via the second generic parameter. See [Imperative Events](#imperative-events).
896
+ - **App-level EventBus** — signals that cross route boundaries. Singleton, shared across ViewModels.
897
+
898
+ ```typescript
899
+ export interface AppEvents {
900
+ 'users:loaded': void;
901
+ 'auth:logout': void;
902
+ }
903
+
904
+ export class AppEventBus extends EventBus<AppEvents> {}
905
+ ```
906
+
907
+ **EventBus vs Collection subscription:** If a ViewModel needs to react when *data changes*, subscribe to the Collection. If it needs to react when *something happened* (user logged out, data loaded on another route), listen to the EventBus. Collections carry state; EventBus carries intent.
908
+
909
+ Keep the event map small — past 10–15 events, your app likely has entangled concerns.
910
+
911
+ ---
912
+
913
+ ## Channel
914
+
915
+ A Channel manages a persistent external connection (WebSocket, SSE) with auto-reconnect and typed message handling. See `src/Channel.md` for the subclass contract, connection control, auto-reconnect configuration, and message routing API.
916
+
917
+ Channels are singletons. ViewModels subscribe to them for status changes and message handling.
918
+
919
+ ### pipeChannel — Channel-to-Collection Bridge
920
+
921
+ When every Channel message should be upserted into a Collection, use `pipeChannel`:
922
+
923
+ ```typescript
924
+ export class DashboardCardViewModel extends ViewModel {
925
+ private channel = singleton(DashboardChannel);
926
+ private collection = singleton(DashboardCollection);
927
+
928
+ protected onInit() {
929
+ this.pipeChannel(this.channel, 'data', this.collection);
930
+ this.channel.connect();
931
+ }
932
+ }
933
+ ```
934
+
935
+ `pipeChannel` calls `channel.init()` (idempotent), subscribes to the event, and upserts each payload. Auto-cleanup on dispose and reset.
936
+
937
+ ### Custom Channel Handling
938
+
939
+ When you need to transform, filter, or route messages, use `listenTo` directly:
940
+
941
+ ```typescript
942
+ export class ChatViewModel extends ViewModel<State> {
943
+ private channel = singleton(ChatChannel);
944
+
945
+ protected onInit() {
946
+ this.listenTo(this.channel, 'message', (msg) => {
947
+ this.set({ messages: [...this.state.messages, msg] });
948
+ });
949
+
950
+ this.channel.connect();
951
+ }
952
+ }
953
+ ```
954
+
955
+ Because Channel implements `Subscribable`, it is auto-tracked as a subscribable member — getter memoization sees connection status changes. Use `listenTo` for `channel.on()` event subscriptions — it auto-cleans up on both dispose and reset, just like `subscribeTo` does for state subscriptions.
956
+
957
+ ---
958
+
959
+ ## Controller
960
+
961
+ Controller is a minimal base class for stateless orchestrators. It provides lifecycle management (`init`/`dispose`), `disposeSignal`, `subscribeTo`, `listenTo`, and `addCleanup` — but no state, no getters, no async tracking.
962
+
963
+ Most orchestration fits in a single ViewModel. Use a Controller only when coordinating multiple ViewModels in a single workflow (multi-step checkout, drag-and-drop between lists, complex form wizards). It is not a prescribed pattern — reach for it only when a ViewModel can't do the job alone.
964
+
965
+ ---
966
+
967
+ ## Models
968
+
969
+ Models represent individual entities with validation and dirty tracking. Use them for create and edit forms.
970
+
971
+ **When to use Model vs ViewModel:** Use a **Model** when editing a single entity with field-level validation, commit/rollback semantics, and dirty tracking. Use a **ViewModel** for everything else.
972
+
973
+ ```typescript
974
+ class UserFormModel extends Model<UserFormState> {
975
+ setName(name: string) { this.set({ name }); }
976
+ setEmail(email: string) { this.set({ email }); }
977
+
978
+ protected validate(state: UserFormState) {
979
+ const errors: Partial<Record<keyof UserFormState, string>> = {};
980
+ if (!state.name.trim()) errors.name = 'Name is required';
981
+ if (!state.email.includes('@')) errors.email = 'Invalid email';
982
+ return errors;
983
+ }
984
+ }
985
+ ```
986
+
987
+ ### Models Inside ViewModels
988
+
989
+ For forms with surrounding page state (submission, server errors), wrap the Model inside a ViewModel:
990
+
991
+ ```typescript
992
+ class EditUserViewModel extends ViewModel<EditState, EditEvents> {
993
+ public model!: UserFormModel;
994
+ private service = singleton(UserService);
995
+
996
+ protected async onInit() {
997
+ const user = await this.service.getById(this.userId, this.disposeSignal);
998
+ this.model = new UserFormModel(user);
999
+ this.set({ draft: user });
1000
+ }
1001
+
1002
+ async save() {
1003
+ if (!this.model.valid) return;
1004
+ await this.service.update(this.userId, this.model.state, this.disposeSignal);
1005
+ this.model.commit();
1006
+ this.emit('saved', { id: this.userId });
1007
+ }
1008
+
1009
+ protected onDispose() {
1010
+ this.model?.dispose();
1011
+ }
1012
+ }
1013
+ ```
1014
+
1015
+ The component:
1016
+
1017
+ ```tsx
1018
+ function EditUserPage() {
1019
+ const [state, vm] = useLocal(EditUserViewModel, { draft: null });
1020
+ const loadState = vm.async.onInit;
1021
+ const saveState = vm.async.save;
1022
+
1023
+ useEvent(vm, 'saved', ({ id }) => {
1024
+ toast.success('User saved');
1025
+ navigate(`/users/${id}`);
1026
+ });
1027
+
1028
+ if (loadState.loading || !vm.model) return <Spinner />;
1029
+ if (loadState.error) return <ErrorBanner message={loadState.error} />;
1030
+
1031
+ return <EditUserForm model={vm.model} onSave={() => vm.save()} saving={saveState.loading} />;
1032
+ }
1033
+
1034
+ function EditUserForm({ model, onSave, saving }: Props) {
1035
+ const { state, errors, valid, dirty } = useModel(() => model);
1036
+
1037
+ return (
1038
+ <form onSubmit={e => { e.preventDefault(); onSave(); }}>
1039
+ <input value={state.name} onChange={e => model.setName(e.target.value)} />
1040
+ {errors.name && <span>{errors.name}</span>}
1041
+ <button disabled={!valid || !dirty || saving}>
1042
+ {saving ? 'Saving…' : 'Save'}
1043
+ </button>
1044
+ </form>
1045
+ );
1046
+ }
1047
+ ```
1048
+
1049
+ `useModel` binds a Model to a React component — it creates the instance from a factory, auto-initializes, auto-disposes, and subscribes to changes. The returned `ModelHandle` provides `state`, `errors`, `valid`, `dirty`, and `model`. For large forms with per-field isolation, use `useModelRef` in the parent (lifecycle-only, no subscription) and `useField(model, 'fieldName')` in children for surgical re-renders. See `src/react/use-model.md` for full API details.
1050
+
1051
+ ---
1052
+
1053
+ ## Singleton vs Component-Scoped
1054
+
1055
+ | Class | Default Scope | Why |
1056
+ |---|---|---|
1057
+ | Service | Singleton | Stateless, shared infrastructure |
1058
+ | Collection | Singleton | Shared data cache across ViewModels |
1059
+ | EventBus (app-level) | Singleton | App-wide communication |
1060
+ | Channel | Singleton | Persistent connection shared across ViewModels |
1061
+ | ViewModel | Component-scoped | Tied to a specific component's lifecycle |
1062
+ | Controller | Component-scoped | Tied to a specific workflow's lifecycle |
1063
+ | Model | Component-scoped | Tied to a specific form's lifecycle |
1064
+
1065
+ Singleton ViewModels are the exception for app-wide state (auth, theme, cart). Default to `useLocal` and promote to `useSingleton` only when needed.
1066
+
1067
+ ---
1068
+
1069
+ ## Dev Mode
1070
+
1071
+ Enable `__MVC_KIT_DEV__` during development to catch common mistakes early:
1072
+
1073
+ ```typescript
1074
+ // vite.config.ts
1075
+ export default defineConfig({
1076
+ define: {
1077
+ __MVC_KIT_DEV__: process.env.NODE_ENV !== 'production',
1078
+ },
1079
+ });
1080
+ ```
1081
+
1082
+ Dev mode catches:
1083
+
1084
+ - **`set()` inside a getter** — prevents infinite loops with a clear console error.
1085
+ - **Ghost async operations** — warns when a ViewModel is disposed with pending async calls, suggesting `disposeSignal`.
1086
+ - **Method call after dispose** — warns and returns `undefined` instead of silently proceeding.
1087
+ - **Method call before init** — warns that async tracking isn't active yet.
1088
+
1089
+ In production, omit the flag (or set to `false`). All dev checks are dead-code-eliminated by minifiers — zero runtime cost.
1090
+
1091
+ ---
1092
+
1093
+ ## Error Handling Strategy
1094
+
1095
+ mvc-kit provides a three-layer error handling strategy:
1096
+
1097
+ **Layer 1 — Async Tracking (automatic).** For most async methods, write the happy path. Errors are captured in `vm.async.methodName.error` and `errorCode`. The component reads them directly. No try/catch needed. See [Async Methods](#async-methods) for examples.
1098
+
1099
+ **Layer 2 — Imperative Events (explicit).** When an error should trigger a specific UI effect (toast, redirect, modal), add try/catch, emit a ViewModel event, and **re-throw** so async tracking still captures it. `emit()` and `set()` are no-ops after dispose, so no `isAbortError()` guard is needed for them. See [Imperative Events](#imperative-events) for examples.
1100
+
1101
+ **Layer 3 — Error Classification (services).** Services throw `HttpError` for HTTP failures. `classifyError()` normalizes any error into a canonical shape:
1102
+
1103
+ ```typescript
1104
+ const appError = classifyError(error);
1105
+ // appError.code: 'unauthorized' | 'network' | 'timeout' | 'abort' | ...
1106
+ // appError.message: human-readable string
1107
+ ```
1108
+
1109
+ **When to use `isAbortError()`:** The async tracking wrapper swallows AbortErrors at the outer promise level, but your internal catch blocks do receive them. You only need `isAbortError()` when the catch block has side effects on shared state that outlives the ViewModel — like rolling back optimistic updates on a singleton Collection. You don't need it when the catch block only calls `set()` and `emit()` (both are no-ops after dispose), and you never need it in methods without try/catch.
1110
+
1111
+ ---
1112
+
1113
+ ## Testing
1114
+
1115
+ Test ViewModels by constructing, calling `init()`, invoking methods, and asserting state, getters, and async status. Always call `teardownAll()` in `beforeEach` to reset the singleton registry.
1116
+
1117
+ ```typescript
1118
+ import { singleton, teardownAll } from 'mvc-kit';
1119
+
1120
+ beforeEach(() => teardownAll());
1121
+
1122
+ test('filtered getter applies search', () => {
1123
+ const collection = singleton(UsersCollection);
1124
+ collection.reset([
1125
+ { id: '1', firstName: 'Alice', status: 'on_duty' },
1126
+ { id: '2', firstName: 'Bob', status: 'off_duty' },
1127
+ ]);
1128
+
1129
+ const vm = new UsersViewModel({ search: '', roleFilter: 'all' });
1130
+ vm.init();
1131
+
1132
+ expect(vm.items).toHaveLength(2);
1133
+ expect(vm.filtered).toHaveLength(2);
1134
+
1135
+ vm.setSearch('alice');
1136
+ expect(vm.filtered).toHaveLength(1);
1137
+ expect(vm.filtered[0].firstName).toBe('Alice');
1138
+
1139
+ vm.dispose();
1140
+ });
1141
+ ```
1142
+
1143
+ **Async methods:** Assert against `vm.async.methodName.loading` and `vm.async.methodName.error` before and after awaiting the method.
1144
+
1145
+ **Events:** Subscribe via `vm.events.on('eventName', handler)` and assert the handler was called with expected payload.
1146
+
1147
+ **Models:** Construct, call setters, assert `model.valid`, `model.errors`, and `model.dirty`. See `src/Model.md`.
1148
+
1149
+ **Integration tests with Provider:** See `src/react/use-model.md` and `src/react/use-local.md` for examples with mock dependency injection.
1150
+
1151
+ ---
1152
+
1153
+ ## Composable Helpers
1154
+
1155
+ Sorting, Pagination, Selection, Feed, and Pending are composable helpers that extend `Trackable`. They integrate with auto-tracking: any ViewModel getter that reads from a helper auto-invalidates when the helper's state changes.
1156
+
1157
+ ### Custom Helpers with Trackable
1158
+
1159
+ When integrating third-party SDKs or building custom reactive state, extend `Trackable` instead of reimplementing subscribe/dispose/bind boilerplate:
1160
+
1161
+ ```typescript
1162
+ import { Trackable } from 'mvc-kit';
1163
+
1164
+ class RPCQuery<Data> extends Trackable {
1165
+ private _data: Data | undefined;
1166
+ private _loading = false;
1167
+
1168
+ get data() { return this._data; }
1169
+ get loading() { return this._loading; }
1170
+
1171
+ async call(): Promise<void> {
1172
+ this._loading = true;
1173
+ this.notify();
1174
+ this._data = await fetchData();
1175
+ this._loading = false;
1176
+ this.notify();
1177
+ }
1178
+ }
1179
+ ```
1180
+
1181
+ Use as a ViewModel property for auto-tracking, or directly with `useLocal` for component-scoped reactive instances.
1182
+
1183
+ ### When to Use Which Helper
1184
+
1185
+ | Scenario | Helper |
1186
+ |----------|--------|
1187
+ | Table column sorting (single or multi) | `Sorting<T>` |
1188
+ | Client-side page-based pagination | `Pagination` |
1189
+ | Row/item selection (checkboxes, bulk actions) | `Selection<K>` |
1190
+ | Server-side cursor pagination (infinite scroll) | `Feed<T>` |
1191
+ | Per-item operation retry with status tracking | `Pending<K, Meta?>` |
1192
+
1193
+ ### Pattern: Table with Sort + Filter + Pagination + Selection
1194
+
1195
+ ```typescript
1196
+ interface UsersFilter { search: string; roleFilter: 'all' | Role }
1197
+
1198
+ class UsersListVM extends ViewModel<UsersFilter> {
1199
+ private users = singleton(UsersResource);
1200
+
1201
+ readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
1202
+ readonly pagination = new Pagination({ pageSize: 25 });
1203
+ readonly selection = new Selection<string>();
1204
+
1205
+ get filtered(): User[] {
1206
+ const { search, roleFilter } = this.state;
1207
+ let result = this.users.items;
1208
+ if (search) {
1209
+ const q = search.toLowerCase();
1210
+ result = result.filter(u => u.name.toLowerCase().includes(q));
1211
+ }
1212
+ if (roleFilter !== 'all') result = result.filter(u => u.role === roleFilter);
1213
+ return result;
1214
+ }
1215
+
1216
+ get items(): User[] {
1217
+ return this.pagination.apply(this.sorting.apply(this.filtered));
1218
+ }
1219
+
1220
+ get total() { return this.filtered.length; }
1221
+
1222
+ protected onInit() {
1223
+ if (this.users.length === 0) this.users.loadAll();
1224
+ }
1225
+
1226
+ setSearch(search: string) { this.set({ search }); this.pagination.reset(); }
1227
+ }
1228
+ ```
1229
+
1230
+ ### Pattern: Infinite Scroll Feed
1231
+
1232
+ ```typescript
1233
+ class SocialFeedVM extends ViewModel {
1234
+ private resource = singleton(PostsResource);
1235
+ readonly feed = new Feed();
1236
+
1237
+ get items() { return this.resource.items; }
1238
+ get hasMore() { return this.feed.hasMore; }
1239
+
1240
+ protected onInit() {
1241
+ if (this.resource.length === 0) this.loadMore();
1242
+ }
1243
+
1244
+ async loadMore() {
1245
+ const result = await fetchPosts(this.feed.cursor, 20, this.disposeSignal);
1246
+ this.resource.upsert(...result.items);
1247
+ this.feed.setResult(result);
1248
+ }
1249
+ }
1250
+ ```
1251
+
1252
+ ### Pattern: Resilient Optimistic Updates with Pending
1253
+
1254
+ ```typescript
1255
+ class ItemsResource extends Resource<Item> {
1256
+ readonly pending = new Pending<string>();
1257
+
1258
+ async deleteItem(id: string) {
1259
+ this.optimistic(() => this.remove(id));
1260
+ this.pending.enqueue(id, 'delete', async (signal) => {
1261
+ await api.deleteItem(id, signal);
1262
+ });
1263
+ }
1264
+
1265
+ protected override onDispose() {
1266
+ this.pending.dispose();
1267
+ }
1268
+ }
1269
+
1270
+ class ItemsVM extends ViewModel<{ filter: string }> {
1271
+ private resource = singleton(ItemsResource);
1272
+
1273
+ get pending() { return this.resource.pending; }
1274
+ get items() { return this.resource.items; }
1275
+
1276
+ deleteItem(id: string) { this.resource.deleteItem(id); }
1277
+ retryFailed() { this.resource.pending.retryAll(); }
1278
+ }
1279
+ ```
1280
+
1281
+ ### Key Points
1282
+
1283
+ - Helpers are **plain classes** with a `subscribe()` method — no base class needed
1284
+ - Declare as `readonly` instance properties (not in state)
1285
+ - Reset pagination on filter changes: `this.pagination.reset()`
1286
+ - Reset feed on filter changes: `this.feed.reset()`
1287
+ - Use `Selection.set(...keys)` to replace the entire selection atomically (single notification, no-op if unchanged)
1288
+ - Use `Feed.replacePage(page)` for pull-to-refresh — replaces all items and updates cursor/hasMore atomically
1289
+ - Pending lives on the **singleton Resource**, not the component-scoped ViewModel — operations survive unmount
1290
+ - Use the signal from Pending's `enqueue` callback, not `vm.disposeSignal`
1291
+ - Helpers without lifecycle (Sorting, Pagination, Selection, Feed) are garbage collected with the ViewModel; Pending has `dispose()` and should be cleaned up in the Resource's `onDispose()`
1292
+
1293
+ ---
1294
+
1295
+ ## Headless React Components
1296
+
1297
+ `DataTable`, `CardList`, and `InfiniteScroll` are unstyled, semantic HTML components with render-prop customization. They compose naturally with the helpers above but work independently.
1298
+
1299
+ ### Wiring Helpers to Components
1300
+
1301
+ Pass helpers directly — DataTable duck-types their interfaces:
1302
+
1303
+ ```tsx
1304
+ function UsersPage() {
1305
+ const [state, vm] = useLocal(UsersListVM, { search: '', roleFilter: 'all' });
1306
+ return (
1307
+ <DataTable
1308
+ items={vm.items}
1309
+ columns={columns}
1310
+ sort={vm.sorting}
1311
+ selection={vm.selection}
1312
+ pagination={vm.pagination}
1313
+ paginationTotal={vm.total}
1314
+ renderEmpty={() => <p>No users found.</p>}
1315
+ />
1316
+ );
1317
+ }
1318
+ ```
1319
+
1320
+ ### InfiniteScroll + CardList
1321
+
1322
+ ```tsx
1323
+ function SocialFeed() {
1324
+ const [, vm] = useLocal(SocialFeedVM, {});
1325
+ return (
1326
+ <InfiniteScroll
1327
+ hasMore={vm.hasMore}
1328
+ loading={vm.async.loadMore?.loading}
1329
+ onLoadMore={() => vm.loadMore()}
1330
+ renderEnd={() => <p>You've seen it all!</p>}
1331
+ >
1332
+ <CardList
1333
+ items={vm.items}
1334
+ renderItem={post => <PostCard post={post} />}
1335
+ layout="grid"
1336
+ columns={2}
1337
+ />
1338
+ </InfiniteScroll>
1339
+ );
1340
+ }
1341
+ ```
1342
+
1343
+ ### Styling with Data Attributes
1344
+
1345
+ Components emit data attributes for CSS hooks — no class name opinions:
1346
+
1347
+ ```css
1348
+ [data-component="data-table"] table { width: 100%; }
1349
+ [data-component="data-table"] th[data-sorted] { font-weight: bold; }
1350
+ [data-component="data-table"] tr[data-selected] { background: #e8f0fe; }
1351
+ [data-component="card-list"][data-layout="grid"] { --card-list-columns: 4; }
1352
+ ```
1353
+
1354
+ ---
1355
+
1356
+ ## Quick Reference
1357
+
1358
+ ### Do
1359
+
1360
+ - State holds only source-of-truth values (user input, filters)
1361
+ - Collection data accessed via getters reading from collection members directly
1362
+ - Derived values are `get` accessors on the ViewModel
1363
+ - One ViewModel per component via `useLocal`
1364
+ - Pass `this.disposeSignal` to every async call
1365
+ - Re-throw errors in explicit try/catch blocks
1366
+ - Use `collection.optimistic()` for instant UI feedback
1367
+ - Use `onInit()` for data loading and subscription setup
1368
+ - Name public methods as user-intent verbs
1369
+
1370
+ ### Don't
1371
+
1372
+ - Put `loading`/`error` in your State interface — use `vm.async.methodName`
1373
+ - Mirror collection data into state with `subscribeTo` + `set()` — use a getter
1374
+ - Store derived values in state — use getters
1375
+ - Write `try/catch` for standard loads — async tracking handles it
1376
+ - Call `set()` inside a getter — creates infinite loop
1377
+ - Import infrastructure (Services, Collections) in components
1378
+ - Use `useEffect` for data loading — use `onInit()`
1379
+ - Use multiple `useLocal` in one component — split into two components
1380
+ - Use `addCleanup` for `channel.on()`/`bus.on()` subscriptions — use `listenTo` instead (reset-safe, impossible to forget)
1381
+ - Swallow errors without re-throwing — breaks async tracking
1382
+ - Compose a separate EventBus for ViewModel events — use the second generic
1383
+ - Put cache/state in Services — use Collections
1384
+ - Write two-step setters that call refilter methods — getters handle derivation
1385
+ - Manually snapshot/restore for optimistic updates — use `collection.optimistic()`
1386
+ - Empty Collection subclass + Service + manual cache checking when a Resource would eliminate the boilerplate
1387
+ - Pass-through Service wrapping a typed API client (RPC, tRPC, GraphQL codegen) → call the client directly from Resource
1388
+ - `Pending` as a ViewModel own property — it dies on unmount; put it on the singleton Resource
1389
+ - Passing `this.disposeSignal` to fetch inside Pending's execute callback — would abort on VM unmount, defeating resilience
1390
+ - Using `collection.optimistic()` rollback with Pending — snapshot goes stale during retries