@view-models/react 1.3.0 → 3.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, derived } from "@view-models/core";
57
+ import { useDerivedState } 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,53 @@ 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
+ * A React hook that subscribes to a ViewModel and returns derived state.
101
+ *
102
+ * This hook combines `useModelState` with a derived mapper function to compute derived values
103
+ * from the model's state. The component will re-render whenever the model state changes.
104
+ *
105
+ * The mapper must be created using the `derived` utility, which provides memoization
106
+ * based on state identity to prevent unnecessary recalculations.
107
+ *
108
+ * @template S - The model state type
109
+ * @template D - The derived state type
110
+ * @param model - The ViewModel instance to subscribe to
111
+ * @param mapper - A derived mapper function created with the `derived` utility
112
+ * @returns The derived state computed from the current model state
113
+ *
114
+ * @example
115
+ * ```tsx
116
+ * import { ViewModel, derived } from "@view-models/core";
117
+ * import { useDerivedState } from "@view-models/react";
118
+ *
119
+ * type TodoState = {
120
+ * items: Array<{ id: string; text: string; completed: boolean }>;
121
+ * };
122
+ *
123
+ * const todoModel = new ViewModel<TodoState>({ items: [] });
124
+ *
125
+ * // Create a derived mapper function
126
+ * const selectCompletedCount = derived(({ items }: TodoState) => ({
127
+ * completed: items.filter(item => item.completed).length,
128
+ * total: items.length
129
+ * }));
130
+ *
131
+ * function TodoStats() {
132
+ * const stats = useDerivedState(todoModel, selectCompletedCount);
133
+ * return <div>{stats.completed} of {stats.total} completed</div>;
134
+ * }
135
+ * ```
136
+ */
137
+ declare const useDerivedState: <S, D>(model: ViewModel<S>, mapper: DerivedMapper<S, D>) => D;
138
+
139
+ export { useDerivedState, useModelState };
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 r}from"react";function t(t){return r(t.subscribe.bind(t),()=>t.state)}const o=(r,o)=>o(t(r));export{o as useDerivedState,t 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"],"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\";\n\ntype 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 */\ntype DerivedMapper<I, O> = Mapper<I, O> & { __brand: \"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, derived } from \"@view-models/core\";\n * import { useDerivedState } 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"],"names":["useModelState","model","useSyncExternalStore","subscribe","bind","state","useDerivedState","mapper"],"mappings":"6CAgCM,SAAUA,EAAiBC,GAC/B,OAAOC,EAAqBD,EAAME,UAAUC,KAAKH,GAAQ,IAAMA,EAAMI,MACvE,CCgBO,MAAMC,EAAkB,CAC7BL,EACAM,IACGA,EAAOP,EAAcC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@view-models/react",
3
- "version": "1.3.0",
3
+ "version": "3.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.1.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/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from "./useModelState.js";
2
+ export * from "./useDerivedState.js";
@@ -0,0 +1,54 @@
1
+ import { useModelState } from "./useModelState";
2
+ import { ViewModel } from "./ViewModel";
3
+
4
+ type Mapper<I, O> = (input: I) => O;
5
+
6
+ /**
7
+ * A branded type for derived state mapper functions created by the `derived` utility.
8
+ * This type ensures that mapper functions are properly memoized before being used
9
+ * with hooks like `useDerivedState`.
10
+ */
11
+ type DerivedMapper<I, O> = Mapper<I, O> & { __brand: "derived" };
12
+
13
+ /**
14
+ * A React hook that subscribes to a ViewModel and returns derived state.
15
+ *
16
+ * This hook combines `useModelState` with a derived mapper function to compute derived values
17
+ * from the model's state. The component will re-render whenever the model state changes.
18
+ *
19
+ * The mapper must be created using the `derived` utility, which provides memoization
20
+ * based on state identity to prevent unnecessary recalculations.
21
+ *
22
+ * @template S - The model state type
23
+ * @template D - The derived state type
24
+ * @param model - The ViewModel instance to subscribe to
25
+ * @param mapper - A derived mapper function created with the `derived` utility
26
+ * @returns The derived state computed from the current model state
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * import { ViewModel, derived } from "@view-models/core";
31
+ * import { useDerivedState } from "@view-models/react";
32
+ *
33
+ * type TodoState = {
34
+ * items: Array<{ id: string; text: string; completed: boolean }>;
35
+ * };
36
+ *
37
+ * const todoModel = new ViewModel<TodoState>({ items: [] });
38
+ *
39
+ * // Create a derived mapper function
40
+ * const selectCompletedCount = derived(({ items }: TodoState) => ({
41
+ * completed: items.filter(item => item.completed).length,
42
+ * total: items.length
43
+ * }));
44
+ *
45
+ * function TodoStats() {
46
+ * const stats = useDerivedState(todoModel, selectCompletedCount);
47
+ * return <div>{stats.completed} of {stats.total} completed</div>;
48
+ * }
49
+ * ```
50
+ */
51
+ export const useDerivedState = <S, D>(
52
+ model: ViewModel<S>,
53
+ mapper: DerivedMapper<S, D>,
54
+ ) => 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.