@montra-interactive/deepstate-react 0.2.4 → 0.2.5

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.
Files changed (2) hide show
  1. package/README.md +374 -0
  2. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,374 @@
1
+ # @montra-interactive/deepstate-react
2
+
3
+ React bindings for [deepstate](https://www.npmjs.com/package/@montra-interactive/deepstate) - proxy-based reactive state management with RxJS.
4
+
5
+ ## Features
6
+
7
+ - **Fine-grained subscriptions**: Subscribe to any nested property
8
+ - **Concurrent mode safe**: Uses `useSyncExternalStore` for React 18+
9
+ - **Type-safe**: Full TypeScript support with inferred types
10
+ - **RxJS integration**: Use `usePipeSelect` for debouncing, filtering, mapping
11
+ - **Multiple node combining**: Array form (tuple) or object form (named keys)
12
+ - **Custom equality**: Prevent unnecessary re-renders with custom comparators
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @montra-interactive/deepstate @montra-interactive/deepstate-react rxjs
18
+ # or
19
+ bun add @montra-interactive/deepstate @montra-interactive/deepstate-react rxjs
20
+ # or
21
+ yarn add @montra-interactive/deepstate @montra-interactive/deepstate-react rxjs
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ```tsx
27
+ import { state } from "@montra-interactive/deepstate";
28
+ import { useSelect } from "@montra-interactive/deepstate-react";
29
+
30
+ // Create your store
31
+ const store = state({
32
+ user: { name: "Alice", age: 30 },
33
+ count: 0,
34
+ });
35
+
36
+ // Use in components
37
+ function UserName() {
38
+ const name = useSelect(store.user.name);
39
+ return <span>{name}</span>;
40
+ }
41
+
42
+ function Counter() {
43
+ const count = useSelect(store.count);
44
+ return (
45
+ <button onClick={() => store.count.set(count + 1)}>
46
+ Count: {count}
47
+ </button>
48
+ );
49
+ }
50
+ ```
51
+
52
+ ## API Reference
53
+
54
+ ### `useSelect` - Subscribe to Deepstate Nodes
55
+
56
+ The primary hook for using deepstate in React. Returns the current value and re-renders when it changes.
57
+
58
+ #### Single Node
59
+
60
+ ```tsx
61
+ const value = useSelect(store.user.name); // string
62
+ const user = useSelect(store.user); // { name: string, age: number }
63
+ ```
64
+
65
+ #### With Selector
66
+
67
+ Transform the value before returning. Only re-renders when the derived value changes.
68
+
69
+ ```tsx
70
+ const fullName = useSelect(
71
+ store.user,
72
+ user => `${user.firstName} ${user.lastName}`
73
+ );
74
+
75
+ const adultCount = useSelect(
76
+ store.users,
77
+ users => users.filter(u => u.age >= 18).length
78
+ );
79
+ ```
80
+
81
+ #### Multiple Nodes (Array Form)
82
+
83
+ Combine multiple nodes into a single derived value:
84
+
85
+ ```tsx
86
+ const percentage = useSelect(
87
+ [store.stats.completed, store.stats.total],
88
+ ([completed, total]) => total > 0 ? (completed / total) * 100 : 0
89
+ );
90
+ ```
91
+
92
+ #### Multiple Nodes (Object Form)
93
+
94
+ Same as array form, but with named keys:
95
+
96
+ ```tsx
97
+ const summary = useSelect(
98
+ {
99
+ name: store.user.name,
100
+ completed: store.stats.completed
101
+ },
102
+ ({ name, completed }) => `${name} completed ${completed} tasks`
103
+ );
104
+ ```
105
+
106
+ #### Custom Equality Function
107
+
108
+ Prevent re-renders with a custom equality check:
109
+
110
+ ```tsx
111
+ const ids = useSelect(
112
+ store.items,
113
+ items => items.map(i => i.id),
114
+ // Custom array equality
115
+ (a, b) => a.length === b.length && a.every((v, i) => v === b[i])
116
+ );
117
+ ```
118
+
119
+ ### `usePipeSelect` - Subscribe to Piped Observables
120
+
121
+ For observables transformed with RxJS operators. Returns `T | undefined` because the stream might not have emitted yet.
122
+
123
+ #### Debouncing
124
+
125
+ Reduce re-renders from high-frequency updates:
126
+
127
+ ```tsx
128
+ import { debounceTime } from "rxjs";
129
+
130
+ function DebouncedSearch() {
131
+ const query = usePipeSelect(
132
+ store.searchQuery.pipe(debounceTime(300))
133
+ );
134
+
135
+ if (query === undefined) {
136
+ return <span>Type to search...</span>;
137
+ }
138
+
139
+ return <SearchResults query={query} />;
140
+ }
141
+ ```
142
+
143
+ #### Filtering
144
+
145
+ Only emit when conditions are met:
146
+
147
+ ```tsx
148
+ import { filter } from "rxjs";
149
+
150
+ function PositiveOnly() {
151
+ const value = usePipeSelect(
152
+ store.count.pipe(filter(v => v > 0))
153
+ );
154
+
155
+ // undefined until count > 0
156
+ return <span>{value ?? "Waiting for positive..."}</span>;
157
+ }
158
+ ```
159
+
160
+ #### Mapping / Transforming
161
+
162
+ Transform values in the stream:
163
+
164
+ ```tsx
165
+ import { map } from "rxjs";
166
+
167
+ function TotalDuration() {
168
+ const total = usePipeSelect(
169
+ store.clips.pipe(
170
+ map(clips => clips.reduce((sum, c) => sum + c.duration, 0))
171
+ )
172
+ );
173
+
174
+ return <span>Total: {total ?? 0}ms</span>;
175
+ }
176
+ ```
177
+
178
+ #### Combined Operators
179
+
180
+ Chain multiple operators:
181
+
182
+ ```tsx
183
+ import { debounceTime, filter, map } from "rxjs";
184
+
185
+ function SmartSearch() {
186
+ const query = usePipeSelect(
187
+ store.searchQuery.pipe(
188
+ debounceTime(300),
189
+ filter(q => q.length >= 2),
190
+ map(q => q.trim().toLowerCase())
191
+ )
192
+ );
193
+
194
+ if (query === undefined) {
195
+ return <span>Type at least 2 characters...</span>;
196
+ }
197
+
198
+ return <SearchResults query={query} />;
199
+ }
200
+ ```
201
+
202
+ ### `useObservable` - Low-level Observable Hook
203
+
204
+ For any RxJS Observable when you need to provide the initial value getter:
205
+
206
+ ```tsx
207
+ import { BehaviorSubject } from "rxjs";
208
+
209
+ const count$ = new BehaviorSubject(0);
210
+
211
+ function Counter() {
212
+ const count = useObservable(count$, () => count$.getValue());
213
+ return <span>{count}</span>;
214
+ }
215
+ ```
216
+
217
+ ## Why Two Hooks?
218
+
219
+ ### The Sync/Async Boundary
220
+
221
+ deepstate is a **synchronous store** backed by **reactive streams**:
222
+
223
+ - `useSelect(store.x)` - Node has `.get()`, initial value always available. Returns `T`.
224
+ - `usePipeSelect(store.x.pipe(...))` - Piped stream has no sync value. Returns `T | undefined`.
225
+
226
+ When you `.pipe()` a node, you enter the async world of RxJS where:
227
+
228
+ | Operator | Why No Sync Value? |
229
+ |----------|-------------------|
230
+ | `debounceTime(300)` | Waits 300ms before emitting |
231
+ | `filter(v => v > 0)` | If value is `0`, nothing passed yet |
232
+ | `switchMap(...)` | Depends on async operation |
233
+
234
+ The `T | undefined` return type is **honest** - it forces you to handle the "not yet" case:
235
+
236
+ ```tsx
237
+ // useSelect - always has value
238
+ const count = useSelect(store.count);
239
+ const doubled = count * 2; // Safe
240
+
241
+ // usePipeSelect - might be undefined
242
+ const filtered = usePipeSelect(store.count.pipe(filter(v => v > 0)));
243
+ const doubled = (filtered ?? 0) * 2; // Must handle undefined
244
+ ```
245
+
246
+ ## Type Exports
247
+
248
+ ```ts
249
+ import type { DeepstateNode } from "@montra-interactive/deepstate-react";
250
+ ```
251
+
252
+ | Type | Description |
253
+ |------|-------------|
254
+ | `DeepstateNode<T>` | Observable with `.get()` - what `useSelect` accepts |
255
+
256
+ ## Full Type Signatures
257
+
258
+ ```ts
259
+ // useSelect overloads
260
+ function useSelect<T>(node: DeepstateNode<T>): T;
261
+
262
+ function useSelect<T, R>(
263
+ node: DeepstateNode<T>,
264
+ selector: (value: T) => R,
265
+ equalityFn?: (a: R, b: R) => boolean
266
+ ): R;
267
+
268
+ function useSelect<T1, T2, R>(
269
+ nodes: [DeepstateNode<T1>, DeepstateNode<T2>],
270
+ selector: (values: [T1, T2]) => R,
271
+ equalityFn?: (a: R, b: R) => boolean
272
+ ): R;
273
+
274
+ // ... up to 5 nodes supported
275
+
276
+ function useSelect<T extends Record<string, DeepstateNode<unknown>>, R>(
277
+ nodes: T,
278
+ selector: (values: { [K in keyof T]: /* inferred */ }) => R,
279
+ equalityFn?: (a: R, b: R) => boolean
280
+ ): R;
281
+
282
+ // usePipeSelect
283
+ function usePipeSelect<T>(piped$: Observable<T>): T | undefined;
284
+
285
+ // useObservable
286
+ function useObservable<T>(
287
+ observable$: Observable<T>,
288
+ getSnapshot: () => T
289
+ ): T;
290
+ ```
291
+
292
+ ## Common Patterns
293
+
294
+ ### Debounced Search Input
295
+
296
+ ```tsx
297
+ function SearchBox() {
298
+ // Controlled input - immediate updates
299
+ const rawQuery = useSelect(store.searchQuery);
300
+
301
+ // Debounced for expensive operations
302
+ const debouncedQuery = usePipeSelect(
303
+ store.searchQuery.pipe(debounceTime(300))
304
+ );
305
+
306
+ return (
307
+ <div>
308
+ <input
309
+ value={rawQuery}
310
+ onChange={e => store.searchQuery.set(e.target.value)}
311
+ />
312
+ {debouncedQuery !== undefined && (
313
+ <SearchResults query={debouncedQuery} />
314
+ )}
315
+ </div>
316
+ );
317
+ }
318
+ ```
319
+
320
+ ### Computing Totals
321
+
322
+ ```tsx
323
+ function CartTotal() {
324
+ const total = usePipeSelect(
325
+ store.cart.items.pipe(
326
+ map(items => items.reduce((sum, i) => sum + i.price * i.qty, 0))
327
+ )
328
+ );
329
+
330
+ return <span>${(total ?? 0).toFixed(2)}</span>;
331
+ }
332
+ ```
333
+
334
+ ### Conditional Rendering
335
+
336
+ ```tsx
337
+ function ValidUser() {
338
+ const user = usePipeSelect(
339
+ store.user.pipe(filter(u => u.name.length > 0))
340
+ );
341
+
342
+ if (user === undefined) {
343
+ return <span>Please enter your name</span>;
344
+ }
345
+
346
+ return <Profile user={user} />;
347
+ }
348
+ ```
349
+
350
+ ### Preventing Re-renders
351
+
352
+ ```tsx
353
+ // Only re-render when age changes, not name
354
+ function UserAge() {
355
+ const age = useSelect(store.user, u => u.age);
356
+ return <span>{age}</span>;
357
+ }
358
+
359
+ // Or subscribe directly to the property
360
+ function UserAge() {
361
+ const age = useSelect(store.user.age);
362
+ return <span>{age}</span>;
363
+ }
364
+ ```
365
+
366
+ ## Peer Dependencies
367
+
368
+ - `react` ^18 || ^19
369
+ - `rxjs` ^7
370
+ - `@montra-interactive/deepstate` ^0.2.0
371
+
372
+ ## License
373
+
374
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montra-interactive/deepstate-react",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "React bindings for deepstate - Proxy-based reactive state management with RxJS.",
5
5
  "keywords": [
6
6
  "react",