@view-models/react 1.3.0 → 2.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
@@ -46,6 +46,80 @@ function Counter() {
46
46
  }
47
47
  ```
48
48
 
49
+ ### Derived State
50
+
51
+ Use `useDerivedState` with the `derived` helper to compute values from your model state. The `derived` function creates a memoized mapper that uses reference equality (`Object.is`) to cache results, which works perfectly with immutable state.
52
+
53
+ Derived mappers should be defined outside your components:
54
+
55
+ ```tsx
56
+ import { ViewModel } from "@view-models/core";
57
+ import { useDerivedState, derived } from "@view-models/react";
58
+
59
+ type TodoItem = {
60
+ id: number;
61
+ text: string;
62
+ completed: boolean;
63
+ };
64
+
65
+ type TodoState = Readonly<{
66
+ items: TodoItem[];
67
+ filter: "all" | "active" | "completed";
68
+ }>;
69
+
70
+ class TodoViewModel extends ViewModel<TodoState> {
71
+ // ... methods to modify state
72
+ }
73
+
74
+ const todoModel = new TodoViewModel({ items: [], filter: "all" });
75
+
76
+ // Create derived mappers using the derived helper
77
+ const selectStats = derived(({ items }): TodoState) => ({
78
+ total: items.length,
79
+ completed: items.filter((item) => item.completed).length,
80
+ active: items.filter((item) => !item.completed).length,
81
+ }));
82
+
83
+ function TodoStats() {
84
+ // Use the derived mapper with useDerivedState
85
+ const stats = useDerivedState(todoModel, selectStats);
86
+
87
+ return (
88
+ <div>
89
+ <p>Total: {stats.total}</p>
90
+ <p>Completed: {stats.completed}</p>
91
+ <p>Active: {stats.active}</p>
92
+ </div>
93
+ );
94
+ }
95
+ ```
96
+
97
+ You can create multiple derived mappers for different parts of your state:
98
+
99
+ ```tsx
100
+ // Create derived mappers outside your components
101
+ const selectFilteredItems = derived(({ items, filter }: TodoState) =>
102
+ items.filter((item) => {
103
+ if (filter === "active") return !item.completed;
104
+ if (filter === "completed") return item.completed;
105
+ return true;
106
+ }),
107
+ );
108
+
109
+ function TodoList() {
110
+ // The mapper only re-runs when the state reference changes
111
+ const filteredItems = useDerivedState(todoModel, selectFilteredItems);
112
+
113
+ return (
114
+ <ul>
115
+ {filteredItems.map((item) => (
116
+ <li key={item.id}>{item.text}</li>
117
+ ))}
118
+ </ul>
119
+ );
120
+ }
121
+ ```
122
+
49
123
  ### Creating view models inside components
50
124
 
51
125
  When you need to create a view model from within a React component, use `useMemo` to ensure the model is only created once.
package/dist/index.d.ts CHANGED
@@ -55,6 +55,7 @@ interface ViewModel<T> {
55
55
  */
56
56
  get state(): T;
57
57
  }
58
+
58
59
  /**
59
60
  * A React hook that subscribes a component to a ViewModel's state updates.
60
61
  *
@@ -86,5 +87,81 @@ interface ViewModel<T> {
86
87
  */
87
88
  declare function useModelState<T>(model: ViewModel<T>): T;
88
89
 
89
- export { useModelState };
90
- export type { ViewModel, ViewModelListener };
90
+ type Mapper<I, O> = (input: I) => O;
91
+ /**
92
+ * A branded type for derived state mapper functions created by the `derived` utility.
93
+ * This type ensures that mapper functions are properly memoized before being used
94
+ * with hooks like `useDerivedState`.
95
+ */
96
+ type DerivedMapper<I, O> = Mapper<I, O> & {
97
+ __brand: "derived";
98
+ };
99
+ /**
100
+ * Creates a memoized derived state mapper function that caches results based on input identity.
101
+ *
102
+ * The memoization uses `Object.is` to compare inputs, making it ideal for use with
103
+ * immutable state objects. When the input reference hasn't changed, the cached output
104
+ * is returned without re-executing the function.
105
+ *
106
+ * This is the required way to create mapper functions for use with `useDerivedState`.
107
+ *
108
+ * @template I - The input type
109
+ * @template O - The output type
110
+ * @param fn - A pure function that transforms input to output
111
+ * @returns A memoized derived mapper function
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * const selectStats = derived(({ items }: AppState) => ({
116
+ * total: items.reduce((sum, item) => sum + item.price, 0),
117
+ * count: items.length
118
+ * }));
119
+ *
120
+ * // Use with useDerivedState
121
+ * const stats = useDerivedState(model, selectStats);
122
+ * ```
123
+ */
124
+ declare const derived: <I, O>(fn: Mapper<I, O>) => DerivedMapper<I, O>;
125
+
126
+ /**
127
+ * A React hook that subscribes to a ViewModel and returns derived state.
128
+ *
129
+ * This hook combines `useModelState` with a derived mapper function to compute derived values
130
+ * from the model's state. The component will re-render whenever the model state changes.
131
+ *
132
+ * The mapper must be created using the `derived` utility, which provides memoization
133
+ * based on state identity to prevent unnecessary recalculations.
134
+ *
135
+ * @template S - The model state type
136
+ * @template D - The derived state type
137
+ * @param model - The ViewModel instance to subscribe to
138
+ * @param mapper - A derived mapper function created with the `derived` utility
139
+ * @returns The derived state computed from the current model state
140
+ *
141
+ * @example
142
+ * ```tsx
143
+ * import { ViewModel } from "@view-models/core";
144
+ * import { useDerivedState, derived } from "@view-models/react";
145
+ *
146
+ * type TodoState = {
147
+ * items: Array<{ id: string; text: string; completed: boolean }>;
148
+ * };
149
+ *
150
+ * const todoModel = new ViewModel<TodoState>({ items: [] });
151
+ *
152
+ * // Create a derived mapper function
153
+ * const selectCompletedCount = derived(({ items }: TodoState) => ({
154
+ * completed: items.filter(item => item.completed).length,
155
+ * total: items.length
156
+ * }));
157
+ *
158
+ * function TodoStats() {
159
+ * const stats = useDerivedState(todoModel, selectCompletedCount);
160
+ * return <div>{stats.completed} of {stats.total} completed</div>;
161
+ * }
162
+ * ```
163
+ */
164
+ declare const useDerivedState: <S, D>(model: ViewModel<S>, mapper: DerivedMapper<S, D>) => D;
165
+
166
+ export { derived, useDerivedState, useModelState };
167
+ export type { DerivedMapper };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import{useSyncExternalStore as r}from"react";function t(t){return r(t.subscribe.bind(t),()=>t.state)}export{t as useModelState};
1
+ import{useSyncExternalStore as t}from"react";function r(r){return t(r.subscribe.bind(r),()=>r.state)}const e=(t,e)=>e(r(t)),n={},o=t=>{let r=n,e=n;return o=>r!==n&&Object.is(r,o)?e:e=t(r=o)};export{o as derived,e as useDerivedState,r as useModelState};
2
2
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/useModelState.ts"],"sourcesContent":["import { useSyncExternalStore } from \"react\";\n\n/**\n * Function that gets called when the state changes.\n *\n * @template T - The state type\n * @param state - The new state\n */\nexport type ViewModelListener = () => void;\n\n/**\n * Interface for a ViewModel that manages state and notifies subscribers of changes.\n *\n * ViewModels provide a way to manage component state outside of the component tree,\n * allowing multiple components to share and react to the same state. They follow\n * the observer pattern, where components can subscribe to state changes and receive\n * notifications when updates occur.\n *\n * @template T - The type of state managed by this ViewModel\n *\n * @example\n * ```typescript\n * // Implementing a ViewModel\n * class CounterViewModel extends ViewModel<{ count: number }> {\n * increment = () => super.update({ count: super.state.count + 1 });\n * decrement = () => super.update(({ count: super.state.count - 1 }));\n * }\n *\n * // Using in a component\n * const counterModel = new CounterViewModel({ count: 0 });\n * const { count } = useModelState(counterModel);\n * ```\n */\nexport interface ViewModel<T> {\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\n /**\n * Get the current state.\n *\n * @returns The current state\n */\n get state(): T;\n}\n\n/**\n * A React hook that subscribes a component to a ViewModel's state updates.\n *\n * This hook connects a React component to a ViewModel instance, automatically\n * subscribing to state changes and triggering re-renders when the state updates.\n *\n * @template T - The state type, which must extend the State interface\n * @param model - The ViewModel instance to subscribe to\n * @returns The current state from the ViewModel\n *\n * @example\n * ```tsx\n * class CounterViewModel extends ViewModel<{ count: number }> {\n * increment = () => super.update({ count: super.state.count + 1 });\n * }\n *\n * function Counter() {\n * const counterModel = useMemo(() => new CounterViewModel({ count: 0 }), []);\n * const { count } = useModelState(counterModel);\n *\n * return (\n * <div>\n * <p>Count: {count}</p>\n * <button onClick={counterModel.increment}>+</button>\n * </div>\n * );\n * }\n * ```\n */\nexport function useModelState<T>(model: ViewModel<T>): T {\n return useSyncExternalStore(model.subscribe.bind(model), () => model.state);\n}\n"],"names":["useModelState","model","useSyncExternalStore","subscribe","bind","state"],"mappings":"6CA2FM,SAAUA,EAAiBC,GAC/B,OAAOC,EAAqBD,EAAME,UAAUC,KAAKH,GAAQ,IAAMA,EAAMI,MACvE"}
1
+ {"version":3,"file":"index.js","sources":["../src/useModelState.ts","../src/useDerivedState.ts","../src/derived.ts"],"sourcesContent":["import { useSyncExternalStore } from \"react\";\nimport { ViewModel } from \"./ViewModel\";\n\n/**\n * A React hook that subscribes a component to a ViewModel's state updates.\n *\n * This hook connects a React component to a ViewModel instance, automatically\n * subscribing to state changes and triggering re-renders when the state updates.\n *\n * @template T - The state type, which must extend the State interface\n * @param model - The ViewModel instance to subscribe to\n * @returns The current state from the ViewModel\n *\n * @example\n * ```tsx\n * class CounterViewModel extends ViewModel<{ count: number }> {\n * increment = () => super.update({ count: super.state.count + 1 });\n * }\n *\n * function Counter() {\n * const counterModel = useMemo(() => new CounterViewModel({ count: 0 }), []);\n * const { count } = useModelState(counterModel);\n *\n * return (\n * <div>\n * <p>Count: {count}</p>\n * <button onClick={counterModel.increment}>+</button>\n * </div>\n * );\n * }\n * ```\n */\nexport function useModelState<T>(model: ViewModel<T>): T {\n return useSyncExternalStore(model.subscribe.bind(model), () => model.state);\n}\n","import { useModelState } from \"./useModelState\";\nimport { ViewModel } from \"./ViewModel\";\nimport { DerivedMapper } from \"./derived\";\n\n/**\n * A React hook that subscribes to a ViewModel and returns derived state.\n *\n * This hook combines `useModelState` with a derived mapper function to compute derived values\n * from the model's state. The component will re-render whenever the model state changes.\n *\n * The mapper must be created using the `derived` utility, which provides memoization\n * based on state identity to prevent unnecessary recalculations.\n *\n * @template S - The model state type\n * @template D - The derived state type\n * @param model - The ViewModel instance to subscribe to\n * @param mapper - A derived mapper function created with the `derived` utility\n * @returns The derived state computed from the current model state\n *\n * @example\n * ```tsx\n * import { ViewModel } from \"@view-models/core\";\n * import { useDerivedState, derived } from \"@view-models/react\";\n *\n * type TodoState = {\n * items: Array<{ id: string; text: string; completed: boolean }>;\n * };\n *\n * const todoModel = new ViewModel<TodoState>({ items: [] });\n *\n * // Create a derived mapper function\n * const selectCompletedCount = derived(({ items }: TodoState) => ({\n * completed: items.filter(item => item.completed).length,\n * total: items.length\n * }));\n *\n * function TodoStats() {\n * const stats = useDerivedState(todoModel, selectCompletedCount);\n * return <div>{stats.completed} of {stats.total} completed</div>;\n * }\n * ```\n */\nexport const useDerivedState = <S, D>(\n model: ViewModel<S>,\n mapper: DerivedMapper<S, D>,\n) => mapper(useModelState(model));\n","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 * This is the required way to create mapper functions for use with `useDerivedState`.\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 = useDerivedState(model, selectStats);\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"],"names":["useModelState","model","useSyncExternalStore","subscribe","bind","state","useDerivedState","mapper","UNINITIALIZED","derived","fn","lastInput","lastOutput","input","Object","is"],"mappings":"6CAgCM,SAAUA,EAAiBC,GAC/B,OAAOC,EAAqBD,EAAME,UAAUC,KAAKH,GAAQ,IAAMA,EAAMI,MACvE,CCQO,MAAMC,EAAkB,CAC7BL,EACAM,IACGA,EAAOP,EAAcC,ICpCpBO,EAAgB,CAAA,EA2BTC,EAAiBC,IAC5B,IAAIC,EAAsCH,EACtCI,EAAuCJ,EAC3C,OAASK,GACHF,IAAcH,GAAiBM,OAAOC,GAAGJ,EAAWE,GAC/CD,EAEDA,EAAaF,EAAIC,EAAYE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@view-models/react",
3
- "version": "1.3.0",
3
+ "version": "2.0.0",
4
4
  "description": "React integration for @view-models/core",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,7 +54,7 @@
54
54
  "@rollup/plugin-typescript": "^12.3.0",
55
55
  "@testing-library/react": "^16.1.0",
56
56
  "@types/react": "^18.3.18",
57
- "@view-models/core": "^3.0.0",
57
+ "@view-models/core": "^4.0.0",
58
58
  "@vitest/coverage-v8": "^2.1.8",
59
59
  "jsdom": "^26.0.0",
60
60
  "prettier": "^3.4.2",
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Function that gets called when the state changes.
3
+ *
4
+ * @template T - The state type
5
+ * @param state - The new state
6
+ */
7
+ export type ViewModelListener = () => void;
8
+
9
+ /**
10
+ * Interface for a ViewModel that manages state and notifies subscribers of changes.
11
+ *
12
+ * ViewModels provide a way to manage component state outside of the component tree,
13
+ * allowing multiple components to share and react to the same state. They follow
14
+ * the observer pattern, where components can subscribe to state changes and receive
15
+ * notifications when updates occur.
16
+ *
17
+ * @template T - The type of state managed by this ViewModel
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * // Implementing a ViewModel
22
+ * class CounterViewModel extends ViewModel<{ count: number }> {
23
+ * increment = () => super.update({ count: super.state.count + 1 });
24
+ * decrement = () => super.update(({ count: super.state.count - 1 }));
25
+ * }
26
+ *
27
+ * // Using in a component
28
+ * const counterModel = new CounterViewModel({ count: 0 });
29
+ * const { count } = useModelState(counterModel);
30
+ * ```
31
+ */
32
+ export interface ViewModel<T> {
33
+ /**
34
+ * Subscribe to state changes.
35
+ *
36
+ * The listener will be called immediately after any state update.
37
+ *
38
+ * @param listener - Function to call when state changes
39
+ * @returns Function to unsubscribe the listener
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * const unsubscribe = viewModel.subscribe((state) => {
44
+ * console.log('State changed:', state);
45
+ * });
46
+ *
47
+ * // Later, when you want to stop listening:
48
+ * unsubscribe();
49
+ * ```
50
+ */
51
+ subscribe(listener: ViewModelListener): () => void;
52
+
53
+ /**
54
+ * Get the current state.
55
+ *
56
+ * @returns The current state
57
+ */
58
+ get state(): T;
59
+ }
package/src/derived.ts ADDED
@@ -0,0 +1,46 @@
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
+ * This is the required way to create mapper functions for use with `useDerivedState`.
20
+ *
21
+ * @template I - The input type
22
+ * @template O - The output type
23
+ * @param fn - A pure function that transforms input to output
24
+ * @returns A memoized derived mapper function
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const selectStats = derived(({ items }: AppState) => ({
29
+ * total: items.reduce((sum, item) => sum + item.price, 0),
30
+ * count: items.length
31
+ * }));
32
+ *
33
+ * // Use with useDerivedState
34
+ * const stats = useDerivedState(model, selectStats);
35
+ * ```
36
+ */
37
+ export const derived = <I, O>(fn: Mapper<I, O>): DerivedMapper<I, O> => {
38
+ let lastInput: I | typeof UNINITIALIZED = UNINITIALIZED;
39
+ let lastOutput: O | typeof UNINITIALIZED = UNINITIALIZED;
40
+ return ((input: I) => {
41
+ if (lastInput !== UNINITIALIZED && Object.is(lastInput, input)) {
42
+ return lastOutput as O;
43
+ }
44
+ return (lastOutput = fn((lastInput = input)));
45
+ }) as DerivedMapper<I, O>;
46
+ };
package/src/index.ts CHANGED
@@ -1 +1,3 @@
1
1
  export * from "./useModelState.js";
2
+ export * from "./useDerivedState.js";
3
+ export * from "./derived.js";
@@ -0,0 +1,46 @@
1
+ import { useModelState } from "./useModelState";
2
+ import { ViewModel } from "./ViewModel";
3
+ import { DerivedMapper } from "./derived";
4
+
5
+ /**
6
+ * A React hook that subscribes to a ViewModel and returns derived state.
7
+ *
8
+ * This hook combines `useModelState` with a derived mapper function to compute derived values
9
+ * from the model's state. The component will re-render whenever the model state changes.
10
+ *
11
+ * The mapper must be created using the `derived` utility, which provides memoization
12
+ * based on state identity to prevent unnecessary recalculations.
13
+ *
14
+ * @template S - The model state type
15
+ * @template D - The derived state type
16
+ * @param model - The ViewModel instance to subscribe to
17
+ * @param mapper - A derived mapper function created with the `derived` utility
18
+ * @returns The derived state computed from the current model state
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * import { ViewModel } from "@view-models/core";
23
+ * import { useDerivedState, derived } from "@view-models/react";
24
+ *
25
+ * type TodoState = {
26
+ * items: Array<{ id: string; text: string; completed: boolean }>;
27
+ * };
28
+ *
29
+ * const todoModel = new ViewModel<TodoState>({ items: [] });
30
+ *
31
+ * // Create a derived mapper function
32
+ * const selectCompletedCount = derived(({ items }: TodoState) => ({
33
+ * completed: items.filter(item => item.completed).length,
34
+ * total: items.length
35
+ * }));
36
+ *
37
+ * function TodoStats() {
38
+ * const stats = useDerivedState(todoModel, selectCompletedCount);
39
+ * return <div>{stats.completed} of {stats.total} completed</div>;
40
+ * }
41
+ * ```
42
+ */
43
+ export const useDerivedState = <S, D>(
44
+ model: ViewModel<S>,
45
+ mapper: DerivedMapper<S, D>,
46
+ ) => mapper(useModelState(model));
@@ -1,64 +1,5 @@
1
1
  import { useSyncExternalStore } from "react";
2
-
3
- /**
4
- * Function that gets called when the state changes.
5
- *
6
- * @template T - The state type
7
- * @param state - The new state
8
- */
9
- export type ViewModelListener = () => void;
10
-
11
- /**
12
- * Interface for a ViewModel that manages state and notifies subscribers of changes.
13
- *
14
- * ViewModels provide a way to manage component state outside of the component tree,
15
- * allowing multiple components to share and react to the same state. They follow
16
- * the observer pattern, where components can subscribe to state changes and receive
17
- * notifications when updates occur.
18
- *
19
- * @template T - The type of state managed by this ViewModel
20
- *
21
- * @example
22
- * ```typescript
23
- * // Implementing a ViewModel
24
- * class CounterViewModel extends ViewModel<{ count: number }> {
25
- * increment = () => super.update({ count: super.state.count + 1 });
26
- * decrement = () => super.update(({ count: super.state.count - 1 }));
27
- * }
28
- *
29
- * // Using in a component
30
- * const counterModel = new CounterViewModel({ count: 0 });
31
- * const { count } = useModelState(counterModel);
32
- * ```
33
- */
34
- export interface ViewModel<T> {
35
- /**
36
- * Subscribe to state changes.
37
- *
38
- * The listener will be called immediately after any state update.
39
- *
40
- * @param listener - Function to call when state changes
41
- * @returns Function to unsubscribe the listener
42
- *
43
- * @example
44
- * ```typescript
45
- * const unsubscribe = viewModel.subscribe((state) => {
46
- * console.log('State changed:', state);
47
- * });
48
- *
49
- * // Later, when you want to stop listening:
50
- * unsubscribe();
51
- * ```
52
- */
53
- subscribe(listener: ViewModelListener): () => void;
54
-
55
- /**
56
- * Get the current state.
57
- *
58
- * @returns The current state
59
- */
60
- get state(): T;
61
- }
2
+ import { ViewModel } from "./ViewModel";
62
3
 
63
4
  /**
64
5
  * A React hook that subscribes a component to a ViewModel's state updates.