@view-models/core 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sune Simonsen <sune@we-knowhow.dk>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # @view-models/core
2
+
3
+ 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
+
5
+ ## Why View Models?
6
+
7
+ - **Framework Agnostic**: Write your logic once, use it with React, Preact, or any other framework
8
+ - **Easy Testing**: Test your view logic without rendering anything to the DOM
9
+ - **Simple API**: Just extend `ViewModel` and you're ready to go
10
+ - **Type Safe**: Built with TypeScript for excellent IDE support
11
+ - **Minimal Boilerplate**: Much lighter than Redux with no reducers, actions, or middleware needed
12
+ - **Natural Organization**: A clear place to put your application logic
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @view-models/core
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ Define your state type and extend `ViewModel`:
23
+
24
+ ```typescript
25
+ import { ViewModel } from "@view-models/core";
26
+
27
+ type CounterState = {
28
+ count: number;
29
+ };
30
+
31
+ class CounterViewModel extends ViewModel<CounterState> {
32
+ constructor() {
33
+ super({ count: 0 });
34
+ }
35
+
36
+ increment() {
37
+ this.update(({ count }) => ({
38
+ count: count + 1,
39
+ }));
40
+ }
41
+
42
+ decrement() {
43
+ this.update(({ count }) => ({
44
+ count: count - 1,
45
+ }));
46
+ }
47
+
48
+ reset() {
49
+ this.update(() => ({ count: 0 }));
50
+ }
51
+ }
52
+ ```
53
+
54
+ Use it in your tests:
55
+
56
+ ```typescript
57
+ import { describe, it, expect } from "vitest";
58
+
59
+ describe("CounterViewModel", () => {
60
+ it("increments the counter", () => {
61
+ const counter = new CounterViewModel();
62
+
63
+ counter.increment();
64
+ expect(counter.state.count).toBe(1);
65
+
66
+ counter.increment();
67
+ expect(counter.state.count).toBe(2);
68
+ });
69
+
70
+ it("notifies subscribers on updates", () => {
71
+ const counter = new CounterViewModel();
72
+ const updates = [];
73
+
74
+ counter.subscribe((state) => updates.push(state));
75
+
76
+ counter.increment();
77
+ counter.increment();
78
+
79
+ expect(updates).toEqual([{ count: 1 }, { count: 2 }]);
80
+ });
81
+ });
82
+ ```
83
+
84
+ ## Framework Integration
85
+
86
+ The view models are designed to work with framework-specific adapters. Upcoming adapters include:
87
+
88
+ - **@view-models/react** - React hooks integration
89
+ - **@view-models/preact** - Preact hooks integration
90
+
91
+ These adapters will allow you to use the same view model with different frameworks:
92
+
93
+ ```typescript
94
+ // Coming soon with @view-models/react
95
+ function Counter({ model }) {
96
+ const { count } = useModelState(model);
97
+
98
+ return (
99
+ <div>
100
+ <p>Count: {count}</p>
101
+ <button onClick={model.increment}>+</button>
102
+ <button onClick={model.decrement}>-</button>
103
+ </div>
104
+ );
105
+ }
106
+ ```
107
+
108
+ ## API Reference
109
+
110
+ ### `ViewModel<T>`
111
+
112
+ Abstract base class for creating view models.
113
+
114
+ #### Constructor
115
+
116
+ ```typescript
117
+ constructor(initialState: T)
118
+ ```
119
+
120
+ Initialize the view model with an initial state.
121
+
122
+ #### Methods
123
+
124
+ ##### `subscribe(listener: ViewModelListener<T>): void`
125
+
126
+ Subscribe to state changes. The listener will be called with the new state whenever `update()` is called.
127
+
128
+ ```typescript
129
+ const unsubscribe = viewModel.subscribe((state) => {
130
+ console.log("State changed:", state);
131
+ });
132
+ ```
133
+
134
+ ##### `unsubscribe(listener: ViewModelListener<T>): void`
135
+
136
+ Unsubscribe a previously subscribed listener.
137
+
138
+ ```typescript
139
+ viewModel.unsubscribe(listener);
140
+ ```
141
+
142
+ ##### `update(updater: Updater<T>): void` (protected)
143
+
144
+ Update the state and notify all subscribers. This method is protected and should only be called from within your view model subclass.
145
+
146
+ ```typescript
147
+ protected update(updater: (currentState: T) => T): void
148
+ ```
149
+
150
+ The updater function receives the current state and should return the new state.
151
+
152
+ ##### `state: T` (getter)
153
+
154
+ Access the current state.
155
+
156
+ ```typescript
157
+ const currentState = viewModel.state;
158
+ ```
159
+
160
+ ## Patterns and Best Practices
161
+
162
+ ### Keep State Immutable
163
+
164
+ Always return new state objects from your updater functions:
165
+
166
+ ```typescript
167
+ // Good
168
+ this.update(({ count }) => ({
169
+ ...state,
170
+ count: count + 1,
171
+ }));
172
+
173
+ // Bad - mutates existing state
174
+ this.update((state) => {
175
+ state.count++;
176
+ return state;
177
+ });
178
+ ```
179
+
180
+ ### Use Readonly Types
181
+
182
+ TypeScript's `Readonly` and `ReadonlyArray` help ensure immutability:
183
+
184
+ ```typescript
185
+ type State = {
186
+ items: ReadonlyArray<string>;
187
+ config: Readonly<{ enabled: boolean }>;
188
+ };
189
+ ```
190
+
191
+ ### Compose Multiple View Models
192
+
193
+ You can compose view models for complex UIs:
194
+
195
+ ```typescript
196
+ class AppViewModel {
197
+ readonly user = new UserViewModel();
198
+ readonly cart = new CartViewModel();
199
+ readonly products = new ProductsViewModel();
200
+ }
201
+ ```
202
+
203
+ ### Handle Side Effects
204
+
205
+ Keep your update logic pure, but expose methods for side effects:
206
+
207
+ ```typescript
208
+ class TodosViewModel extends ViewModel<TodosState> {
209
+ private api: API
210
+
211
+ constructor(state: TodosState, api: API) {
212
+ super(state)
213
+ this.api = api
214
+ }
215
+
216
+ async loadTodos() {
217
+ this.update((state) => ({ ...state, loading: true, failed: false }));
218
+ try {
219
+ const todos = await this.api.fetchTodos();
220
+ this.update(() => ({ todos, loading: false }));
221
+ } catch {
222
+ this.update((state) => ({ ...state, loading: false: failed: true }));
223
+ }
224
+ }
225
+
226
+ async addTodo(text: string) {
227
+ try {
228
+ const todo = await this.api.createTodo(text);
229
+ this.update(({ todos }) => ({
230
+ todos: [...todos, todo],
231
+ }));
232
+ } catch {
233
+ // TODO show error
234
+ }
235
+ }
236
+ }
237
+ ```
238
+
239
+ ## License
240
+
241
+ MIT License
242
+
243
+ Copyright (c) 2026 Sune Simonsen <sune@we-knowhow.dk>
244
+
245
+ Permission is hereby granted, free of charge, to any person obtaining a copy
246
+ of this software and associated documentation files (the "Software"), to deal
247
+ in the Software without restriction, including without limitation the rights
248
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
249
+ copies of the Software, and to permit persons to whom the Software is
250
+ furnished to do so, subject to the following conditions:
251
+
252
+ The above copyright notice and this permission notice shall be included in all
253
+ copies or substantial portions of the Software.
254
+
255
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
256
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
257
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
258
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
259
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
260
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
261
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ type State = Readonly<object>;
2
+ /**
3
+ * Function that receives the current state and returns the new state.
4
+ * The updater function should be pure and return a new state object.
5
+ *
6
+ * @template T - The state type
7
+ * @param currentState - The current state
8
+ * @returns The new state
9
+ */
10
+ export type Updater<T extends State> = (currentState: T) => T;
11
+ /**
12
+ * Function that gets called when the state changes.
13
+ *
14
+ * @template T - The state type
15
+ * @param state - The new state
16
+ */
17
+ export type ViewModelListener<T> = (state: T) => void;
18
+ /**
19
+ * Abstract base class for creating reactive view models.
20
+ *
21
+ * A ViewModel manages state and notifies subscribers when the state changes.
22
+ * Extend this class to create your own view models with custom business logic.
23
+ *
24
+ * @template T - The state type (must be a readonly object)
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * type CounterState = { count: number };
29
+ *
30
+ * class CounterViewModel extends ViewModel<CounterState> {
31
+ * constructor() {
32
+ * super({ count: 0 });
33
+ * }
34
+ *
35
+ * increment() {
36
+ * this.update(({ count }) => ({ count: count + 1 }));
37
+ * }
38
+ * }
39
+ *
40
+ * const counter = new CounterViewModel();
41
+ * const unsubscribe = counter.subscribe((state) => {
42
+ * console.log('Count:', state.count);
43
+ * });
44
+ * counter.increment(); // Logs: Count: 1
45
+ * ```
46
+ */
47
+ export declare abstract class ViewModel<T extends State> {
48
+ private _listeners;
49
+ /**
50
+ * Subscribe to state changes.
51
+ *
52
+ * The listener will be called immediately after any state update.
53
+ *
54
+ * @param listener - Function to call when state changes
55
+ * @returns Function to unsubscribe the listener
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * const unsubscribe = viewModel.subscribe((state) => {
60
+ * console.log('State changed:', state);
61
+ * });
62
+ *
63
+ * // Later, when you want to stop listening:
64
+ * unsubscribe();
65
+ * ```
66
+ */
67
+ subscribe(listener: ViewModelListener<T>): () => void;
68
+ private _state;
69
+ /**
70
+ * Create a new ViewModel with the given initial state.
71
+ *
72
+ * @param initialState - The initial state of the view model
73
+ */
74
+ constructor(initialState: T);
75
+ /**
76
+ * Update the state and notify all subscribers.
77
+ *
78
+ * This method is protected and should only be called from within your view model subclass.
79
+ * The updater function receives the current state and should return the new state.
80
+ * Always return a new state object to ensure immutability.
81
+ *
82
+ * @param updater - Function that receives current state and returns new state
83
+ *
84
+ * @example
85
+ * ```typescript
86
+ * this.update((currentState) => ({
87
+ * ...currentState,
88
+ * count: currentState.count + 1
89
+ * }));
90
+ * ```
91
+ */
92
+ protected update(updater: Updater<T>): void;
93
+ /**
94
+ * Get the current state.
95
+ *
96
+ * @returns The current state
97
+ */
98
+ get state(): T;
99
+ }
100
+ export {};
101
+ //# sourceMappingURL=ViewModel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ViewModel.d.ts","sourceRoot":"","sources":["../src/ViewModel.ts"],"names":[],"mappings":"AAAA,KAAK,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;AAE9B;;;;;;;GAOG;AACH,MAAM,MAAM,OAAO,CAAC,CAAC,SAAS,KAAK,IAAI,CAAC,YAAY,EAAE,CAAC,KAAK,CAAC,CAAC;AAE9D;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,8BAAsB,SAAS,CAAC,CAAC,SAAS,KAAK;IAC7C,OAAO,CAAC,UAAU,CAAwC;IAE1D;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,QAAQ,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI;IAOrD,OAAO,CAAC,MAAM,CAAI;IAElB;;;;OAIG;gBACS,YAAY,EAAE,CAAC;IAI3B;;;;;;;;;;;;;;;;OAgBG;IACH,SAAS,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IAQpC;;;;OAIG;IACH,IAAI,KAAK,IAAI,CAAC,CAEb;CACF"}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Abstract base class for creating reactive view models.
3
+ *
4
+ * A ViewModel manages state and notifies subscribers when the state changes.
5
+ * Extend this class to create your own view models with custom business logic.
6
+ *
7
+ * @template T - The state type (must be a readonly object)
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * type CounterState = { count: number };
12
+ *
13
+ * class CounterViewModel extends ViewModel<CounterState> {
14
+ * constructor() {
15
+ * super({ count: 0 });
16
+ * }
17
+ *
18
+ * increment() {
19
+ * this.update(({ count }) => ({ count: count + 1 }));
20
+ * }
21
+ * }
22
+ *
23
+ * const counter = new CounterViewModel();
24
+ * const unsubscribe = counter.subscribe((state) => {
25
+ * console.log('Count:', state.count);
26
+ * });
27
+ * counter.increment(); // Logs: Count: 1
28
+ * ```
29
+ */
30
+ export class ViewModel {
31
+ /**
32
+ * Subscribe to state changes.
33
+ *
34
+ * The listener will be called immediately after any state update.
35
+ *
36
+ * @param listener - Function to call when state changes
37
+ * @returns Function to unsubscribe the listener
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * const unsubscribe = viewModel.subscribe((state) => {
42
+ * console.log('State changed:', state);
43
+ * });
44
+ *
45
+ * // Later, when you want to stop listening:
46
+ * unsubscribe();
47
+ * ```
48
+ */
49
+ subscribe(listener) {
50
+ this._listeners.add(listener);
51
+ return () => {
52
+ this._listeners.delete(listener);
53
+ };
54
+ }
55
+ /**
56
+ * Create a new ViewModel with the given initial state.
57
+ *
58
+ * @param initialState - The initial state of the view model
59
+ */
60
+ constructor(initialState) {
61
+ this._listeners = new Set();
62
+ this._state = initialState;
63
+ }
64
+ /**
65
+ * Update the state and notify all subscribers.
66
+ *
67
+ * This method is protected and should only be called from within your view model subclass.
68
+ * The updater function receives the current state and should return the new state.
69
+ * Always return a new state object to ensure immutability.
70
+ *
71
+ * @param updater - Function that receives current state and returns new state
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * this.update((currentState) => ({
76
+ * ...currentState,
77
+ * count: currentState.count + 1
78
+ * }));
79
+ * ```
80
+ */
81
+ update(updater) {
82
+ this._state = updater(this._state);
83
+ for (const listener of this._listeners) {
84
+ listener(this._state);
85
+ }
86
+ }
87
+ /**
88
+ * Get the current state.
89
+ *
90
+ * @returns The current state
91
+ */
92
+ get state() {
93
+ return this._state;
94
+ }
95
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./ViewModel.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./ViewModel.js";
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@view-models/core",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight, framework-agnostic library for building reactive view models with TypeScript",
5
+ "keywords": [
6
+ "view",
7
+ "viewmodel",
8
+ "reactive",
9
+ "state",
10
+ "typescript"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Sune Simonsen",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/sunesimonsen/view-models-core.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/sunesimonsen/view-models-core/issues"
20
+ },
21
+ "homepage": "https://github.com/sunesimonsen/view-models-core#readme",
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest",
43
+ "test:coverage": "vitest run --coverage",
44
+ "format": "prettier --write \"**/*.{ts,js,json,md}\"",
45
+ "format:check": "prettier --check \"**/*.{ts,js,json,md}\"",
46
+ "prepublishOnly": "npm run build"
47
+ },
48
+ "devDependencies": {
49
+ "@vitest/coverage-v8": "^4.0.16",
50
+ "prettier": "^3.7.4",
51
+ "typescript": "^5.7.3",
52
+ "vitest": "^4.0.16"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }
@@ -0,0 +1,122 @@
1
+ type State = Readonly<object>;
2
+
3
+ /**
4
+ * Function that receives the current state and returns the new state.
5
+ * The updater function should be pure and return a new state object.
6
+ *
7
+ * @template T - The state type
8
+ * @param currentState - The current state
9
+ * @returns The new state
10
+ */
11
+ export type Updater<T extends State> = (currentState: T) => T;
12
+
13
+ /**
14
+ * Function that gets called when the state changes.
15
+ *
16
+ * @template T - The state type
17
+ * @param state - The new state
18
+ */
19
+ export type ViewModelListener<T> = (state: T) => void;
20
+
21
+ /**
22
+ * Abstract base class for creating reactive view models.
23
+ *
24
+ * A ViewModel manages state and notifies subscribers when the state changes.
25
+ * Extend this class to create your own view models with custom business logic.
26
+ *
27
+ * @template T - The state type (must be a readonly object)
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * type CounterState = { count: number };
32
+ *
33
+ * class CounterViewModel extends ViewModel<CounterState> {
34
+ * constructor() {
35
+ * super({ count: 0 });
36
+ * }
37
+ *
38
+ * increment() {
39
+ * this.update(({ count }) => ({ count: count + 1 }));
40
+ * }
41
+ * }
42
+ *
43
+ * const counter = new CounterViewModel();
44
+ * const unsubscribe = counter.subscribe((state) => {
45
+ * console.log('Count:', state.count);
46
+ * });
47
+ * counter.increment(); // Logs: Count: 1
48
+ * ```
49
+ */
50
+ export abstract class ViewModel<T extends State> {
51
+ private _listeners: Set<ViewModelListener<T>> = new Set();
52
+
53
+ /**
54
+ * Subscribe to state changes.
55
+ *
56
+ * The listener will be called immediately after any state update.
57
+ *
58
+ * @param listener - Function to call when state changes
59
+ * @returns Function to unsubscribe the listener
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * const unsubscribe = viewModel.subscribe((state) => {
64
+ * console.log('State changed:', state);
65
+ * });
66
+ *
67
+ * // Later, when you want to stop listening:
68
+ * unsubscribe();
69
+ * ```
70
+ */
71
+ subscribe(listener: ViewModelListener<T>): () => void {
72
+ this._listeners.add(listener);
73
+ return () => {
74
+ this._listeners.delete(listener);
75
+ };
76
+ }
77
+
78
+ private _state: T;
79
+
80
+ /**
81
+ * Create a new ViewModel with the given initial state.
82
+ *
83
+ * @param initialState - The initial state of the view model
84
+ */
85
+ constructor(initialState: T) {
86
+ this._state = initialState;
87
+ }
88
+
89
+ /**
90
+ * Update the state and notify all subscribers.
91
+ *
92
+ * This method is protected and should only be called from within your view model subclass.
93
+ * The updater function receives the current state and should return the new state.
94
+ * Always return a new state object to ensure immutability.
95
+ *
96
+ * @param updater - Function that receives current state and returns new state
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * this.update((currentState) => ({
101
+ * ...currentState,
102
+ * count: currentState.count + 1
103
+ * }));
104
+ * ```
105
+ */
106
+ protected update(updater: Updater<T>) {
107
+ this._state = updater(this._state);
108
+
109
+ for (const listener of this._listeners) {
110
+ listener(this._state);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Get the current state.
116
+ *
117
+ * @returns The current state
118
+ */
119
+ get state(): T {
120
+ return this._state;
121
+ }
122
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./ViewModel.js";