@view-models/core 4.1.0 → 6.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 +61 -60
- package/dist/index.d.ts +4 -38
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ViewModel.ts +3 -3
- 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,58 @@ class TodosViewModel extends ViewModel<TodosState> {
|
|
|
242
191
|
}
|
|
243
192
|
```
|
|
244
193
|
|
|
194
|
+
### Derived State
|
|
195
|
+
|
|
196
|
+
When you have state values that are derived from other state, create a dedicated
|
|
197
|
+
update method that computes these values. This approach is more explicit and
|
|
198
|
+
efficient than intercepting every state update:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
type TodoState = {
|
|
202
|
+
todos: ReadonlyArray<{ id: number; text: string; completed: boolean }>;
|
|
203
|
+
completedCount: number;
|
|
204
|
+
pendingCount: number;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
class TodoViewModel extends ViewModel<TodoState> {
|
|
208
|
+
private nextId = 1;
|
|
209
|
+
|
|
210
|
+
constructor() {
|
|
211
|
+
super({ todos: [], completedCount: 0, pendingCount: 0 });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
add(text: string) {
|
|
215
|
+
const todos = [
|
|
216
|
+
...super.state.todos,
|
|
217
|
+
{ id: this.nextId++, text, completed: false },
|
|
218
|
+
];
|
|
219
|
+
this.updateTodos(todos);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
toggle(id: number) {
|
|
223
|
+
const todos = super.state.todos.map((todo) =>
|
|
224
|
+
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
|
|
225
|
+
);
|
|
226
|
+
this.updateTodos(todos);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private updateTodos(todos: TodoState["todos"]) {
|
|
230
|
+
super.update({
|
|
231
|
+
todos,
|
|
232
|
+
completedCount: todos.filter((t) => t.completed).length,
|
|
233
|
+
pendingCount: todos.filter((t) => !t.completed).length,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const todoModel = new TodoViewModel();
|
|
239
|
+
todoModel.add("Buy milk");
|
|
240
|
+
todoModel.add("Walk the dog");
|
|
241
|
+
todoModel.toggle(1);
|
|
242
|
+
console.log(todoModel.state.completedCount); // 1
|
|
243
|
+
console.log(todoModel.state.pendingCount); // 1
|
|
244
|
+
```
|
|
245
|
+
|
|
245
246
|
## License
|
|
246
247
|
|
|
247
248
|
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,7 @@ declare abstract class ViewModel<S extends object> {
|
|
|
108
74
|
* });
|
|
109
75
|
* ```
|
|
110
76
|
*/
|
|
111
|
-
protected update(
|
|
77
|
+
protected update(update: Partial<S>): void;
|
|
112
78
|
/**
|
|
113
79
|
* Get the current state.
|
|
114
80
|
*
|
|
@@ -117,5 +83,5 @@ declare abstract class ViewModel<S extends object> {
|
|
|
117
83
|
get state(): S;
|
|
118
84
|
}
|
|
119
85
|
|
|
120
|
-
export { ViewModel
|
|
121
|
-
export type {
|
|
86
|
+
export { ViewModel };
|
|
87
|
+
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=t}update(t){this.i={...this.i,...t},this.t.forEach(t=>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 = 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._state, ...update };\n this._listeners.forEach((l) => l());\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","update","forEach","l","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,EAASF,CAChB,CAiBU,MAAAG,CAAOA,GACfR,KAAKO,EAAS,IAAKP,KAAKO,KAAWC,GACnCR,KAAKC,EAAWQ,QAASC,GAAMA,IACjC,CAOA,SAAIC,GACF,OAAOX,KAAKO,CACd"}
|
package/package.json
CHANGED
package/src/ViewModel.ts
CHANGED
|
@@ -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,8 +85,8 @@ 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._state, ...update };
|
|
90
90
|
this._listeners.forEach((l) => l());
|
|
91
91
|
}
|
|
92
92
|
|
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
|
-
};
|