@view-models/core 4.1.0 → 5.0.0
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/README.md +55 -60
- package/dist/index.d.ts +31 -38
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ViewModel.ts +34 -4
- package/src/index.ts +0 -1
- package/src/derived.ts +0 -44
package/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
[](https://github.com/sunesimonsen/view-models-core/actions/workflows/ci.yml)
|
|
4
4
|
[](https://unpkg.com/@view-models/core@latest/dist/index.js)
|
|
5
5
|
|
|
6
|
-
A lightweight, framework-agnostic library for building reactive view models with
|
|
6
|
+
A lightweight, framework-agnostic library for building reactive view models with
|
|
7
|
+
TypeScript. Separate your business logic from your UI framework with a simple,
|
|
8
|
+
testable pattern.
|
|
7
9
|
|
|
8
10
|

|
|
9
11
|
|
|
@@ -38,17 +40,17 @@ class CounterViewModel extends ViewModel<CounterState> {
|
|
|
38
40
|
super({ count: 0 });
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
increment() {
|
|
43
|
+
increment = () => {
|
|
42
44
|
super.update({
|
|
43
45
|
count: super.state.count + 1,
|
|
44
46
|
});
|
|
45
|
-
}
|
|
47
|
+
};
|
|
46
48
|
|
|
47
|
-
decrement() {
|
|
49
|
+
decrement = () => {
|
|
48
50
|
super.update({
|
|
49
51
|
count: super.state.count - 1,
|
|
50
52
|
});
|
|
51
|
-
}
|
|
53
|
+
};
|
|
52
54
|
}
|
|
53
55
|
```
|
|
54
56
|
|
|
@@ -84,7 +86,8 @@ describe("CounterViewModel", () => {
|
|
|
84
86
|
|
|
85
87
|
## Framework Integration
|
|
86
88
|
|
|
87
|
-
The view models are designed to work with framework-specific adapters. Upcoming
|
|
89
|
+
The view models are designed to work with framework-specific adapters. Upcoming
|
|
90
|
+
adapters include:
|
|
88
91
|
|
|
89
92
|
- [@view-models/react](https://github.com/sunesimonsen/view-models-react) - React hooks integration
|
|
90
93
|
- [@view-models/preact](https://github.com/sunesimonsen/view-models-preact) - Preact hooks integration
|
|
@@ -106,60 +109,6 @@ function Counter({ model }) {
|
|
|
106
109
|
}
|
|
107
110
|
```
|
|
108
111
|
|
|
109
|
-
## Derived State
|
|
110
|
-
|
|
111
|
-
The `derived` utility creates memoized mapper functions for computing derived state from your view model. This is useful when you need to transform or aggregate state values without recomputing on every render.
|
|
112
|
-
|
|
113
|
-
The `derived` function uses `Object.is` to compare inputs. When the input reference hasn't changed (which is guaranteed when using immutable updates), the cached output is returned without re-executing the mapper function.
|
|
114
|
-
|
|
115
|
-
```typescript
|
|
116
|
-
import { ViewModel, derived } from "@view-models/core";
|
|
117
|
-
|
|
118
|
-
type CartState = {
|
|
119
|
-
items: Array<{ id: string; price: number; quantity: number }>;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
class CartViewModel extends ViewModel<CartState> {
|
|
123
|
-
constructor() {
|
|
124
|
-
super({ items: [] });
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
addItem(item: { id: string; price: number; quantity: number }) {
|
|
128
|
-
super.update({
|
|
129
|
-
items: [...super.state.items, item],
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Create a derived mapper that computes cart statistics
|
|
135
|
-
const selectCartStats = derived((state: CartState) => ({
|
|
136
|
-
total: state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
|
137
|
-
itemCount: state.items.reduce((sum, item) => sum + item.quantity, 0),
|
|
138
|
-
}));
|
|
139
|
-
|
|
140
|
-
// Use it to compute derived state
|
|
141
|
-
const cart = new CartViewModel();
|
|
142
|
-
cart.addItem({ id: "1", price: 10, quantity: 2 });
|
|
143
|
-
cart.addItem({ id: "2", price: 15, quantity: 1 });
|
|
144
|
-
const stats = selectCartStats(cart.state); // { total: 35, itemCount: 3 }
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
Derived mappers are particularly useful with framework hooks like `useDerivedState` (from [@view-models/react](https://github.com/sunesimonsen/view-models-react)):
|
|
148
|
-
|
|
149
|
-
```typescript
|
|
150
|
-
function CartSummary({ cart }) {
|
|
151
|
-
// Only recomputes when cart.state reference changes
|
|
152
|
-
const stats = useDerivedState(cart, selectCartStats);
|
|
153
|
-
|
|
154
|
-
return (
|
|
155
|
-
<div>
|
|
156
|
-
<p>Items: {stats.itemCount}</p>
|
|
157
|
-
<p>Total: ${stats.total}</p>
|
|
158
|
-
</div>
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
```
|
|
162
|
-
|
|
163
112
|
## API Reference
|
|
164
113
|
|
|
165
114
|
For detailed API documentation, see [docs](./docs).
|
|
@@ -242,6 +191,52 @@ class TodosViewModel extends ViewModel<TodosState> {
|
|
|
242
191
|
}
|
|
243
192
|
```
|
|
244
193
|
|
|
194
|
+
### Derived State with prepareState
|
|
195
|
+
|
|
196
|
+
Override the `prepareState` method to compute derived values or enforce
|
|
197
|
+
invariants before state is committed. This hook intercepts every state update,
|
|
198
|
+
allowing you to transform the state before subscribers are notified:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
type FormState = {
|
|
202
|
+
firstName: string;
|
|
203
|
+
lastName: string;
|
|
204
|
+
fullName: string;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
class FormViewModel extends ViewModel<FormState> {
|
|
208
|
+
constructor() {
|
|
209
|
+
super({ firstName: "", lastName: "", fullName: "" });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
protected prepareState(updatedState: FormState): FormState {
|
|
213
|
+
return {
|
|
214
|
+
...updatedState,
|
|
215
|
+
fullName: `${updatedState.firstName} ${updatedState.lastName}`.trim(),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setFirstName(firstName: string) {
|
|
220
|
+
super.update({ firstName });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setLastName(lastName: string) {
|
|
224
|
+
super.update({ lastName });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const form = new FormViewModel();
|
|
229
|
+
form.setFirstName("John");
|
|
230
|
+
form.setLastName("Doe");
|
|
231
|
+
console.log(form.state.fullName); // "John Doe"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
This pattern is useful for:
|
|
235
|
+
|
|
236
|
+
- Computing derived values that depend on multiple state fields
|
|
237
|
+
- Enforcing invariants (e.g., ensuring values stay within bounds)
|
|
238
|
+
- Normalizing state (e.g., trimming strings, sorting arrays)
|
|
239
|
+
|
|
245
240
|
## License
|
|
246
241
|
|
|
247
242
|
MIT License
|
package/dist/index.d.ts
CHANGED
|
@@ -1,37 +1,3 @@
|
|
|
1
|
-
type Mapper<I, O> = (input: I) => O;
|
|
2
|
-
/**
|
|
3
|
-
* A branded type for derived state mapper functions created by the `derived` utility.
|
|
4
|
-
* This type ensures that mapper functions are properly memoized before being used
|
|
5
|
-
* with hooks like `useDerivedState`.
|
|
6
|
-
*/
|
|
7
|
-
type DerivedMapper<I, O> = Mapper<I, O> & {
|
|
8
|
-
__brand: "derived";
|
|
9
|
-
};
|
|
10
|
-
/**
|
|
11
|
-
* Creates a memoized derived state mapper function that caches results based on input identity.
|
|
12
|
-
*
|
|
13
|
-
* The memoization uses `Object.is` to compare inputs, making it ideal for use with
|
|
14
|
-
* immutable state objects. When the input reference hasn't changed, the cached output
|
|
15
|
-
* is returned without re-executing the function.
|
|
16
|
-
*
|
|
17
|
-
* @template I - The input type
|
|
18
|
-
* @template O - The output type
|
|
19
|
-
* @param fn - A pure function that transforms input to output
|
|
20
|
-
* @returns A memoized derived mapper function
|
|
21
|
-
*
|
|
22
|
-
* @example
|
|
23
|
-
* ```ts
|
|
24
|
-
* const selectStats = derived(({ items }: AppState) => ({
|
|
25
|
-
* total: items.reduce((sum, item) => sum + item.price, 0),
|
|
26
|
-
* count: items.length
|
|
27
|
-
* }));
|
|
28
|
-
*
|
|
29
|
-
* // Use with useDerivedState
|
|
30
|
-
* const stats = selectStats(model.state);
|
|
31
|
-
* ```
|
|
32
|
-
*/
|
|
33
|
-
declare const derived: <I, O>(fn: Mapper<I, O>) => DerivedMapper<I, O>;
|
|
34
|
-
|
|
35
1
|
/**
|
|
36
2
|
* Function that gets called when the state changes.
|
|
37
3
|
*/
|
|
@@ -99,7 +65,7 @@ declare abstract class ViewModel<S extends object> {
|
|
|
99
65
|
* This method is protected and should only be called from within your view model subclass.
|
|
100
66
|
* The partial state is merged with the current state to create the new state.
|
|
101
67
|
*
|
|
102
|
-
* @param
|
|
68
|
+
* @param update - Partial state to merge with the current state
|
|
103
69
|
*
|
|
104
70
|
* @example
|
|
105
71
|
* ```typescript
|
|
@@ -108,7 +74,34 @@ declare abstract class ViewModel<S extends object> {
|
|
|
108
74
|
* });
|
|
109
75
|
* ```
|
|
110
76
|
*/
|
|
111
|
-
protected update(
|
|
77
|
+
protected update(update: Partial<S>): void;
|
|
78
|
+
/**
|
|
79
|
+
* Hook to transform state before it is committed and subscribers are notified.
|
|
80
|
+
*
|
|
81
|
+
* Override this method in your subclass to intercept and modify state updates.
|
|
82
|
+
* This is useful for computing derived values, enforcing invariants, or
|
|
83
|
+
* normalizing state before it becomes the new state.
|
|
84
|
+
*
|
|
85
|
+
* By default, this method returns the input unchanged.
|
|
86
|
+
*
|
|
87
|
+
* @param updatedState - The new state that will be committed
|
|
88
|
+
* @returns The state to commit (can be modified or the same object)
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```typescript
|
|
92
|
+
* type FormState = { firstName: string; lastName: string; fullName: string };
|
|
93
|
+
*
|
|
94
|
+
* class FormViewModel extends ViewModel<FormState> {
|
|
95
|
+
* protected prepareState(updatedState: FormState): FormState {
|
|
96
|
+
* return {
|
|
97
|
+
* ...updatedState,
|
|
98
|
+
* fullName: `${updatedState.firstName} ${updatedState.lastName}`,
|
|
99
|
+
* };
|
|
100
|
+
* }
|
|
101
|
+
* }
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
protected prepareState(updatedState: S): S;
|
|
112
105
|
/**
|
|
113
106
|
* Get the current state.
|
|
114
107
|
*
|
|
@@ -117,5 +110,5 @@ declare abstract class ViewModel<S extends object> {
|
|
|
117
110
|
get state(): S;
|
|
118
111
|
}
|
|
119
112
|
|
|
120
|
-
export { ViewModel
|
|
121
|
-
export type {
|
|
113
|
+
export { ViewModel };
|
|
114
|
+
export type { ViewModelListener };
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
class t{subscribe(t){return this.t.add(t),()=>{this.t.delete(t)}}constructor(t){this.t=new Set,this.i=this.prepareState(t)}update(t){this.i=this.prepareState({...this.i,...t}),this.t.forEach(t=>t())}prepareState(t){return t}get state(){return this.i}}export{t as ViewModel};
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../src/
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/ViewModel.ts"],"sourcesContent":["/**\n * Function that gets called when the state changes.\n */\nexport type ViewModelListener = () => void;\n\n/**\n * Abstract base class for creating reactive view models.\n *\n * A ViewModel manages state and notifies subscribers when the state changes.\n * Extend this class to create your own view models with custom business logic.\n *\n * @template S - The state type\n *\n * @example\n * ```typescript\n * type CounterState = { count: number };\n *\n * class CounterViewModel extends ViewModel<CounterState> {\n * constructor() {\n * super({ count: 0 });\n * }\n *\n * increment() {\n * super.update({ count: super.state.count + 1 });\n * }\n * }\n *\n * const counter = new CounterViewModel();\n * const unsubscribe = counter.subscribe(() => {\n * console.log('Count:', counter.state.count);\n * });\n * counter.increment(); // Logs: Count: 1\n * ```\n */\nexport abstract class ViewModel<S extends object> {\n private _listeners = new Set<ViewModelListener>();\n private _state: S;\n\n /**\n * Subscribe to state changes.\n *\n * The listener will be called immediately after any state update.\n *\n * @param listener - Function to call when state changes\n * @returns Function to unsubscribe the listener\n *\n * @example\n * ```typescript\n * const unsubscribe = viewModel.subscribe(() => {\n * console.log('State changed:', viewModel.state);\n * });\n *\n * // Later, when you want to stop listening:\n * unsubscribe();\n * ```\n */\n subscribe(listener: ViewModelListener): () => void {\n this._listeners.add(listener);\n return () => {\n this._listeners.delete(listener);\n };\n }\n\n /**\n * Create a new ViewModel with the given initial state.\n *\n * @param initialState - The initial state of the view model\n */\n constructor(initialState: S) {\n this._state = this.prepareState(initialState);\n }\n\n /**\n * Update the state and notify all subscribers.\n *\n * This method is protected and should only be called from within your view model subclass.\n * The partial state is merged with the current state to create the new state.\n *\n * @param update - Partial state to merge with the current state\n *\n * @example\n * ```typescript\n * super.update({\n * count: super.state.count + 1\n * });\n * ```\n */\n protected update(update: Partial<S>) {\n this._state = this.prepareState({ ...this._state, ...update });\n this._listeners.forEach((l) => l());\n }\n\n /**\n * Hook to transform state before it is committed and subscribers are notified.\n *\n * Override this method in your subclass to intercept and modify state updates.\n * This is useful for computing derived values, enforcing invariants, or\n * normalizing state before it becomes the new state.\n *\n * By default, this method returns the input unchanged.\n *\n * @param updatedState - The new state that will be committed\n * @returns The state to commit (can be modified or the same object)\n *\n * @example\n * ```typescript\n * type FormState = { firstName: string; lastName: string; fullName: string };\n *\n * class FormViewModel extends ViewModel<FormState> {\n * protected prepareState(updatedState: FormState): FormState {\n * return {\n * ...updatedState,\n * fullName: `${updatedState.firstName} ${updatedState.lastName}`,\n * };\n * }\n * }\n * ```\n */\n protected prepareState(updatedState: S): S {\n return updatedState;\n }\n\n /**\n * Get the current state.\n *\n * @returns The current state\n */\n get state(): S {\n return this._state;\n }\n}\n"],"names":["ViewModel","subscribe","listener","this","_listeners","add","delete","constructor","initialState","Set","_state","prepareState","update","forEach","l","updatedState","state"],"mappings":"MAkCsBA,EAsBpB,SAAAC,CAAUC,GAER,OADAC,KAAKC,EAAWC,IAAIH,GACb,KACLC,KAAKC,EAAWE,OAAOJ,GAE3B,CAOA,WAAAK,CAAYC,GAjCJL,KAAAC,EAAa,IAAIK,IAkCvBN,KAAKO,EAASP,KAAKQ,aAAaH,EAClC,CAiBU,MAAAI,CAAOA,GACfT,KAAKO,EAASP,KAAKQ,aAAa,IAAKR,KAAKO,KAAWE,IACrDT,KAAKC,EAAWS,QAASC,GAAMA,IACjC,CA4BU,YAAAH,CAAaI,GACrB,OAAOA,CACT,CAOA,SAAIC,GACF,OAAOb,KAAKO,CACd"}
|
package/package.json
CHANGED
package/src/ViewModel.ts
CHANGED
|
@@ -67,7 +67,7 @@ export abstract class ViewModel<S extends object> {
|
|
|
67
67
|
* @param initialState - The initial state of the view model
|
|
68
68
|
*/
|
|
69
69
|
constructor(initialState: S) {
|
|
70
|
-
this._state = initialState;
|
|
70
|
+
this._state = this.prepareState(initialState);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
@@ -76,7 +76,7 @@ export abstract class ViewModel<S extends object> {
|
|
|
76
76
|
* This method is protected and should only be called from within your view model subclass.
|
|
77
77
|
* The partial state is merged with the current state to create the new state.
|
|
78
78
|
*
|
|
79
|
-
* @param
|
|
79
|
+
* @param update - Partial state to merge with the current state
|
|
80
80
|
*
|
|
81
81
|
* @example
|
|
82
82
|
* ```typescript
|
|
@@ -85,11 +85,41 @@ export abstract class ViewModel<S extends object> {
|
|
|
85
85
|
* });
|
|
86
86
|
* ```
|
|
87
87
|
*/
|
|
88
|
-
protected update(
|
|
89
|
-
this._state = { ...this._state, ...
|
|
88
|
+
protected update(update: Partial<S>) {
|
|
89
|
+
this._state = this.prepareState({ ...this._state, ...update });
|
|
90
90
|
this._listeners.forEach((l) => l());
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Hook to transform state before it is committed and subscribers are notified.
|
|
95
|
+
*
|
|
96
|
+
* Override this method in your subclass to intercept and modify state updates.
|
|
97
|
+
* This is useful for computing derived values, enforcing invariants, or
|
|
98
|
+
* normalizing state before it becomes the new state.
|
|
99
|
+
*
|
|
100
|
+
* By default, this method returns the input unchanged.
|
|
101
|
+
*
|
|
102
|
+
* @param updatedState - The new state that will be committed
|
|
103
|
+
* @returns The state to commit (can be modified or the same object)
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* type FormState = { firstName: string; lastName: string; fullName: string };
|
|
108
|
+
*
|
|
109
|
+
* class FormViewModel extends ViewModel<FormState> {
|
|
110
|
+
* protected prepareState(updatedState: FormState): FormState {
|
|
111
|
+
* return {
|
|
112
|
+
* ...updatedState,
|
|
113
|
+
* fullName: `${updatedState.firstName} ${updatedState.lastName}`,
|
|
114
|
+
* };
|
|
115
|
+
* }
|
|
116
|
+
* }
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
protected prepareState(updatedState: S): S {
|
|
120
|
+
return updatedState;
|
|
121
|
+
}
|
|
122
|
+
|
|
93
123
|
/**
|
|
94
124
|
* Get the current state.
|
|
95
125
|
*
|
package/src/index.ts
CHANGED
package/src/derived.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
type Mapper<I, O> = (input: I) => O;
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A branded type for derived state mapper functions created by the `derived` utility.
|
|
5
|
-
* This type ensures that mapper functions are properly memoized before being used
|
|
6
|
-
* with hooks like `useDerivedState`.
|
|
7
|
-
*/
|
|
8
|
-
export type DerivedMapper<I, O> = Mapper<I, O> & { __brand: "derived" };
|
|
9
|
-
|
|
10
|
-
const UNINITIALIZED = {};
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Creates a memoized derived state mapper function that caches results based on input identity.
|
|
14
|
-
*
|
|
15
|
-
* The memoization uses `Object.is` to compare inputs, making it ideal for use with
|
|
16
|
-
* immutable state objects. When the input reference hasn't changed, the cached output
|
|
17
|
-
* is returned without re-executing the function.
|
|
18
|
-
*
|
|
19
|
-
* @template I - The input type
|
|
20
|
-
* @template O - The output type
|
|
21
|
-
* @param fn - A pure function that transforms input to output
|
|
22
|
-
* @returns A memoized derived mapper function
|
|
23
|
-
*
|
|
24
|
-
* @example
|
|
25
|
-
* ```ts
|
|
26
|
-
* const selectStats = derived(({ items }: AppState) => ({
|
|
27
|
-
* total: items.reduce((sum, item) => sum + item.price, 0),
|
|
28
|
-
* count: items.length
|
|
29
|
-
* }));
|
|
30
|
-
*
|
|
31
|
-
* // Use with useDerivedState
|
|
32
|
-
* const stats = selectStats(model.state);
|
|
33
|
-
* ```
|
|
34
|
-
*/
|
|
35
|
-
export const derived = <I, O>(fn: Mapper<I, O>): DerivedMapper<I, O> => {
|
|
36
|
-
let lastInput: I | typeof UNINITIALIZED = UNINITIALIZED;
|
|
37
|
-
let lastOutput: O | typeof UNINITIALIZED = UNINITIALIZED;
|
|
38
|
-
return ((input: I) => {
|
|
39
|
-
if (lastInput !== UNINITIALIZED && Object.is(lastInput, input)) {
|
|
40
|
-
return lastOutput as O;
|
|
41
|
-
}
|
|
42
|
-
return (lastOutput = fn((lastInput = input)));
|
|
43
|
-
}) as DerivedMapper<I, O>;
|
|
44
|
-
};
|