@montra-interactive/deepstate-react 0.2.4 → 0.2.6
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 +374 -0
- package/package.json +2 -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.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "React bindings for deepstate - Proxy-based reactive state management with RxJS.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"author": "Ronnie Magatti",
|
|
16
16
|
"license": "MIT",
|
|
17
|
+
"homepage": "https://github.com/Montra-Interactive/deepstate/tree/main/packages/react",
|
|
17
18
|
"repository": {
|
|
18
19
|
"type": "git",
|
|
19
20
|
"url": "https://github.com/Montra-Interactive/deepstate",
|