@view-models/core 4.0.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 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
@@ -188,6 +191,52 @@ class TodosViewModel extends ViewModel<TodosState> {
188
191
  }
189
192
  ```
190
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
+
191
240
  ## License
192
241
 
193
242
  MIT License
package/dist/index.d.ts CHANGED
@@ -65,7 +65,7 @@ declare abstract class ViewModel<S extends object> {
65
65
  * This method is protected and should only be called from within your view model subclass.
66
66
  * The partial state is merged with the current state to create the new state.
67
67
  *
68
- * @param partial - Partial state to merge with the current state
68
+ * @param update - Partial state to merge with the current state
69
69
  *
70
70
  * @example
71
71
  * ```typescript
@@ -74,7 +74,34 @@ declare abstract class ViewModel<S extends object> {
74
74
  * });
75
75
  * ```
76
76
  */
77
- protected update(partial: Partial<S>): void;
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;
78
105
  /**
79
106
  * Get the current state.
80
107
  *
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
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};
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/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 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":["ViewModel","subscribe","listener","this","_listeners","add","delete","constructor","initialState","Set","_state","update","partial","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,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 = 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@view-models/core",
3
- "version": "4.0.0",
3
+ "version": "5.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
@@ -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 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,11 +85,41 @@ 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.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
  *