@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 CHANGED
@@ -3,7 +3,9 @@
3
3
  [![CI](https://github.com/sunesimonsen/view-models-core/actions/workflows/ci.yml/badge.svg)](https://github.com/sunesimonsen/view-models-core/actions/workflows/ci.yml)
4
4
  [![Bundle Size](https://img.badgesize.io/https://unpkg.com/@view-models/core@latest/dist/index.js?label=gzip&compression=gzip)](https://unpkg.com/@view-models/core@latest/dist/index.js)
5
5
 
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.
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
  ![View models banner](./view-models.png)
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 adapters include:
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 partial - Partial state to merge with the current state
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(partial: Partial<S>): void;
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, derived };
121
- export type { DerivedMapper, ViewModelListener };
86
+ export { ViewModel };
87
+ export type { ViewModelListener };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
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};
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/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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@view-models/core",
3
- "version": "4.1.0",
3
+ "version": "6.0.0",
4
4
  "description": "A lightweight, framework-agnostic library for building reactive view models with TypeScript",
5
5
  "keywords": [
6
6
  "view",
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 partial - Partial state to merge with the current state
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(partial: Partial<S>) {
89
- this._state = { ...this._state, ...partial };
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
@@ -1,2 +1 @@
1
- export * from "./derived.js";
2
1
  export * from "./ViewModel.js";
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
- };