@view-models/core 4.0.0 → 4.1.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
@@ -106,6 +106,60 @@ function Counter({ model }) {
106
106
  }
107
107
  ```
108
108
 
109
+ ## Derived State
110
+
111
+ The `derived` utility creates memoized mapper functions for computing derived state from your view model. This is useful when you need to transform or aggregate state values without recomputing on every render.
112
+
113
+ The `derived` function uses `Object.is` to compare inputs. When the input reference hasn't changed (which is guaranteed when using immutable updates), the cached output is returned without re-executing the mapper function.
114
+
115
+ ```typescript
116
+ import { ViewModel, derived } from "@view-models/core";
117
+
118
+ type CartState = {
119
+ items: Array<{ id: string; price: number; quantity: number }>;
120
+ };
121
+
122
+ class CartViewModel extends ViewModel<CartState> {
123
+ constructor() {
124
+ super({ items: [] });
125
+ }
126
+
127
+ addItem(item: { id: string; price: number; quantity: number }) {
128
+ super.update({
129
+ items: [...super.state.items, item],
130
+ });
131
+ }
132
+ }
133
+
134
+ // Create a derived mapper that computes cart statistics
135
+ const selectCartStats = derived((state: CartState) => ({
136
+ total: state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
137
+ itemCount: state.items.reduce((sum, item) => sum + item.quantity, 0),
138
+ }));
139
+
140
+ // Use it to compute derived state
141
+ const cart = new CartViewModel();
142
+ cart.addItem({ id: "1", price: 10, quantity: 2 });
143
+ cart.addItem({ id: "2", price: 15, quantity: 1 });
144
+ const stats = selectCartStats(cart.state); // { total: 35, itemCount: 3 }
145
+ ```
146
+
147
+ Derived mappers are particularly useful with framework hooks like `useDerivedState` (from [@view-models/react](https://github.com/sunesimonsen/view-models-react)):
148
+
149
+ ```typescript
150
+ function CartSummary({ cart }) {
151
+ // Only recomputes when cart.state reference changes
152
+ const stats = useDerivedState(cart, selectCartStats);
153
+
154
+ return (
155
+ <div>
156
+ <p>Items: {stats.itemCount}</p>
157
+ <p>Total: ${stats.total}</p>
158
+ </div>
159
+ );
160
+ }
161
+ ```
162
+
109
163
  ## API Reference
110
164
 
111
165
  For detailed API documentation, see [docs](./docs).
package/dist/index.d.ts CHANGED
@@ -1,3 +1,37 @@
1
+ type Mapper<I, O> = (input: I) => O;
2
+ /**
3
+ * A branded type for derived state mapper functions created by the `derived` utility.
4
+ * This type ensures that mapper functions are properly memoized before being used
5
+ * with hooks like `useDerivedState`.
6
+ */
7
+ type DerivedMapper<I, O> = Mapper<I, O> & {
8
+ __brand: "derived";
9
+ };
10
+ /**
11
+ * Creates a memoized derived state mapper function that caches results based on input identity.
12
+ *
13
+ * The memoization uses `Object.is` to compare inputs, making it ideal for use with
14
+ * immutable state objects. When the input reference hasn't changed, the cached output
15
+ * is returned without re-executing the function.
16
+ *
17
+ * @template I - The input type
18
+ * @template O - The output type
19
+ * @param fn - A pure function that transforms input to output
20
+ * @returns A memoized derived mapper function
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * const selectStats = derived(({ items }: AppState) => ({
25
+ * total: items.reduce((sum, item) => sum + item.price, 0),
26
+ * count: items.length
27
+ * }));
28
+ *
29
+ * // Use with useDerivedState
30
+ * const stats = selectStats(model.state);
31
+ * ```
32
+ */
33
+ declare const derived: <I, O>(fn: Mapper<I, O>) => DerivedMapper<I, O>;
34
+
1
35
  /**
2
36
  * Function that gets called when the state changes.
3
37
  */
@@ -83,5 +117,5 @@ declare abstract class ViewModel<S extends object> {
83
117
  get state(): S;
84
118
  }
85
119
 
86
- export { ViewModel };
87
- export type { ViewModelListener };
120
+ export { ViewModel, derived };
121
+ export type { DerivedMapper, 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}update(t){this.i={...this.i,...t},this.t.forEach(t=>t())}get state(){return this.i}}export{t as ViewModel};
1
+ const t={},s=s=>{let e=t,r=t;return i=>e!==t&&Object.is(e,i)?r:r=s(e=i)};class e{subscribe(t){return this.t.add(t),()=>{this.t.delete(t)}}constructor(t){this.t=new Set,this.i=t}update(t){this.i={...this.i,...t},this.t.forEach(t=>t())}get state(){return this.i}}export{e as ViewModel,s as derived};
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/derived.ts","../src/ViewModel.ts"],"sourcesContent":["type Mapper<I, O> = (input: I) => O;\n\n/**\n * A branded type for derived state mapper functions created by the `derived` utility.\n * This type ensures that mapper functions are properly memoized before being used\n * with hooks like `useDerivedState`.\n */\nexport type DerivedMapper<I, O> = Mapper<I, O> & { __brand: \"derived\" };\n\nconst UNINITIALIZED = {};\n\n/**\n * Creates a memoized derived state mapper function that caches results based on input identity.\n *\n * The memoization uses `Object.is` to compare inputs, making it ideal for use with\n * immutable state objects. When the input reference hasn't changed, the cached output\n * is returned without re-executing the function.\n *\n * @template I - The input type\n * @template O - The output type\n * @param fn - A pure function that transforms input to output\n * @returns A memoized derived mapper function\n *\n * @example\n * ```ts\n * const selectStats = derived(({ items }: AppState) => ({\n * total: items.reduce((sum, item) => sum + item.price, 0),\n * count: items.length\n * }));\n *\n * // Use with useDerivedState\n * const stats = selectStats(model.state);\n * ```\n */\nexport const derived = <I, O>(fn: Mapper<I, O>): DerivedMapper<I, O> => {\n let lastInput: I | typeof UNINITIALIZED = UNINITIALIZED;\n let lastOutput: O | typeof UNINITIALIZED = UNINITIALIZED;\n return ((input: I) => {\n if (lastInput !== UNINITIALIZED && Object.is(lastInput, input)) {\n return lastOutput as O;\n }\n return (lastOutput = fn((lastInput = input)));\n }) as DerivedMapper<I, O>;\n};\n","/**\n * Function that gets called when the state changes.\n */\nexport type ViewModelListener = () => void;\n\n/**\n * Abstract base class for creating reactive view models.\n *\n * A ViewModel manages state and notifies subscribers when the state changes.\n * Extend this class to create your own view models with custom business logic.\n *\n * @template S - The state type\n *\n * @example\n * ```typescript\n * type CounterState = { count: number };\n *\n * class CounterViewModel extends ViewModel<CounterState> {\n * constructor() {\n * super({ count: 0 });\n * }\n *\n * increment() {\n * super.update({ count: super.state.count + 1 });\n * }\n * }\n *\n * const counter = new CounterViewModel();\n * const unsubscribe = counter.subscribe(() => {\n * console.log('Count:', counter.state.count);\n * });\n * counter.increment(); // Logs: Count: 1\n * ```\n */\nexport abstract class ViewModel<S extends object> {\n private _listeners = new Set<ViewModelListener>();\n private _state: S;\n\n /**\n * Subscribe to state changes.\n *\n * The listener will be called immediately after any state update.\n *\n * @param listener - Function to call when state changes\n * @returns Function to unsubscribe the listener\n *\n * @example\n * ```typescript\n * const unsubscribe = viewModel.subscribe(() => {\n * console.log('State changed:', viewModel.state);\n * });\n *\n * // Later, when you want to stop listening:\n * unsubscribe();\n * ```\n */\n subscribe(listener: ViewModelListener): () => void {\n this._listeners.add(listener);\n return () => {\n this._listeners.delete(listener);\n };\n }\n\n /**\n * Create a new ViewModel with the given initial state.\n *\n * @param initialState - The initial state of the view model\n */\n constructor(initialState: S) {\n this._state = initialState;\n }\n\n /**\n * Update the state and notify all subscribers.\n *\n * This method is protected and should only be called from within your view model subclass.\n * The partial state is merged with the current state to create the new state.\n *\n * @param partial - Partial state to merge with the current state\n *\n * @example\n * ```typescript\n * super.update({\n * count: super.state.count + 1\n * });\n * ```\n */\n protected update(partial: Partial<S>) {\n this._state = { ...this._state, ...partial };\n this._listeners.forEach((l) => l());\n }\n\n /**\n * Get the current state.\n *\n * @returns The current state\n */\n get state(): S {\n return this._state;\n }\n}\n"],"names":["UNINITIALIZED","derived","fn","lastInput","lastOutput","input","Object","is","ViewModel","subscribe","listener","this","_listeners","add","delete","constructor","initialState","Set","_state","update","partial","forEach","l","state"],"mappings":"AASA,MAAMA,EAAgB,CAAA,EAyBTC,EAAiBC,IAC5B,IAAIC,EAAsCH,EACtCI,EAAuCJ,EAC3C,OAASK,GACHF,IAAcH,GAAiBM,OAAOC,GAAGJ,EAAWE,GAC/CD,EAEDA,EAAaF,EAAIC,EAAYE,UCPnBG,EAsBpB,SAAAC,CAAUC,GAER,OADAC,KAAKC,EAAWC,IAAIH,GACb,KACLC,KAAKC,EAAWE,OAAOJ,GAE3B,CAOA,WAAAK,CAAYC,GAjCJL,KAAAC,EAAa,IAAIK,IAkCvBN,KAAKO,EAASF,CAChB,CAiBU,MAAAG,CAAOC,GACfT,KAAKO,EAAS,IAAKP,KAAKO,KAAWE,GACnCT,KAAKC,EAAWS,QAASC,GAAMA,IACjC,CAOA,SAAIC,GACF,OAAOZ,KAAKO,CACd"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@view-models/core",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "A lightweight, framework-agnostic library for building reactive view models with TypeScript",
5
5
  "keywords": [
6
6
  "view",
package/src/derived.ts ADDED
@@ -0,0 +1,44 @@
1
+ type Mapper<I, O> = (input: I) => O;
2
+
3
+ /**
4
+ * A branded type for derived state mapper functions created by the `derived` utility.
5
+ * This type ensures that mapper functions are properly memoized before being used
6
+ * with hooks like `useDerivedState`.
7
+ */
8
+ export type DerivedMapper<I, O> = Mapper<I, O> & { __brand: "derived" };
9
+
10
+ const UNINITIALIZED = {};
11
+
12
+ /**
13
+ * Creates a memoized derived state mapper function that caches results based on input identity.
14
+ *
15
+ * The memoization uses `Object.is` to compare inputs, making it ideal for use with
16
+ * immutable state objects. When the input reference hasn't changed, the cached output
17
+ * is returned without re-executing the function.
18
+ *
19
+ * @template I - The input type
20
+ * @template O - The output type
21
+ * @param fn - A pure function that transforms input to output
22
+ * @returns A memoized derived mapper function
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const selectStats = derived(({ items }: AppState) => ({
27
+ * total: items.reduce((sum, item) => sum + item.price, 0),
28
+ * count: items.length
29
+ * }));
30
+ *
31
+ * // Use with useDerivedState
32
+ * const stats = selectStats(model.state);
33
+ * ```
34
+ */
35
+ export const derived = <I, O>(fn: Mapper<I, O>): DerivedMapper<I, O> => {
36
+ let lastInput: I | typeof UNINITIALIZED = UNINITIALIZED;
37
+ let lastOutput: O | typeof UNINITIALIZED = UNINITIALIZED;
38
+ return ((input: I) => {
39
+ if (lastInput !== UNINITIALIZED && Object.is(lastInput, input)) {
40
+ return lastOutput as O;
41
+ }
42
+ return (lastOutput = fn((lastInput = input)));
43
+ }) as DerivedMapper<I, O>;
44
+ };
package/src/index.ts CHANGED
@@ -1 +1,2 @@
1
+ export * from "./derived.js";
1
2
  export * from "./ViewModel.js";