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.
- package/BEST_PRACTICES.md +1390 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
- package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
- package/agent-config/lib/install-claude.mjs +8 -2
- package/examples/primitive/channel.ts +109 -0
- package/examples/primitive/collection.ts +118 -0
- package/examples/primitive/controller.ts +118 -0
- package/examples/primitive/counter.ts +108 -0
- package/examples/primitive/env.d.ts +1 -0
- package/examples/primitive/eventbus.ts +77 -0
- package/examples/primitive/feed.ts +162 -0
- package/examples/primitive/model.ts +82 -0
- package/examples/primitive/pagination.ts +91 -0
- package/examples/primitive/pending.ts +189 -0
- package/examples/primitive/persistent-collection.ts +116 -0
- package/examples/primitive/resource.ts +114 -0
- package/examples/primitive/selection.ts +96 -0
- package/examples/primitive/sorting.ts +112 -0
- package/examples/primitive/timer.ts +58 -0
- package/examples/primitive/trackable.ts +225 -0
- package/examples/primitive/tsconfig.json +20 -0
- package/examples/primitive/viewmodel-service.ts +161 -0
- package/examples/react/AuthExample/index.html +12 -0
- package/examples/react/AuthExample/src/App.tsx +29 -0
- package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
- package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
- package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
- package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
- package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
- package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
- package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
- package/examples/react/AuthExample/src/env.d.ts +10 -0
- package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
- package/examples/react/AuthExample/src/main.tsx +10 -0
- package/examples/react/AuthExample/src/mock/api.ts +78 -0
- package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
- package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
- package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
- package/examples/react/AuthExample/src/styles.css +445 -0
- package/examples/react/AuthExample/src/types/auth.ts +12 -0
- package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
- package/examples/react/AuthExample/tsconfig.json +22 -0
- package/examples/react/AuthExample/vite.config.ts +18 -0
- package/examples/react/ComplexApp/index.html +12 -0
- package/examples/react/ComplexApp/src/App.tsx +17 -0
- package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
- package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
- package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
- package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
- package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
- package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
- package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
- package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
- package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
- package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
- package/examples/react/ComplexApp/src/env.d.ts +10 -0
- package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/ComplexApp/src/main.tsx +10 -0
- package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
- package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
- package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
- package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
- package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
- package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
- package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
- package/examples/react/ComplexApp/src/styles.css +463 -0
- package/examples/react/ComplexApp/src/types/activity.ts +8 -0
- package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
- package/examples/react/ComplexApp/src/types/social.ts +8 -0
- package/examples/react/ComplexApp/src/types/users.ts +6 -0
- package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
- package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
- package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
- package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
- package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/tsconfig.json +22 -0
- package/examples/react/ComplexApp/vite.config.ts +18 -0
- package/examples/react/FullApp/index.html +12 -0
- package/examples/react/FullApp/src/App.tsx +28 -0
- package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
- package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
- package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
- package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
- package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
- package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
- package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
- package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
- package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
- package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
- package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
- package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
- package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
- package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
- package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
- package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
- package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
- package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
- package/examples/react/FullApp/src/env.d.ts +10 -0
- package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/FullApp/src/main.tsx +10 -0
- package/examples/react/FullApp/src/mock/delay.ts +21 -0
- package/examples/react/FullApp/src/mock/locations.ts +76 -0
- package/examples/react/FullApp/src/mock/messages.ts +237 -0
- package/examples/react/FullApp/src/mock/users.ts +84 -0
- package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
- package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
- package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
- package/examples/react/FullApp/src/services/AuthService.ts +18 -0
- package/examples/react/FullApp/src/services/LocationService.ts +23 -0
- package/examples/react/FullApp/src/services/MessageService.ts +65 -0
- package/examples/react/FullApp/src/services/UserService.ts +23 -0
- package/examples/react/FullApp/src/styles.css +767 -0
- package/examples/react/FullApp/src/types/conversation.ts +7 -0
- package/examples/react/FullApp/src/types/location.ts +12 -0
- package/examples/react/FullApp/src/types/message.ts +7 -0
- package/examples/react/FullApp/src/types/user.ts +10 -0
- package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
- package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
- package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
- package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
- package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
- package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
- package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
- package/examples/react/FullApp/tsconfig.json +22 -0
- package/examples/react/FullApp/vite.config.ts +18 -0
- package/examples/react/WorkerApp/index.html +12 -0
- package/examples/react/WorkerApp/src/App.tsx +24 -0
- package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
- package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
- package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
- package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
- package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
- package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
- package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
- package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
- package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
- package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
- package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
- package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
- package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
- package/examples/react/WorkerApp/src/env.d.ts +10 -0
- package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/WorkerApp/src/main.tsx +10 -0
- package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
- package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
- package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
- package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
- package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
- package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
- package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
- package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
- package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
- package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
- package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
- package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
- package/examples/react/WorkerApp/src/styles.css +756 -0
- package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
- package/examples/react/WorkerApp/src/types/message.ts +7 -0
- package/examples/react/WorkerApp/src/types/shift.ts +13 -0
- package/examples/react/WorkerApp/src/types/site.ts +8 -0
- package/examples/react/WorkerApp/src/types/worker.ts +8 -0
- package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
- package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
- package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
- package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
- package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
- package/examples/react/WorkerApp/tsconfig.json +22 -0
- package/examples/react/WorkerApp/vite.config.ts +18 -0
- 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
|