@view-models/core 2.2.0 → 4.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
@@ -1,5 +1,8 @@
1
1
  # @view-models/core
2
2
 
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
+ [![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
+
3
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.
4
7
 
5
8
  ![View models banner](./view-models.png)
@@ -31,16 +34,20 @@ type CounterState = {
31
34
  };
32
35
 
33
36
  class CounterViewModel extends ViewModel<CounterState> {
37
+ constructor() {
38
+ super({ count: 0 });
39
+ }
40
+
34
41
  increment() {
35
- this.update(({ count }) => ({
36
- count: count + 1,
37
- }));
42
+ super.update({
43
+ count: super.state.count + 1,
44
+ });
38
45
  }
39
46
 
40
47
  decrement() {
41
- this.update(({ count }) => ({
42
- count: count - 1,
43
- }));
48
+ super.update({
49
+ count: super.state.count - 1,
50
+ });
44
51
  }
45
52
  }
46
53
  ```
@@ -75,65 +82,6 @@ describe("CounterViewModel", () => {
75
82
  });
76
83
  ```
77
84
 
78
- ## View Models with Derived State
79
-
80
- When you need to compute derived values from your state (like counts, filtered lists, or formatted data), use `ViewModelWithDerivedState`:
81
-
82
- ```typescript
83
- import { ViewModelWithDerivedState } from "@view-models/core";
84
-
85
- type TodoState = {
86
- items: Array<{ id: string; text: string; done: boolean }>;
87
- };
88
-
89
- type TodoDerivedState = TodoState & {
90
- totalCount: number;
91
- completedCount: number;
92
- remainingCount: number;
93
- };
94
-
95
- class TodoViewModel extends ViewModelWithDerivedState<
96
- TodoState,
97
- TodoDerivedState
98
- > {
99
- constructor() {
100
- super({ items: [] });
101
- }
102
-
103
- computeDerivedState({ items }: TodoState): TodoDerivedState {
104
- return {
105
- items,
106
- totalCount: items.length,
107
- completedCount: items.filter((item) => item.done).length,
108
- remainingCount: items.filter((item) => !item.done).length,
109
- };
110
- }
111
-
112
- addTodo(text: string) {
113
- this.update(({ items }) => ({
114
- items: [...items, { id: crypto.randomUUID(), text, done: false }],
115
- }));
116
- }
117
-
118
- toggleTodo(id: string) {
119
- this.update(({ items }) => ({
120
- items: items.map((item) =>
121
- item.id === id ? { ...item, done: !item.done } : item,
122
- ),
123
- }));
124
- }
125
- }
126
-
127
- const todos = new TodoViewModel();
128
- console.log(todos.state.totalCount); // 0
129
-
130
- todos.addTodo("Learn ViewModels");
131
- console.log(todos.state.totalCount); // 1
132
- console.log(todos.state.remainingCount); // 1
133
- ```
134
-
135
- The derived state is automatically recomputed whenever the internal state changes, ensuring your computed properties are always up-to-date.
136
-
137
85
  ## Framework Integration
138
86
 
139
87
  The view models are designed to work with framework-specific adapters. Upcoming adapters include:
@@ -170,16 +118,14 @@ Always return new state objects from your updater functions:
170
118
 
171
119
  ```typescript
172
120
  // Good
173
- this.update(({ count }) => ({
174
- ...state,
175
- count: count + 1,
176
- }));
121
+ super.update({
122
+ count: super.state.count + 1,
123
+ });
177
124
 
178
125
  // Bad - mutates existing state
179
- this.update((state) => {
180
- state.count++;
181
- return state;
182
- });
126
+ const state = super.state;
127
+ state.count++;
128
+ super.update(state);
183
129
  ```
184
130
 
185
131
  ### Use Readonly Types
@@ -212,29 +158,29 @@ actions:
212
158
 
213
159
  ```typescript
214
160
  class TodosViewModel extends ViewModel<TodosState> {
215
- private api: API
161
+ private api: API;
216
162
 
217
163
  constructor(state: TodosState, api: API) {
218
- super(state)
219
- this.api = api
164
+ super(state);
165
+ this.api = api;
220
166
  }
221
167
 
222
168
  async loadTodos() {
223
- this.update((state) => ({ ...state, loading: true, failed: false }));
169
+ super.update({ loading: true, failed: false });
224
170
  try {
225
171
  const todos = await this.api.fetchTodos();
226
- this.update(() => ({ todos, loading: false }));
172
+ super.update({ todos, loading: false });
227
173
  } catch {
228
- this.update((state) => ({ ...state, loading: false: failed: true }));
174
+ super.update({ loading: false, failed: true });
229
175
  }
230
176
  }
231
177
 
232
178
  async addTodo(text: string) {
233
179
  try {
234
180
  const todo = await this.api.createTodo(text);
235
- this.update(({ todos }) => ({
236
- todos: [...todos, todo],
237
- }));
181
+ super.update({
182
+ todos: [...super.state.todos, todo],
183
+ });
238
184
  } catch {
239
185
  // TODO show error
240
186
  }
package/dist/index.d.ts CHANGED
@@ -1,72 +1,38 @@
1
- /**
2
- * Function that receives the current state and returns the new state.
3
- * The updater function should be pure and return a new state object.
4
- *
5
- * @template T - The state type
6
- * @param currentState - The current state
7
- * @returns The new state
8
- */
9
- type Updater<T> = (currentState: T) => T;
10
1
  /**
11
2
  * Function that gets called when the state changes.
12
- *
13
- * @template T - The state type
14
3
  */
15
4
  type ViewModelListener = () => void;
16
5
  /**
17
- * Abstract base class for creating reactive view models with derived state.
6
+ * Abstract base class for creating reactive view models.
18
7
  *
19
- * This class extends the basic ViewModel pattern by allowing you to compute
20
- * derived state from internal state. The internal state is managed privately,
21
- * while the derived state (which can include computed properties) is exposed
22
- * to subscribers.
8
+ * A ViewModel manages state and notifies subscribers when the state changes.
9
+ * Extend this class to create your own view models with custom business logic.
23
10
  *
24
- * @template S - The internal state type (managed internally)
25
- * @template D - The derived state type (exposed to subscribers)
11
+ * @template S - The state type
26
12
  *
27
13
  * @example
28
14
  * ```typescript
29
- * type TodoState = {
30
- * items: Array<{ id: string; text: string; done: boolean }>;
31
- * };
32
- *
33
- * type TodoDerivedState = TodoState & {
34
- * totalCount: number;
35
- * completedCount: number;
36
- * remainingCount: number;
37
- * };
15
+ * type CounterState = { count: number };
38
16
  *
39
- * class TodoViewModel extends ViewModelWithDerivedState<TodoState, TodoDerivedState> {
17
+ * class CounterViewModel extends ViewModel<CounterState> {
40
18
  * constructor() {
41
- * super({ items: [] });
42
- * }
43
- *
44
- * computeDerivedState({ items }: TodoState): TodoDerivedState {
45
- * return {
46
- * items,
47
- * totalCount: items.length,
48
- * completedCount: items.filter(item => item.done).length,
49
- * remainingCount: items.filter(item => !item.done).length,
50
- * };
19
+ * super({ count: 0 });
51
20
  * }
52
21
  *
53
- * addTodo(text: string) {
54
- * this.update(({ items }) => ({
55
- * items: [...items, { id: crypto.randomUUID(), text, done: false }],
56
- * }));
22
+ * increment() {
23
+ * super.update({ count: super.state.count + 1 });
57
24
  * }
58
25
  * }
59
26
  *
60
- * const todos = new TodoViewModel();
61
- * todos.subscribe(() => {
62
- * console.log('Completed:', todos.state.completedCount);
27
+ * const counter = new CounterViewModel();
28
+ * const unsubscribe = counter.subscribe(() => {
29
+ * console.log('Count:', counter.state.count);
63
30
  * });
64
- * todos.addTodo('Learn ViewModels'); // Logs: Completed: 0
31
+ * counter.increment(); // Logs: Count: 1
65
32
  * ```
66
33
  */
67
- declare abstract class ViewModelWithDerivedState<S, D> {
34
+ declare abstract class ViewModel<S extends object> {
68
35
  private _listeners;
69
- private _internalState;
70
36
  private _state;
71
37
  /**
72
38
  * Subscribe to state changes.
@@ -78,8 +44,8 @@ declare abstract class ViewModelWithDerivedState<S, D> {
78
44
  *
79
45
  * @example
80
46
  * ```typescript
81
- * const unsubscribe = viewModel.subscribe((state) => {
82
- * console.log('State changed:', state);
47
+ * const unsubscribe = viewModel.subscribe(() => {
48
+ * console.log('State changed:', viewModel.state);
83
49
  * });
84
50
  *
85
51
  * // Later, when you want to stop listening:
@@ -88,105 +54,34 @@ declare abstract class ViewModelWithDerivedState<S, D> {
88
54
  */
89
55
  subscribe(listener: ViewModelListener): () => void;
90
56
  /**
91
- * Create a new ViewModel with the given initial internal state.
92
- *
93
- * The constructor initializes the internal state and immediately computes
94
- * the derived state by calling `computeDerivedState`.
57
+ * Create a new ViewModel with the given initial state.
95
58
  *
96
- * @param initialState - The initial internal state of the view model
59
+ * @param initialState - The initial state of the view model
97
60
  */
98
61
  constructor(initialState: S);
99
62
  /**
100
- * Update the internal state, recompute derived state, and notify all subscribers.
63
+ * Update the state and notify all subscribers.
101
64
  *
102
65
  * This method is protected and should only be called from within your view model subclass.
103
- * The updater function receives the current internal state and should return the new internal state.
104
- * After updating, the derived state is automatically recomputed via `computeDerivedState`.
105
- * Always return a new state object to ensure immutability.
106
- *
107
- * @param updater - Function that receives current internal state and returns new internal state
108
- *
109
- * @example
110
- * ```typescript
111
- * this.update((currentState) => ({
112
- * ...currentState,
113
- * count: currentState.count + 1
114
- * }));
115
- * ```
116
- */
117
- protected update(updater: Updater<S>): void;
118
- /**
119
- * Compute the derived state from the internal state.
120
- *
121
- * This abstract method must be implemented by subclasses to transform
122
- * the internal state into the derived state that will be exposed to
123
- * subscribers. This method is called automatically after each state
124
- * update and during initialization.
66
+ * The partial state is merged with the current state to create the new state.
125
67
  *
126
- * @param state - The current internal state
127
- * @returns The derived state with any computed properties
68
+ * @param partial - Partial state to merge with the current state
128
69
  *
129
70
  * @example
130
71
  * ```typescript
131
- * computeDerivedState({ count }: CounterState): CounterDerivedState {
132
- * return {
133
- * count,
134
- * isEven: count % 2 === 0,
135
- * isPositive: count > 0,
136
- * };
137
- * }
72
+ * super.update({
73
+ * count: super.state.count + 1
74
+ * });
138
75
  * ```
139
76
  */
140
- abstract computeDerivedState(state: S): D;
141
- /**
142
- * Get the current derived state.
143
- *
144
- * This returns the derived state computed by `computeDerivedState`,
145
- * not the internal state.
146
- *
147
- * @returns The current derived state
148
- */
149
- get state(): D;
150
- }
151
-
152
- /**
153
- * Abstract base class for creating reactive view models.
154
- *
155
- * A ViewModel manages state and notifies subscribers when the state changes.
156
- * Extend this class to create your own view models with custom business logic.
157
- *
158
- * @template S - The state type
159
- *
160
- * @example
161
- * ```typescript
162
- * type CounterState = { count: number };
163
- *
164
- * class CounterViewModel extends ViewModel<CounterState> {
165
- * constructor() {
166
- * super({ count: 0 });
167
- * }
168
- *
169
- * increment() {
170
- * this.update(({ count }) => ({ count: count + 1 }));
171
- * }
172
- * }
173
- *
174
- * const counter = new CounterViewModel();
175
- * const unsubscribe = counter.subscribe((state) => {
176
- * console.log('Count:', state.count);
177
- * });
178
- * counter.increment(); // Logs: Count: 1
179
- * ```
180
- */
181
- declare abstract class ViewModel<S> extends ViewModelWithDerivedState<S, S> {
77
+ protected update(partial: Partial<S>): void;
182
78
  /**
183
- * Create a new ViewModel with the given initial state.
79
+ * Get the current state.
184
80
  *
185
- * @param initialState - The initial state of the view model
81
+ * @returns The current state
186
82
  */
187
- constructor(initialState: S);
188
- computeDerivedState(state: S): S;
83
+ get state(): S;
189
84
  }
190
85
 
191
- export { ViewModel, ViewModelWithDerivedState };
192
- export type { Updater, ViewModelListener };
86
+ export { ViewModel };
87
+ export type { ViewModelListener };
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,this.h=this.computeDerivedState(this.i)}update(t){this.i=t(this.i),this.h=this.computeDerivedState(this.i);for(const t of this.t)t()}get state(){return this.h}}class s extends t{constructor(t){super(t)}computeDerivedState(t){return t}}export{s as ViewModel,t as ViewModelWithDerivedState};
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/ViewModelWithDerivedState.ts","../src/ViewModel.ts"],"sourcesContent":["/**\n * Function that receives the current state and returns the new state.\n * The updater function should be pure and return a new state object.\n *\n * @template T - The state type\n * @param currentState - The current state\n * @returns The new state\n */\nexport type Updater<T> = (currentState: T) => T;\n\n/**\n * Function that gets called when the state changes.\n *\n * @template T - The state type\n */\nexport type ViewModelListener = () => void;\n\n/**\n * Abstract base class for creating reactive view models with derived state.\n *\n * This class extends the basic ViewModel pattern by allowing you to compute\n * derived state from internal state. The internal state is managed privately,\n * while the derived state (which can include computed properties) is exposed\n * to subscribers.\n *\n * @template S - The internal state type (managed internally)\n * @template D - The derived state type (exposed to subscribers)\n *\n * @example\n * ```typescript\n * type TodoState = {\n * items: Array<{ id: string; text: string; done: boolean }>;\n * };\n *\n * type TodoDerivedState = TodoState & {\n * totalCount: number;\n * completedCount: number;\n * remainingCount: number;\n * };\n *\n * class TodoViewModel extends ViewModelWithDerivedState<TodoState, TodoDerivedState> {\n * constructor() {\n * super({ items: [] });\n * }\n *\n * computeDerivedState({ items }: TodoState): TodoDerivedState {\n * return {\n * items,\n * totalCount: items.length,\n * completedCount: items.filter(item => item.done).length,\n * remainingCount: items.filter(item => !item.done).length,\n * };\n * }\n *\n * addTodo(text: string) {\n * this.update(({ items }) => ({\n * items: [...items, { id: crypto.randomUUID(), text, done: false }],\n * }));\n * }\n * }\n *\n * const todos = new TodoViewModel();\n * todos.subscribe(() => {\n * console.log('Completed:', todos.state.completedCount);\n * });\n * todos.addTodo('Learn ViewModels'); // Logs: Completed: 0\n * ```\n */\nexport abstract class ViewModelWithDerivedState<S, D> {\n private _listeners: Set<ViewModelListener> = new Set();\n private _internalState: S;\n private _state: D;\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((state) => {\n * console.log('State changed:', 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 internal state.\n *\n * The constructor initializes the internal state and immediately computes\n * the derived state by calling `computeDerivedState`.\n *\n * @param initialState - The initial internal state of the view model\n */\n constructor(initialState: S) {\n this._internalState = initialState;\n this._state = this.computeDerivedState(this._internalState);\n }\n\n /**\n * Update the internal state, recompute derived state, and notify all subscribers.\n *\n * This method is protected and should only be called from within your view model subclass.\n * The updater function receives the current internal state and should return the new internal state.\n * After updating, the derived state is automatically recomputed via `computeDerivedState`.\n * Always return a new state object to ensure immutability.\n *\n * @param updater - Function that receives current internal state and returns new internal state\n *\n * @example\n * ```typescript\n * this.update((currentState) => ({\n * ...currentState,\n * count: currentState.count + 1\n * }));\n * ```\n */\n protected update(updater: Updater<S>) {\n this._internalState = updater(this._internalState);\n this._state = this.computeDerivedState(this._internalState);\n\n for (const listener of this._listeners) {\n listener();\n }\n }\n\n /**\n * Compute the derived state from the internal state.\n *\n * This abstract method must be implemented by subclasses to transform\n * the internal state into the derived state that will be exposed to\n * subscribers. This method is called automatically after each state\n * update and during initialization.\n *\n * @param state - The current internal state\n * @returns The derived state with any computed properties\n *\n * @example\n * ```typescript\n * computeDerivedState({ count }: CounterState): CounterDerivedState {\n * return {\n * count,\n * isEven: count % 2 === 0,\n * isPositive: count > 0,\n * };\n * }\n * ```\n */\n abstract computeDerivedState(state: S): D;\n\n /**\n * Get the current derived state.\n *\n * This returns the derived state computed by `computeDerivedState`,\n * not the internal state.\n *\n * @returns The current derived state\n */\n get state(): D {\n return this._state;\n }\n}\n","import { ViewModelWithDerivedState } from \"./ViewModelWithDerivedState.js\";\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 * this.update(({ count }) => ({ count: count + 1 }));\n * }\n * }\n *\n * const counter = new CounterViewModel();\n * const unsubscribe = counter.subscribe((state) => {\n * console.log('Count:', state.count);\n * });\n * counter.increment(); // Logs: Count: 1\n * ```\n */\nexport abstract class ViewModel<S> extends ViewModelWithDerivedState<S, S> {\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 super(initialState);\n }\n\n computeDerivedState(state: S): S {\n return state;\n }\n}\n"],"names":["ViewModelWithDerivedState","subscribe","listener","this","_listeners","add","delete","constructor","initialState","Set","_internalState","_state","computeDerivedState","update","updater","state","ViewModel","super"],"mappings":"MAoEsBA,EAuBpB,SAAAC,CAAUC,GAER,OADAC,KAAKC,EAAWC,IAAIH,GACb,KACLC,KAAKC,EAAWE,OAAOJ,GAE3B,CAUA,WAAAK,CAAYC,GArCJL,KAAAC,EAAqC,IAAIK,IAsC/CN,KAAKO,EAAiBF,EACtBL,KAAKQ,EAASR,KAAKS,oBAAoBT,KAAKO,EAC9C,CAoBU,MAAAG,CAAOC,GACfX,KAAKO,EAAiBI,EAAQX,KAAKO,GACnCP,KAAKQ,EAASR,KAAKS,oBAAoBT,KAAKO,GAE5C,IAAK,MAAMR,KAAYC,KAAKC,EAC1BF,GAEJ,CAkCA,SAAIa,GACF,OAAOZ,KAAKQ,CACd,EC7II,MAAgBK,UAAqBhB,EAMzC,WAAAO,CAAYC,GACVS,MAAMT,EACR,CAEA,mBAAAI,CAAoBG,GAClB,OAAOA,CACT"}
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@view-models/core",
3
- "version": "2.2.0",
3
+ "version": "4.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
@@ -1,4 +1,7 @@
1
- import { ViewModelWithDerivedState } from "./ViewModelWithDerivedState.js";
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.
@@ -18,28 +21,81 @@ import { ViewModelWithDerivedState } from "./ViewModelWithDerivedState.js";
18
21
  * }
19
22
  *
20
23
  * increment() {
21
- * this.update(({ count }) => ({ count: count + 1 }));
24
+ * super.update({ count: super.state.count + 1 });
22
25
  * }
23
26
  * }
24
27
  *
25
28
  * const counter = new CounterViewModel();
26
- * const unsubscribe = counter.subscribe((state) => {
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<S> extends ViewModelWithDerivedState<S, S> {
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
+
33
64
  /**
34
65
  * Create a new ViewModel with the given initial state.
35
66
  *
36
67
  * @param initialState - The initial state of the view model
37
68
  */
38
69
  constructor(initialState: S) {
39
- super(initialState);
70
+ this._state = initialState;
40
71
  }
41
72
 
42
- computeDerivedState(state: S): S {
43
- return state;
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;
44
100
  }
45
101
  }
package/src/index.ts CHANGED
@@ -1,2 +1 @@
1
1
  export * from "./ViewModel.js";
2
- export * from "./ViewModelWithDerivedState.js";
@@ -1,174 +0,0 @@
1
- /**
2
- * Function that receives the current state and returns the new state.
3
- * The updater function should be pure and return a new state object.
4
- *
5
- * @template T - The state type
6
- * @param currentState - The current state
7
- * @returns The new state
8
- */
9
- export type Updater<T> = (currentState: T) => T;
10
-
11
- /**
12
- * Function that gets called when the state changes.
13
- *
14
- * @template T - The state type
15
- */
16
- export type ViewModelListener = () => void;
17
-
18
- /**
19
- * Abstract base class for creating reactive view models with derived state.
20
- *
21
- * This class extends the basic ViewModel pattern by allowing you to compute
22
- * derived state from internal state. The internal state is managed privately,
23
- * while the derived state (which can include computed properties) is exposed
24
- * to subscribers.
25
- *
26
- * @template S - The internal state type (managed internally)
27
- * @template D - The derived state type (exposed to subscribers)
28
- *
29
- * @example
30
- * ```typescript
31
- * type TodoState = {
32
- * items: Array<{ id: string; text: string; done: boolean }>;
33
- * };
34
- *
35
- * type TodoDerivedState = TodoState & {
36
- * totalCount: number;
37
- * completedCount: number;
38
- * remainingCount: number;
39
- * };
40
- *
41
- * class TodoViewModel extends ViewModelWithDerivedState<TodoState, TodoDerivedState> {
42
- * constructor() {
43
- * super({ items: [] });
44
- * }
45
- *
46
- * computeDerivedState({ items }: TodoState): TodoDerivedState {
47
- * return {
48
- * items,
49
- * totalCount: items.length,
50
- * completedCount: items.filter(item => item.done).length,
51
- * remainingCount: items.filter(item => !item.done).length,
52
- * };
53
- * }
54
- *
55
- * addTodo(text: string) {
56
- * this.update(({ items }) => ({
57
- * items: [...items, { id: crypto.randomUUID(), text, done: false }],
58
- * }));
59
- * }
60
- * }
61
- *
62
- * const todos = new TodoViewModel();
63
- * todos.subscribe(() => {
64
- * console.log('Completed:', todos.state.completedCount);
65
- * });
66
- * todos.addTodo('Learn ViewModels'); // Logs: Completed: 0
67
- * ```
68
- */
69
- export abstract class ViewModelWithDerivedState<S, D> {
70
- private _listeners: Set<ViewModelListener> = new Set();
71
- private _internalState: S;
72
- private _state: D;
73
-
74
- /**
75
- * Subscribe to state changes.
76
- *
77
- * The listener will be called immediately after any state update.
78
- *
79
- * @param listener - Function to call when state changes
80
- * @returns Function to unsubscribe the listener
81
- *
82
- * @example
83
- * ```typescript
84
- * const unsubscribe = viewModel.subscribe((state) => {
85
- * console.log('State changed:', state);
86
- * });
87
- *
88
- * // Later, when you want to stop listening:
89
- * unsubscribe();
90
- * ```
91
- */
92
- subscribe(listener: ViewModelListener): () => void {
93
- this._listeners.add(listener);
94
- return () => {
95
- this._listeners.delete(listener);
96
- };
97
- }
98
-
99
- /**
100
- * Create a new ViewModel with the given initial internal state.
101
- *
102
- * The constructor initializes the internal state and immediately computes
103
- * the derived state by calling `computeDerivedState`.
104
- *
105
- * @param initialState - The initial internal state of the view model
106
- */
107
- constructor(initialState: S) {
108
- this._internalState = initialState;
109
- this._state = this.computeDerivedState(this._internalState);
110
- }
111
-
112
- /**
113
- * Update the internal state, recompute derived state, and notify all subscribers.
114
- *
115
- * This method is protected and should only be called from within your view model subclass.
116
- * The updater function receives the current internal state and should return the new internal state.
117
- * After updating, the derived state is automatically recomputed via `computeDerivedState`.
118
- * Always return a new state object to ensure immutability.
119
- *
120
- * @param updater - Function that receives current internal state and returns new internal state
121
- *
122
- * @example
123
- * ```typescript
124
- * this.update((currentState) => ({
125
- * ...currentState,
126
- * count: currentState.count + 1
127
- * }));
128
- * ```
129
- */
130
- protected update(updater: Updater<S>) {
131
- this._internalState = updater(this._internalState);
132
- this._state = this.computeDerivedState(this._internalState);
133
-
134
- for (const listener of this._listeners) {
135
- listener();
136
- }
137
- }
138
-
139
- /**
140
- * Compute the derived state from the internal state.
141
- *
142
- * This abstract method must be implemented by subclasses to transform
143
- * the internal state into the derived state that will be exposed to
144
- * subscribers. This method is called automatically after each state
145
- * update and during initialization.
146
- *
147
- * @param state - The current internal state
148
- * @returns The derived state with any computed properties
149
- *
150
- * @example
151
- * ```typescript
152
- * computeDerivedState({ count }: CounterState): CounterDerivedState {
153
- * return {
154
- * count,
155
- * isEven: count % 2 === 0,
156
- * isPositive: count > 0,
157
- * };
158
- * }
159
- * ```
160
- */
161
- abstract computeDerivedState(state: S): D;
162
-
163
- /**
164
- * Get the current derived state.
165
- *
166
- * This returns the derived state computed by `computeDerivedState`,
167
- * not the internal state.
168
- *
169
- * @returns The current derived state
170
- */
171
- get state(): D {
172
- return this._state;
173
- }
174
- }