@view-models/core 3.0.0 → 4.1.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 +51 -59
- package/dist/index.d.ts +60 -120
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ViewModel.ts +63 -9
- package/src/derived.ts +44 -0
- package/src/index.ts +1 -1
- package/src/ViewModelWithComputedState.ts +0 -162
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @view-models/core
|
|
2
2
|
|
|
3
3
|
[](https://github.com/sunesimonsen/view-models-core/actions/workflows/ci.yml)
|
|
4
|
-
[](https://unpkg.com/@view-models/core@latest/dist/index.js)
|
|
5
5
|
|
|
6
6
|
A lightweight, framework-agnostic library for building reactive view models with TypeScript. Separate your business logic from your UI framework with a simple, testable pattern.
|
|
7
7
|
|
|
@@ -82,87 +82,79 @@ describe("CounterViewModel", () => {
|
|
|
82
82
|
});
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
##
|
|
85
|
+
## Framework Integration
|
|
86
|
+
|
|
87
|
+
The view models are designed to work with framework-specific adapters. Upcoming adapters include:
|
|
88
|
+
|
|
89
|
+
- [@view-models/react](https://github.com/sunesimonsen/view-models-react) - React hooks integration
|
|
90
|
+
- [@view-models/preact](https://github.com/sunesimonsen/view-models-preact) - Preact hooks integration
|
|
86
91
|
|
|
87
|
-
|
|
92
|
+
These adapters will allow you to use the same view model with different frameworks:
|
|
88
93
|
|
|
89
94
|
```typescript
|
|
90
|
-
|
|
95
|
+
// Coming soon with @view-models/react
|
|
96
|
+
function Counter({ model }) {
|
|
97
|
+
const { count } = useModelState(model);
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
99
|
+
return (
|
|
100
|
+
<div>
|
|
101
|
+
<p>Count: {count}</p>
|
|
102
|
+
<button onClick={model.increment}>+</button>
|
|
103
|
+
<button onClick={model.decrement}>-</button>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Derived State
|
|
95
110
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 }>;
|
|
100
120
|
};
|
|
101
121
|
|
|
102
|
-
class
|
|
103
|
-
TodoState,
|
|
104
|
-
TodoDerivedState
|
|
105
|
-
> {
|
|
122
|
+
class CartViewModel extends ViewModel<CartState> {
|
|
106
123
|
constructor() {
|
|
107
124
|
super({ items: [] });
|
|
108
125
|
}
|
|
109
126
|
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
items,
|
|
113
|
-
totalCount: items.length,
|
|
114
|
-
completedCount: items.filter((item) => item.done).length,
|
|
115
|
-
remainingCount: items.filter((item) => !item.done).length,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
addTodo(text: string) {
|
|
120
|
-
super.update({
|
|
121
|
-
items: [
|
|
122
|
-
...super.state.items,
|
|
123
|
-
{ id: crypto.randomUUID(), text, done: false },
|
|
124
|
-
],
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
toggleTodo(id: string) {
|
|
127
|
+
addItem(item: { id: string; price: number; quantity: number }) {
|
|
129
128
|
super.update({
|
|
130
|
-
items: super.state.items
|
|
131
|
-
item.id === id ? { ...item, done: !item.done } : item,
|
|
132
|
-
),
|
|
129
|
+
items: [...super.state.items, item],
|
|
133
130
|
});
|
|
134
131
|
}
|
|
135
132
|
}
|
|
136
133
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 }
|
|
143
145
|
```
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
## Framework Integration
|
|
148
|
-
|
|
149
|
-
The view models are designed to work with framework-specific adapters. Upcoming adapters include:
|
|
150
|
-
|
|
151
|
-
- [@view-models/react](https://github.com/sunesimonsen/view-models-react) - React hooks integration
|
|
152
|
-
- [@view-models/preact](https://github.com/sunesimonsen/view-models-preact) - Preact hooks integration
|
|
153
|
-
|
|
154
|
-
These adapters will allow you to use the same view model with different frameworks:
|
|
147
|
+
Derived mappers are particularly useful with framework hooks like `useDerivedState` (from [@view-models/react](https://github.com/sunesimonsen/view-models-react)):
|
|
155
148
|
|
|
156
149
|
```typescript
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
150
|
+
function CartSummary({ cart }) {
|
|
151
|
+
// Only recomputes when cart.state reference changes
|
|
152
|
+
const stats = useDerivedState(cart, selectCartStats);
|
|
160
153
|
|
|
161
154
|
return (
|
|
162
155
|
<div>
|
|
163
|
-
<p>
|
|
164
|
-
<
|
|
165
|
-
<button onClick={model.decrement}>-</button>
|
|
156
|
+
<p>Items: {stats.itemCount}</p>
|
|
157
|
+
<p>Total: ${stats.total}</p>
|
|
166
158
|
</div>
|
|
167
159
|
);
|
|
168
160
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,63 +1,72 @@
|
|
|
1
|
+
type Mapper<I, O> = (input: I) => O;
|
|
1
2
|
/**
|
|
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
|
|
3
21
|
*
|
|
4
|
-
* @
|
|
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
|
+
/**
|
|
36
|
+
* Function that gets called when the state changes.
|
|
5
37
|
*/
|
|
6
38
|
type ViewModelListener = () => void;
|
|
7
39
|
/**
|
|
8
|
-
* Abstract base class for creating reactive view models
|
|
40
|
+
* Abstract base class for creating reactive view models.
|
|
9
41
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* while the derived state (which can include computed properties) is exposed
|
|
13
|
-
* to subscribers.
|
|
42
|
+
* A ViewModel manages state and notifies subscribers when the state changes.
|
|
43
|
+
* Extend this class to create your own view models with custom business logic.
|
|
14
44
|
*
|
|
15
|
-
* @template S - The
|
|
16
|
-
* @template D - The derived state type (exposed to subscribers)
|
|
45
|
+
* @template S - The state type
|
|
17
46
|
*
|
|
18
47
|
* @example
|
|
19
48
|
* ```typescript
|
|
20
|
-
* type
|
|
21
|
-
* items: Array<{ id: string; text: string; done: boolean }>;
|
|
22
|
-
* };
|
|
23
|
-
*
|
|
24
|
-
* type TodoDerivedState = TodoState & {
|
|
25
|
-
* totalCount: number;
|
|
26
|
-
* completedCount: number;
|
|
27
|
-
* remainingCount: number;
|
|
28
|
-
* };
|
|
49
|
+
* type CounterState = { count: number };
|
|
29
50
|
*
|
|
30
|
-
* class
|
|
51
|
+
* class CounterViewModel extends ViewModel<CounterState> {
|
|
31
52
|
* constructor() {
|
|
32
|
-
* super({
|
|
33
|
-
* }
|
|
34
|
-
*
|
|
35
|
-
* computedState({ items }: TodoState): TodoDerivedState {
|
|
36
|
-
* return {
|
|
37
|
-
* items,
|
|
38
|
-
* totalCount: items.length,
|
|
39
|
-
* completedCount: items.filter(item => item.done).length,
|
|
40
|
-
* remainingCount: items.filter(item => !item.done).length,
|
|
41
|
-
* };
|
|
53
|
+
* super({ count: 0 });
|
|
42
54
|
* }
|
|
43
55
|
*
|
|
44
|
-
*
|
|
45
|
-
* super.update({
|
|
46
|
-
* items: [...super.state.items, { id: crypto.randomUUID(), text, done: false }],
|
|
47
|
-
* });
|
|
56
|
+
* increment() {
|
|
57
|
+
* super.update({ count: super.state.count + 1 });
|
|
48
58
|
* }
|
|
49
59
|
* }
|
|
50
60
|
*
|
|
51
|
-
* const
|
|
52
|
-
*
|
|
53
|
-
* console.log('
|
|
61
|
+
* const counter = new CounterViewModel();
|
|
62
|
+
* const unsubscribe = counter.subscribe(() => {
|
|
63
|
+
* console.log('Count:', counter.state.count);
|
|
54
64
|
* });
|
|
55
|
-
*
|
|
65
|
+
* counter.increment(); // Logs: Count: 1
|
|
56
66
|
* ```
|
|
57
67
|
*/
|
|
58
|
-
declare abstract class
|
|
68
|
+
declare abstract class ViewModel<S extends object> {
|
|
59
69
|
private _listeners;
|
|
60
|
-
private _internalState;
|
|
61
70
|
private _state;
|
|
62
71
|
/**
|
|
63
72
|
* Subscribe to state changes.
|
|
@@ -69,8 +78,8 @@ declare abstract class ViewModelWithComputedState<S extends object, D> {
|
|
|
69
78
|
*
|
|
70
79
|
* @example
|
|
71
80
|
* ```typescript
|
|
72
|
-
* const unsubscribe = viewModel.subscribe((
|
|
73
|
-
* console.log('State changed:', state);
|
|
81
|
+
* const unsubscribe = viewModel.subscribe(() => {
|
|
82
|
+
* console.log('State changed:', viewModel.state);
|
|
74
83
|
* });
|
|
75
84
|
*
|
|
76
85
|
* // Later, when you want to stop listening:
|
|
@@ -79,22 +88,18 @@ declare abstract class ViewModelWithComputedState<S extends object, D> {
|
|
|
79
88
|
*/
|
|
80
89
|
subscribe(listener: ViewModelListener): () => void;
|
|
81
90
|
/**
|
|
82
|
-
* Create a new ViewModel with the given initial
|
|
83
|
-
*
|
|
84
|
-
* The constructor initializes the internal state and immediately computes
|
|
85
|
-
* the derived state by calling `computedState`.
|
|
91
|
+
* Create a new ViewModel with the given initial state.
|
|
86
92
|
*
|
|
87
|
-
* @param initialState - The initial
|
|
93
|
+
* @param initialState - The initial state of the view model
|
|
88
94
|
*/
|
|
89
95
|
constructor(initialState: S);
|
|
90
96
|
/**
|
|
91
|
-
* Update the
|
|
97
|
+
* Update the state and notify all subscribers.
|
|
92
98
|
*
|
|
93
99
|
* This method is protected and should only be called from within your view model subclass.
|
|
94
|
-
* The partial state is merged with the current
|
|
95
|
-
* After updating, the derived state is automatically recomputed via `computeDerivedState`.
|
|
100
|
+
* The partial state is merged with the current state to create the new state.
|
|
96
101
|
*
|
|
97
|
-
* @param partial - Partial state to merge with the current
|
|
102
|
+
* @param partial - Partial state to merge with the current state
|
|
98
103
|
*
|
|
99
104
|
* @example
|
|
100
105
|
* ```typescript
|
|
@@ -105,77 +110,12 @@ declare abstract class ViewModelWithComputedState<S extends object, D> {
|
|
|
105
110
|
*/
|
|
106
111
|
protected update(partial: Partial<S>): void;
|
|
107
112
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* This abstract method must be implemented by subclasses to transform
|
|
111
|
-
* the internal state into the derived state that will be exposed to
|
|
112
|
-
* subscribers. This method is called automatically after each state
|
|
113
|
-
* update and during initialization.
|
|
114
|
-
*
|
|
115
|
-
* @param state - The current internal state
|
|
116
|
-
* @returns The derived state with any computed properties
|
|
113
|
+
* Get the current state.
|
|
117
114
|
*
|
|
118
|
-
* @
|
|
119
|
-
* ```typescript
|
|
120
|
-
* computedState({ count }: CounterState): CounterDerivedState {
|
|
121
|
-
* return {
|
|
122
|
-
* count,
|
|
123
|
-
* isEven: count % 2 === 0,
|
|
124
|
-
* isPositive: count > 0,
|
|
125
|
-
* };
|
|
126
|
-
* }
|
|
127
|
-
* ```
|
|
115
|
+
* @returns The current state
|
|
128
116
|
*/
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Get the current derived state.
|
|
132
|
-
*
|
|
133
|
-
* This returns the derived state computed by `computedState`,
|
|
134
|
-
* not the internal state.
|
|
135
|
-
*
|
|
136
|
-
* @returns The current derived state
|
|
137
|
-
*/
|
|
138
|
-
get state(): D;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Abstract base class for creating reactive view models.
|
|
143
|
-
*
|
|
144
|
-
* A ViewModel manages state and notifies subscribers when the state changes.
|
|
145
|
-
* Extend this class to create your own view models with custom business logic.
|
|
146
|
-
*
|
|
147
|
-
* @template S - The state type
|
|
148
|
-
*
|
|
149
|
-
* @example
|
|
150
|
-
* ```typescript
|
|
151
|
-
* type CounterState = { count: number };
|
|
152
|
-
*
|
|
153
|
-
* class CounterViewModel extends ViewModel<CounterState> {
|
|
154
|
-
* constructor() {
|
|
155
|
-
* super({ count: 0 });
|
|
156
|
-
* }
|
|
157
|
-
*
|
|
158
|
-
* increment() {
|
|
159
|
-
* super.update({ count: super.state.count + 1 });
|
|
160
|
-
* }
|
|
161
|
-
* }
|
|
162
|
-
*
|
|
163
|
-
* const counter = new CounterViewModel();
|
|
164
|
-
* const unsubscribe = counter.subscribe((state) => {
|
|
165
|
-
* console.log('Count:', state.count);
|
|
166
|
-
* });
|
|
167
|
-
* counter.increment(); // Logs: Count: 1
|
|
168
|
-
* ```
|
|
169
|
-
*/
|
|
170
|
-
declare abstract class ViewModel<S extends object> extends ViewModelWithComputedState<S, S> {
|
|
171
|
-
/**
|
|
172
|
-
* Create a new ViewModel with the given initial state.
|
|
173
|
-
*
|
|
174
|
-
* @param initialState - The initial state of the view model
|
|
175
|
-
*/
|
|
176
|
-
constructor(initialState: S);
|
|
177
|
-
computedState(state: S): S;
|
|
117
|
+
get state(): S;
|
|
178
118
|
}
|
|
179
119
|
|
|
180
|
-
export { ViewModel,
|
|
181
|
-
export type { ViewModelListener };
|
|
120
|
+
export { ViewModel, derived };
|
|
121
|
+
export type { DerivedMapper, ViewModelListener };
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
const t={},s=s=>{let e=t,r=t;return i=>e!==t&&Object.is(e,i)?r:r=s(e=i)};class e{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{e as ViewModel,s as derived};
|
|
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/derived.ts","../src/ViewModel.ts"],"sourcesContent":["type Mapper<I, O> = (input: I) => O;\n\n/**\n * A branded type for derived state mapper functions created by the `derived` utility.\n * This type ensures that mapper functions are properly memoized before being used\n * with hooks like `useDerivedState`.\n */\nexport type DerivedMapper<I, O> = Mapper<I, O> & { __brand: \"derived\" };\n\nconst UNINITIALIZED = {};\n\n/**\n * Creates a memoized derived state mapper function that caches results based on input identity.\n *\n * The memoization uses `Object.is` to compare inputs, making it ideal for use with\n * immutable state objects. When the input reference hasn't changed, the cached output\n * is returned without re-executing the function.\n *\n * @template I - The input type\n * @template O - The output type\n * @param fn - A pure function that transforms input to output\n * @returns A memoized derived mapper function\n *\n * @example\n * ```ts\n * const selectStats = derived(({ items }: AppState) => ({\n * total: items.reduce((sum, item) => sum + item.price, 0),\n * count: items.length\n * }));\n *\n * // Use with useDerivedState\n * const stats = selectStats(model.state);\n * ```\n */\nexport const derived = <I, O>(fn: Mapper<I, O>): DerivedMapper<I, O> => {\n let lastInput: I | typeof UNINITIALIZED = UNINITIALIZED;\n let lastOutput: O | typeof UNINITIALIZED = UNINITIALIZED;\n return ((input: I) => {\n if (lastInput !== UNINITIALIZED && Object.is(lastInput, input)) {\n return lastOutput as O;\n }\n return (lastOutput = fn((lastInput = input)));\n }) as DerivedMapper<I, O>;\n};\n","/**\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 partial - 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(partial: Partial<S>) {\n this._state = { ...this._state, ...partial };\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":["UNINITIALIZED","derived","fn","lastInput","lastOutput","input","Object","is","ViewModel","subscribe","listener","this","_listeners","add","delete","constructor","initialState","Set","_state","update","partial","forEach","l","state"],"mappings":"AASA,MAAMA,EAAgB,CAAA,EAyBTC,EAAiBC,IAC5B,IAAIC,EAAsCH,EACtCI,EAAuCJ,EAC3C,OAASK,GACHF,IAAcH,GAAiBM,OAAOC,GAAGJ,EAAWE,GAC/CD,EAEDA,EAAaF,EAAIC,EAAYE,UCPnBG,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,CAAOC,GACfT,KAAKO,EAAS,IAAKP,KAAKO,KAAWE,GACnCT,KAAKC,EAAWS,QAASC,GAAMA,IACjC,CAOA,SAAIC,GACF,OAAOZ,KAAKO,CACd"}
|
package/package.json
CHANGED
package/src/ViewModel.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Function that gets called when the state changes.
|
|
3
|
+
*/
|
|
4
|
+
export type ViewModelListener = () => void;
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Abstract base class for creating reactive view models.
|
|
@@ -23,25 +26,76 @@ import { ViewModelWithComputedState } from "./ViewModelWithComputedState.js";
|
|
|
23
26
|
* }
|
|
24
27
|
*
|
|
25
28
|
* const counter = new CounterViewModel();
|
|
26
|
-
* const unsubscribe = counter.subscribe((
|
|
27
|
-
* console.log('Count:', state.count);
|
|
29
|
+
* const unsubscribe = counter.subscribe(() => {
|
|
30
|
+
* console.log('Count:', counter.state.count);
|
|
28
31
|
* });
|
|
29
32
|
* counter.increment(); // Logs: Count: 1
|
|
30
33
|
* ```
|
|
31
34
|
*/
|
|
32
|
-
export abstract class ViewModel<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
export abstract class ViewModel<S extends object> {
|
|
36
|
+
private _listeners = new Set<ViewModelListener>();
|
|
37
|
+
private _state: S;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Subscribe to state changes.
|
|
41
|
+
*
|
|
42
|
+
* The listener will be called immediately after any state update.
|
|
43
|
+
*
|
|
44
|
+
* @param listener - Function to call when state changes
|
|
45
|
+
* @returns Function to unsubscribe the listener
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const unsubscribe = viewModel.subscribe(() => {
|
|
50
|
+
* console.log('State changed:', viewModel.state);
|
|
51
|
+
* });
|
|
52
|
+
*
|
|
53
|
+
* // Later, when you want to stop listening:
|
|
54
|
+
* unsubscribe();
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
subscribe(listener: ViewModelListener): () => void {
|
|
58
|
+
this._listeners.add(listener);
|
|
59
|
+
return () => {
|
|
60
|
+
this._listeners.delete(listener);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
/**
|
|
36
65
|
* Create a new ViewModel with the given initial state.
|
|
37
66
|
*
|
|
38
67
|
* @param initialState - The initial state of the view model
|
|
39
68
|
*/
|
|
40
69
|
constructor(initialState: S) {
|
|
41
|
-
|
|
70
|
+
this._state = initialState;
|
|
42
71
|
}
|
|
43
72
|
|
|
44
|
-
|
|
45
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Update the state and notify all subscribers.
|
|
75
|
+
*
|
|
76
|
+
* This method is protected and should only be called from within your view model subclass.
|
|
77
|
+
* The partial state is merged with the current state to create the new state.
|
|
78
|
+
*
|
|
79
|
+
* @param partial - Partial state to merge with the current state
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* super.update({
|
|
84
|
+
* count: super.state.count + 1
|
|
85
|
+
* });
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
protected update(partial: Partial<S>) {
|
|
89
|
+
this._state = { ...this._state, ...partial };
|
|
90
|
+
this._listeners.forEach((l) => l());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the current state.
|
|
95
|
+
*
|
|
96
|
+
* @returns The current state
|
|
97
|
+
*/
|
|
98
|
+
get state(): S {
|
|
99
|
+
return this._state;
|
|
46
100
|
}
|
|
47
101
|
}
|
package/src/derived.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Function that gets called when the state changes.
|
|
3
|
-
*
|
|
4
|
-
* @template T - The state type
|
|
5
|
-
*/
|
|
6
|
-
export type ViewModelListener = () => void;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Abstract base class for creating reactive view models with derived state.
|
|
10
|
-
*
|
|
11
|
-
* This class extends the basic ViewModel pattern by allowing you to compute
|
|
12
|
-
* derived state from internal state. The internal state is managed privately,
|
|
13
|
-
* while the derived state (which can include computed properties) is exposed
|
|
14
|
-
* to subscribers.
|
|
15
|
-
*
|
|
16
|
-
* @template S - The internal state type (managed internally)
|
|
17
|
-
* @template D - The derived state type (exposed to subscribers)
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* ```typescript
|
|
21
|
-
* type TodoState = {
|
|
22
|
-
* items: Array<{ id: string; text: string; done: boolean }>;
|
|
23
|
-
* };
|
|
24
|
-
*
|
|
25
|
-
* type TodoDerivedState = TodoState & {
|
|
26
|
-
* totalCount: number;
|
|
27
|
-
* completedCount: number;
|
|
28
|
-
* remainingCount: number;
|
|
29
|
-
* };
|
|
30
|
-
*
|
|
31
|
-
* class TodoViewModel extends ViewModelWithComputedState<TodoState, TodoDerivedState> {
|
|
32
|
-
* constructor() {
|
|
33
|
-
* super({ items: [] });
|
|
34
|
-
* }
|
|
35
|
-
*
|
|
36
|
-
* computedState({ items }: TodoState): TodoDerivedState {
|
|
37
|
-
* return {
|
|
38
|
-
* items,
|
|
39
|
-
* totalCount: items.length,
|
|
40
|
-
* completedCount: items.filter(item => item.done).length,
|
|
41
|
-
* remainingCount: items.filter(item => !item.done).length,
|
|
42
|
-
* };
|
|
43
|
-
* }
|
|
44
|
-
*
|
|
45
|
-
* addTodo(text: string) {
|
|
46
|
-
* super.update({
|
|
47
|
-
* items: [...super.state.items, { id: crypto.randomUUID(), text, done: false }],
|
|
48
|
-
* });
|
|
49
|
-
* }
|
|
50
|
-
* }
|
|
51
|
-
*
|
|
52
|
-
* const todos = new TodoViewModel();
|
|
53
|
-
* todos.subscribe(() => {
|
|
54
|
-
* console.log('Completed:', todos.state.completedCount);
|
|
55
|
-
* });
|
|
56
|
-
* todos.addTodo('Learn ViewModels'); // Logs: Completed: 0
|
|
57
|
-
* ```
|
|
58
|
-
*/
|
|
59
|
-
export abstract class ViewModelWithComputedState<S extends object, D> {
|
|
60
|
-
private _listeners: Set<ViewModelListener> = new Set();
|
|
61
|
-
private _internalState: S;
|
|
62
|
-
private _state: D;
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Subscribe to state changes.
|
|
66
|
-
*
|
|
67
|
-
* The listener will be called immediately after any state update.
|
|
68
|
-
*
|
|
69
|
-
* @param listener - Function to call when state changes
|
|
70
|
-
* @returns Function to unsubscribe the listener
|
|
71
|
-
*
|
|
72
|
-
* @example
|
|
73
|
-
* ```typescript
|
|
74
|
-
* const unsubscribe = viewModel.subscribe((state) => {
|
|
75
|
-
* console.log('State changed:', state);
|
|
76
|
-
* });
|
|
77
|
-
*
|
|
78
|
-
* // Later, when you want to stop listening:
|
|
79
|
-
* unsubscribe();
|
|
80
|
-
* ```
|
|
81
|
-
*/
|
|
82
|
-
subscribe(listener: ViewModelListener): () => void {
|
|
83
|
-
this._listeners.add(listener);
|
|
84
|
-
return () => {
|
|
85
|
-
this._listeners.delete(listener);
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Create a new ViewModel with the given initial internal state.
|
|
91
|
-
*
|
|
92
|
-
* The constructor initializes the internal state and immediately computes
|
|
93
|
-
* the derived state by calling `computedState`.
|
|
94
|
-
*
|
|
95
|
-
* @param initialState - The initial internal state of the view model
|
|
96
|
-
*/
|
|
97
|
-
constructor(initialState: S) {
|
|
98
|
-
this._internalState = initialState;
|
|
99
|
-
this._state = this.computedState(this._internalState);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Update the internal state, recompute derived state, and notify all subscribers.
|
|
104
|
-
*
|
|
105
|
-
* This method is protected and should only be called from within your view model subclass.
|
|
106
|
-
* The partial state is merged with the current internal state to create the new internal state.
|
|
107
|
-
* After updating, the derived state is automatically recomputed via `computeDerivedState`.
|
|
108
|
-
*
|
|
109
|
-
* @param partial - Partial state to merge with the current internal state
|
|
110
|
-
*
|
|
111
|
-
* @example
|
|
112
|
-
* ```typescript
|
|
113
|
-
* super.update({
|
|
114
|
-
* count: super.state.count + 1
|
|
115
|
-
* });
|
|
116
|
-
* ```
|
|
117
|
-
*/
|
|
118
|
-
protected update(partial: Partial<S>) {
|
|
119
|
-
this._internalState = { ...this._internalState, ...partial };
|
|
120
|
-
this._state = this.computedState(this._internalState);
|
|
121
|
-
|
|
122
|
-
for (const listener of this._listeners) {
|
|
123
|
-
listener();
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Compute the derived state from the internal state.
|
|
129
|
-
*
|
|
130
|
-
* This abstract method must be implemented by subclasses to transform
|
|
131
|
-
* the internal state into the derived state that will be exposed to
|
|
132
|
-
* subscribers. This method is called automatically after each state
|
|
133
|
-
* update and during initialization.
|
|
134
|
-
*
|
|
135
|
-
* @param state - The current internal state
|
|
136
|
-
* @returns The derived state with any computed properties
|
|
137
|
-
*
|
|
138
|
-
* @example
|
|
139
|
-
* ```typescript
|
|
140
|
-
* computedState({ count }: CounterState): CounterDerivedState {
|
|
141
|
-
* return {
|
|
142
|
-
* count,
|
|
143
|
-
* isEven: count % 2 === 0,
|
|
144
|
-
* isPositive: count > 0,
|
|
145
|
-
* };
|
|
146
|
-
* }
|
|
147
|
-
* ```
|
|
148
|
-
*/
|
|
149
|
-
abstract computedState(state: S): D;
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Get the current derived state.
|
|
153
|
-
*
|
|
154
|
-
* This returns the derived state computed by `computedState`,
|
|
155
|
-
* not the internal state.
|
|
156
|
-
*
|
|
157
|
-
* @returns The current derived state
|
|
158
|
-
*/
|
|
159
|
-
get state(): D {
|
|
160
|
-
return this._state;
|
|
161
|
-
}
|
|
162
|
-
}
|