@view-models/core 3.0.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,7 +1,7 @@
1
1
  # @view-models/core
2
2
 
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
- [![Bundle Size](https://img.badgesize.io/https://unpkg.com/@view-models/core@2.2.0/dist/index.js?label=gzip&compression=gzip)](https://unpkg.com/@view-models/core@2.2.0/dist/index.js)
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
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,68 +82,6 @@ describe("CounterViewModel", () => {
82
82
  });
83
83
  ```
84
84
 
85
- ## View Models with Derived State
86
-
87
- When you need to compute derived values from your state (like counts, filtered lists, or formatted data), use `ViewModelWithComputedState`:
88
-
89
- ```typescript
90
- import { ViewModelWithComputedState } from "@view-models/core";
91
-
92
- type TodoState = {
93
- items: Array<{ id: string; text: string; done: boolean }>;
94
- };
95
-
96
- type TodoDerivedState = TodoState & {
97
- totalCount: number;
98
- completedCount: number;
99
- remainingCount: number;
100
- };
101
-
102
- class TodoViewModel extends ViewModelWithComputedState<
103
- TodoState,
104
- TodoDerivedState
105
- > {
106
- constructor() {
107
- super({ items: [] });
108
- }
109
-
110
- computedState({ items }: TodoState): TodoDerivedState {
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) {
129
- super.update({
130
- items: super.state.items.map((item) =>
131
- item.id === id ? { ...item, done: !item.done } : item,
132
- ),
133
- });
134
- }
135
- }
136
-
137
- const todos = new TodoViewModel();
138
- console.log(todos.state.totalCount); // 0
139
-
140
- todos.addTodo("Learn ViewModels");
141
- console.log(todos.state.totalCount); // 1
142
- console.log(todos.state.remainingCount); // 1
143
- ```
144
-
145
- The derived state is automatically recomputed whenever the internal state changes, ensuring your computed properties are always up-to-date.
146
-
147
85
  ## Framework Integration
148
86
 
149
87
  The view models are designed to work with framework-specific adapters. Upcoming adapters include:
package/dist/index.d.ts CHANGED
@@ -1,63 +1,38 @@
1
1
  /**
2
2
  * Function that gets called when the state changes.
3
- *
4
- * @template T - The state type
5
3
  */
6
4
  type ViewModelListener = () => void;
7
5
  /**
8
- * Abstract base class for creating reactive view models with derived state.
6
+ * Abstract base class for creating reactive view models.
9
7
  *
10
- * This class extends the basic ViewModel pattern by allowing you to compute
11
- * derived state from internal state. The internal state is managed privately,
12
- * while the derived state (which can include computed properties) is exposed
13
- * 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.
14
10
  *
15
- * @template S - The internal state type (managed internally)
16
- * @template D - The derived state type (exposed to subscribers)
11
+ * @template S - The state type
17
12
  *
18
13
  * @example
19
14
  * ```typescript
20
- * type TodoState = {
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
- * };
15
+ * type CounterState = { count: number };
29
16
  *
30
- * class TodoViewModel extends ViewModelWithComputedState<TodoState, TodoDerivedState> {
17
+ * class CounterViewModel extends ViewModel<CounterState> {
31
18
  * constructor() {
32
- * super({ items: [] });
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
- * };
19
+ * super({ count: 0 });
42
20
  * }
43
21
  *
44
- * addTodo(text: string) {
45
- * super.update({
46
- * items: [...super.state.items, { id: crypto.randomUUID(), text, done: false }],
47
- * });
22
+ * increment() {
23
+ * super.update({ count: super.state.count + 1 });
48
24
  * }
49
25
  * }
50
26
  *
51
- * const todos = new TodoViewModel();
52
- * todos.subscribe(() => {
53
- * console.log('Completed:', todos.state.completedCount);
27
+ * const counter = new CounterViewModel();
28
+ * const unsubscribe = counter.subscribe(() => {
29
+ * console.log('Count:', counter.state.count);
54
30
  * });
55
- * todos.addTodo('Learn ViewModels'); // Logs: Completed: 0
31
+ * counter.increment(); // Logs: Count: 1
56
32
  * ```
57
33
  */
58
- declare abstract class ViewModelWithComputedState<S extends object, D> {
34
+ declare abstract class ViewModel<S extends object> {
59
35
  private _listeners;
60
- private _internalState;
61
36
  private _state;
62
37
  /**
63
38
  * Subscribe to state changes.
@@ -69,8 +44,8 @@ declare abstract class ViewModelWithComputedState<S extends object, D> {
69
44
  *
70
45
  * @example
71
46
  * ```typescript
72
- * const unsubscribe = viewModel.subscribe((state) => {
73
- * console.log('State changed:', state);
47
+ * const unsubscribe = viewModel.subscribe(() => {
48
+ * console.log('State changed:', viewModel.state);
74
49
  * });
75
50
  *
76
51
  * // Later, when you want to stop listening:
@@ -79,22 +54,18 @@ declare abstract class ViewModelWithComputedState<S extends object, D> {
79
54
  */
80
55
  subscribe(listener: ViewModelListener): () => void;
81
56
  /**
82
- * Create a new ViewModel with the given initial internal state.
83
- *
84
- * The constructor initializes the internal state and immediately computes
85
- * the derived state by calling `computedState`.
57
+ * Create a new ViewModel with the given initial state.
86
58
  *
87
- * @param initialState - The initial internal state of the view model
59
+ * @param initialState - The initial state of the view model
88
60
  */
89
61
  constructor(initialState: S);
90
62
  /**
91
- * Update the internal state, recompute derived state, and notify all subscribers.
63
+ * Update the state and notify all subscribers.
92
64
  *
93
65
  * This method is protected and should only be called from within your view model subclass.
94
- * The partial state is merged with the current internal state to create the new internal state.
95
- * After updating, the derived state is automatically recomputed via `computeDerivedState`.
66
+ * The partial state is merged with the current state to create the new state.
96
67
  *
97
- * @param partial - Partial state to merge with the current internal state
68
+ * @param partial - Partial state to merge with the current state
98
69
  *
99
70
  * @example
100
71
  * ```typescript
@@ -105,77 +76,12 @@ declare abstract class ViewModelWithComputedState<S extends object, D> {
105
76
  */
106
77
  protected update(partial: Partial<S>): void;
107
78
  /**
108
- * Compute the derived state from the internal state.
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
117
- *
118
- * @example
119
- * ```typescript
120
- * computedState({ count }: CounterState): CounterDerivedState {
121
- * return {
122
- * count,
123
- * isEven: count % 2 === 0,
124
- * isPositive: count > 0,
125
- * };
126
- * }
127
- * ```
128
- */
129
- abstract computedState(state: S): D;
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.
79
+ * Get the current state.
173
80
  *
174
- * @param initialState - The initial state of the view model
81
+ * @returns The current state
175
82
  */
176
- constructor(initialState: S);
177
- computedState(state: S): S;
83
+ get state(): S;
178
84
  }
179
85
 
180
- export { ViewModel, ViewModelWithComputedState };
86
+ export { ViewModel };
181
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.computedState(this.i)}update(t){this.i={...this.i,...t},this.h=this.computedState(this.i);for(const t of this.t)t()}get state(){return this.h}}class s extends t{constructor(t){super(t)}computedState(t){return t}}export{s as ViewModel,t as ViewModelWithComputedState};
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/ViewModelWithComputedState.ts","../src/ViewModel.ts"],"sourcesContent":["/**\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 ViewModelWithComputedState<TodoState, TodoDerivedState> {\n * constructor() {\n * super({ items: [] });\n * }\n *\n * computedState({ 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 * super.update({\n * items: [...super.state.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 ViewModelWithComputedState<S extends object, 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 `computedState`.\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.computedState(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 partial state is merged with the current internal state to create the new internal state.\n * After updating, the derived state is automatically recomputed via `computeDerivedState`.\n *\n * @param partial - Partial state to merge with the current internal 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._internalState = { ...this._internalState, ...partial };\n this._state = this.computedState(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 * computedState({ count }: CounterState): CounterDerivedState {\n * return {\n * count,\n * isEven: count % 2 === 0,\n * isPositive: count > 0,\n * };\n * }\n * ```\n */\n abstract computedState(state: S): D;\n\n /**\n * Get the current derived state.\n *\n * This returns the derived state computed by `computedState`,\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 { ViewModelWithComputedState } from \"./ViewModelWithComputedState.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 * super.update({ count: super.state.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<\n S extends object,\n> extends ViewModelWithComputedState<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 computedState(state: S): S {\n return state;\n }\n}\n"],"names":["ViewModelWithComputedState","subscribe","listener","this","_listeners","add","delete","constructor","initialState","Set","_internalState","_state","computedState","update","partial","state","ViewModel","super"],"mappings":"MA0DsBA,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,cAAcT,KAAKO,EACxC,CAkBU,MAAAG,CAAOC,GACfX,KAAKO,EAAiB,IAAKP,KAAKO,KAAmBI,GACnDX,KAAKQ,EAASR,KAAKS,cAAcT,KAAKO,GAEtC,IAAK,MAAMR,KAAYC,KAAKC,EAC1BF,GAEJ,CAkCA,SAAIa,GACF,OAAOZ,KAAKQ,CACd,ECjII,MAAgBK,UAEZhB,EAMR,WAAAO,CAAYC,GACVS,MAAMT,EACR,CAEA,aAAAI,CAAcG,GACZ,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": "3.0.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 { ViewModelWithComputedState } from "./ViewModelWithComputedState.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.
@@ -23,25 +26,76 @@ import { ViewModelWithComputedState } from "./ViewModelWithComputedState.js";
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<
33
- S extends object,
34
- > extends ViewModelWithComputedState<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
+
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
- super(initialState);
70
+ this._state = initialState;
42
71
  }
43
72
 
44
- computedState(state: S): S {
45
- 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;
46
100
  }
47
101
  }
package/src/index.ts CHANGED
@@ -1,2 +1 @@
1
1
  export * from "./ViewModel.js";
2
- export * from "./ViewModelWithComputedState.js";
@@ -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
- }