react-shared-states 1.0.7 → 1.0.11
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/.editorconfig +3 -0
- package/README.md +519 -70
- package/dist/SharedValuesManager.d.ts +73 -0
- package/dist/context/SharedStatesContext.d.ts +5 -4
- package/dist/hooks/index.d.ts +6 -3
- package/dist/hooks/use-shared-function.d.ts +44 -17
- package/dist/hooks/use-shared-state.d.ts +24 -4
- package/dist/hooks/use-shared-subscription.d.ts +51 -22
- package/dist/lib/utils.d.ts +2 -2
- package/dist/main.esm.js +411 -355
- package/dist/main.min.js +5 -5
- package/dist/types.d.ts +6 -2
- package/package.json +6 -2
- package/tests/index.test.tsx +526 -0
- package/vitest.config.ts +8 -0
- package/dist/SharedData.d.ts +0 -61
package/README.md
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
# React Shared States
|
|
3
2
|
|
|
4
3
|
**_Global state made as simple as useState, with zero config, built-in async caching, and automatic scoping._**
|
|
@@ -48,7 +47,6 @@ function B(){
|
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
function App() {
|
|
51
|
-
|
|
52
50
|
return (
|
|
53
51
|
<>
|
|
54
52
|
<A/>
|
|
@@ -69,7 +67,6 @@ function Scoped(){
|
|
|
69
67
|
}
|
|
70
68
|
|
|
71
69
|
function App() {
|
|
72
|
-
|
|
73
70
|
return (
|
|
74
71
|
<>
|
|
75
72
|
<A/>
|
|
@@ -82,6 +79,33 @@ function App() {
|
|
|
82
79
|
}
|
|
83
80
|
```
|
|
84
81
|
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
> **Tip:** For large apps, you can also use `createSharedState(initialValue, scopeName?)` to create and export reusable shared states. If you specify a `scopeName`, the state will always be found in that scope; otherwise, it defaults to global. This helps avoid key collisions and ensures type safety.
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
import { useSharedState } from 'react-shared-states';
|
|
88
|
+
export const sharedCounter = createSharedState(0);
|
|
89
|
+
|
|
90
|
+
function A(){
|
|
91
|
+
const [count, setCount] = useSharedState(sharedCounter);
|
|
92
|
+
return <button onClick={()=>setCount(c=>c+1)}>A {count}</button>;
|
|
93
|
+
}
|
|
94
|
+
function B(){
|
|
95
|
+
const [count] = useSharedState(sharedCounter);
|
|
96
|
+
return <span>B sees {count}</span>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function App() {
|
|
100
|
+
return (
|
|
101
|
+
<>
|
|
102
|
+
<A/>
|
|
103
|
+
<B/>
|
|
104
|
+
</>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
85
109
|
Override / jump to a named scope explicitly:
|
|
86
110
|
```tsx
|
|
87
111
|
useSharedState('counter', 0, 'modal'); // 3rd arg is scopeName override
|
|
@@ -173,56 +197,69 @@ export default function App(){
|
|
|
173
197
|
|
|
174
198
|
|
|
175
199
|
## 🧠 Core Concepts
|
|
176
|
-
| Concept
|
|
177
|
-
|
|
178
|
-
| Global by default
|
|
179
|
-
| Scoping
|
|
180
|
-
| Named scopes
|
|
181
|
-
| Manual override
|
|
182
|
-
| Shared functions
|
|
183
|
-
| Shared subscriptions
|
|
184
|
-
| Static APIs
|
|
200
|
+
| Concept | Summary |
|
|
201
|
+
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
202
|
+
| Global by default | No provider necessary. Same key => shared state. |
|
|
203
|
+
| Scoping | Wrap with `SharedStatesProvider` to isolate. Nearest provider wins. |
|
|
204
|
+
| Named scopes | `scopeName` prop lets distant providers sync (same name ⇒ same bucket). Unnamed providers auto‑generate a random isolated name. |
|
|
205
|
+
| Manual override | Third param in `useSharedState` / `useSharedFunction` / `useSharedSubscription` enforces a specific scope ignoring tree search. |
|
|
206
|
+
| Shared functions | Encapsulate async logic: single flight + cached result + `error` + `isLoading` + opt‑in refresh. |
|
|
207
|
+
| Shared subscriptions | Real-time data streams: automatic cleanup + shared connections + `error` + `isLoading` + subscription state. |
|
|
208
|
+
| Static APIs | Access state/functions/subscriptions outside components (`sharedStatesApi`, `sharedFunctionsApi`, `sharedSubscriptionsApi`). |
|
|
209
|
+
| Static/shared creation | Use `createSharedState`, `createSharedFunction`, `createSharedSubscription` to export reusable, type-safe shared resources that persist across `clearAll()` calls. |
|
|
185
210
|
|
|
186
211
|
|
|
187
212
|
## 🏗️ Sharing State (`useSharedState`)
|
|
188
|
-
Signature:
|
|
213
|
+
Signature:
|
|
214
|
+
- `const [value, setValue] = useSharedState(key, initialValue, scopeName?)`
|
|
215
|
+
- `const [value, setValue] = useSharedState(sharedStateCreated)`
|
|
189
216
|
|
|
190
217
|
Behavior:
|
|
191
218
|
* First hook call (per key + scope) seeds with `initialValue`.
|
|
192
219
|
* Subsequent mounts with same key+scope ignore their `initialValue` (consistent source of truth).
|
|
193
220
|
* Setter accepts either value or updater `(prev)=>next`.
|
|
194
221
|
* React batching + equality check: listeners fire only when the value reference actually changes.
|
|
222
|
+
* States created with `createSharedState` are **static** by default and are not removed by `clear()` or `clearAll()`, ensuring they persist.
|
|
195
223
|
|
|
196
224
|
### Examples
|
|
197
|
-
1. Global theme
|
|
225
|
+
1. Global theme (recommended for large apps)
|
|
198
226
|
```tsx
|
|
199
|
-
|
|
227
|
+
// themeState.ts
|
|
228
|
+
export const themeState = createSharedState('light');
|
|
229
|
+
// In components
|
|
230
|
+
const [theme, setTheme] = useSharedState(themeState);
|
|
200
231
|
```
|
|
201
232
|
2. Isolated wizard progress
|
|
202
233
|
```tsx
|
|
234
|
+
const wizardProgress = createSharedState(0);
|
|
203
235
|
<SharedStatesProvider>
|
|
204
236
|
<Wizard/>
|
|
205
237
|
</SharedStatesProvider>
|
|
238
|
+
// In Wizard
|
|
239
|
+
const [step, setStep] = useSharedState(wizardProgress);
|
|
206
240
|
```
|
|
207
241
|
3. Forcing cross‑portal sync
|
|
208
242
|
```tsx
|
|
243
|
+
const navState = createSharedState('closed', 'nav');
|
|
209
244
|
<SharedStatesProvider scopeName="nav" children={<PrimaryNav/>} />
|
|
210
245
|
<Portal>
|
|
211
246
|
<SharedStatesProvider scopeName="nav" children={<MobileNav/>} />
|
|
212
247
|
</Portal>
|
|
248
|
+
// In both navs
|
|
249
|
+
const [navOpen, setNavOpen] = useSharedState(navState);
|
|
213
250
|
```
|
|
214
251
|
4. Overriding nearest provider
|
|
215
252
|
```tsx
|
|
216
253
|
// Even if inside a provider, this explicitly binds to global
|
|
217
|
-
const
|
|
254
|
+
const globalFlag = createSharedState(false, '_global');
|
|
255
|
+
const [flag, setFlag] = useSharedState(globalFlag);
|
|
218
256
|
```
|
|
219
257
|
|
|
220
258
|
|
|
221
259
|
## ⚡ Shared Async Functions (`useSharedFunction`)
|
|
222
260
|
Signature:
|
|
223
|
-
|
|
224
|
-
const { state, trigger, forceTrigger, clear } = useSharedFunction(
|
|
225
|
-
```
|
|
261
|
+
- `const { state, trigger, forceTrigger, clear } = useSharedFunction(key, asyncFn, scopeName?)`
|
|
262
|
+
- `const { state, trigger, forceTrigger, clear } = useSharedFunction(sharedFunctionCreated)`
|
|
226
263
|
`state` shape: `{ results?: T; isLoading: boolean; error?: unknown }`
|
|
227
264
|
|
|
228
265
|
Semantics:
|
|
@@ -230,15 +267,16 @@ Semantics:
|
|
|
230
267
|
* Multiple components with the same key+scope share one execution + result.
|
|
231
268
|
* `clear()` deletes the cache (next trigger re-runs).
|
|
232
269
|
* You decide when to invoke `trigger` (e.g. on mount, on button click, when dependencies change, etc.).
|
|
270
|
+
* Functions created with `createSharedFunction` are **static** and persist across `clearAll()` calls.
|
|
233
271
|
|
|
234
272
|
### Pattern: lazy load on first render
|
|
235
273
|
```tsx
|
|
274
|
+
// profileFunction.ts
|
|
275
|
+
export const profileFunction = createSharedFunction((id: string) => fetch(`/api/p/${id}`).then(r=>r.json()));
|
|
276
|
+
|
|
236
277
|
function Profile({id}:{id:string}){
|
|
237
|
-
const { state, trigger } = useSharedFunction(
|
|
238
|
-
|
|
239
|
-
if(!state.results && !state.isLoading) trigger();
|
|
240
|
-
if(state.isLoading) return <p>Loading...</p>;
|
|
241
|
-
return <pre>{JSON.stringify(state.results,null,2)}</pre>
|
|
278
|
+
const { state, trigger } = useSharedFunction(profileFunction);
|
|
279
|
+
// ...same as before
|
|
242
280
|
}
|
|
243
281
|
```
|
|
244
282
|
|
|
@@ -254,50 +292,425 @@ Perfect for Firebase listeners, WebSocket connections,
|
|
|
254
292
|
Server-Sent Events, or any streaming data source that needs cleanup.
|
|
255
293
|
|
|
256
294
|
Signature:
|
|
257
|
-
|
|
258
|
-
const { state, trigger, unsubscribe } = useSharedSubscription(
|
|
259
|
-
```
|
|
295
|
+
- `const { state, trigger, unsubscribe } = useSharedSubscription(key, subscriber, scopeName?)`
|
|
296
|
+
- `const { state, trigger, unsubscribe } = useSharedSubscription(sharedSubscriptionCreated)`
|
|
260
297
|
|
|
261
298
|
`state` shape: `{ data?: T; isLoading: boolean; error?: unknown; subscribed: boolean }`
|
|
262
299
|
|
|
263
300
|
The `subscriber` function receives three callbacks:
|
|
264
301
|
- `set(data)`: Update the shared data
|
|
265
|
-
- `
|
|
266
|
-
- `
|
|
302
|
+
- `error(error)`: Handle errors
|
|
303
|
+
- `complete()`: Mark loading as complete
|
|
267
304
|
- Returns: Optional cleanup function (called on unsubscribe/unmount)
|
|
268
305
|
|
|
269
306
|
### Pattern: Firebase Firestore real-time listener
|
|
270
307
|
```tsx
|
|
271
|
-
|
|
308
|
+
// userSubscription.ts
|
|
272
309
|
import { onSnapshot, doc } from 'firebase/firestore';
|
|
273
|
-
import {
|
|
274
|
-
import { db } from './firebase-config';
|
|
310
|
+
import { createSharedSubscription } from 'react-shared-states';
|
|
311
|
+
import { db } from './firebase-config';
|
|
312
|
+
|
|
313
|
+
export const userSubscription = createSharedSubscription(
|
|
314
|
+
(set, error, complete) => {
|
|
315
|
+
const userDocRef = doc(db, 'users', 'some-user-id');
|
|
316
|
+
const unsubscribe = onSnapshot(userDocRef,
|
|
317
|
+
(doc) => {
|
|
318
|
+
if (doc.exists()) {
|
|
319
|
+
set(doc.data());
|
|
320
|
+
} else {
|
|
321
|
+
error(new Error('User not found'));
|
|
322
|
+
}
|
|
323
|
+
complete();
|
|
324
|
+
},
|
|
325
|
+
(err) => {
|
|
326
|
+
error(err);
|
|
327
|
+
complete();
|
|
328
|
+
}
|
|
329
|
+
);
|
|
330
|
+
return unsubscribe;
|
|
331
|
+
}
|
|
332
|
+
);
|
|
275
333
|
|
|
276
334
|
function UserProfile({ userId }: { userId: string }) {
|
|
277
|
-
const { state, trigger, unsubscribe } = useSharedSubscription(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
335
|
+
const { state, trigger, unsubscribe } = useSharedSubscription(userSubscription);
|
|
336
|
+
// Start listening when component mounts
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
trigger();
|
|
339
|
+
}, []);
|
|
340
|
+
|
|
341
|
+
if (state.isLoading) return <div>Connecting...</div>;
|
|
342
|
+
if (state.error) return <div>Error: {state.error.message}</div>;
|
|
343
|
+
if (!state.data) return <div>User not found</div>;
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<div>
|
|
347
|
+
<h1>{state.data.name}</h1>
|
|
348
|
+
<p>{state.data.email}</p>
|
|
349
|
+
<button onClick={unsubscribe}>Stop listening</button>
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Pattern: WebSocket connection
|
|
356
|
+
```tsx
|
|
357
|
+
import { useEffect } from 'react';
|
|
358
|
+
import { useSharedSubscription } from 'react-shared-states';
|
|
359
|
+
|
|
360
|
+
function ChatRoom({ roomId }: { roomId: string }) {
|
|
361
|
+
const { state, trigger } = useSharedSubscription(
|
|
362
|
+
`chat-${roomId}`,
|
|
363
|
+
(set, error, complete) => {
|
|
364
|
+
const ws = new WebSocket(`ws://chat-server/${roomId}`);
|
|
365
|
+
|
|
366
|
+
ws.onopen = () => complete();
|
|
367
|
+
ws.onmessage = (event) => {
|
|
368
|
+
const message = JSON.parse(event.data);
|
|
369
|
+
set(prev => [...(prev || []), message]);
|
|
370
|
+
};
|
|
371
|
+
ws.onerror = error;
|
|
372
|
+
|
|
373
|
+
return () => ws.close();
|
|
374
|
+
}
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
trigger();
|
|
379
|
+
}, []);
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<div>
|
|
383
|
+
{state.isLoading && <p>Connecting to chat...</p>}
|
|
384
|
+
{state.error && <p>Connection failed</p>}
|
|
385
|
+
<div>
|
|
386
|
+
{state.data?.map(msg => (
|
|
387
|
+
<div key={msg.id}>{msg.text}</div>
|
|
388
|
+
))}
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Pattern: Server-Sent Events
|
|
396
|
+
```tsx
|
|
397
|
+
import { useEffect } from 'react';
|
|
398
|
+
import { useSharedSubscription } from 'react-shared-states';
|
|
399
|
+
|
|
400
|
+
function LiveUpdates() {
|
|
401
|
+
const { state, trigger } = useSharedSubscription(
|
|
402
|
+
'live-updates',
|
|
403
|
+
(set, error, complete) => {
|
|
404
|
+
const eventSource = new EventSource('/api/live-updates');
|
|
281
405
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
set({ id: snapshot.id, ...snapshot.data() });
|
|
288
|
-
} else {
|
|
289
|
-
set(null);
|
|
290
|
-
}
|
|
291
|
-
},
|
|
292
|
-
onError,
|
|
293
|
-
onCompletion
|
|
294
|
-
);
|
|
406
|
+
eventSource.onopen = () => complete();
|
|
407
|
+
eventSource.onmessage = (event) => {
|
|
408
|
+
set(JSON.parse(event.data));
|
|
409
|
+
};
|
|
410
|
+
eventSource.onerror = error;
|
|
295
411
|
|
|
296
|
-
|
|
297
|
-
return unsubscribe;
|
|
412
|
+
return () => eventSource.close();
|
|
298
413
|
}
|
|
299
414
|
);
|
|
300
415
|
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
trigger();
|
|
418
|
+
}, []);
|
|
419
|
+
|
|
420
|
+
return <div>Latest: {JSON.stringify(state.data)}</div>;
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Subscription semantics:
|
|
425
|
+
* First `trigger()` establishes the subscription; subsequent calls do nothing if already subscribed.
|
|
426
|
+
* Multiple components with the same key+scope share one subscription + data stream.
|
|
427
|
+
* `unsubscribe()` closes the connection and clears the subscribed state.
|
|
428
|
+
* Automatic cleanup on component unmount when no other components are listening.
|
|
429
|
+
* Components mounting later instantly get the latest `data` without re-subscribing.
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
## 🛰️ Static APIs (outside React)
|
|
433
|
+
## 🏛️ Static/Global Shared Resource Creation
|
|
434
|
+
|
|
435
|
+
For large apps, you can create and export shared state, function,
|
|
436
|
+
or subscription objects for type safety and to avoid key collisions.
|
|
437
|
+
This pattern is similar to Zustand or Jotai stores:
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { createSharedState, createSharedFunction, createSharedSubscription, useSharedState, useSharedFunction, useSharedSubscription } from 'react-shared-states';
|
|
441
|
+
|
|
442
|
+
// Create and export shared resources
|
|
443
|
+
export const counterState = createSharedState(0);
|
|
444
|
+
export const fetchUserFunction = createSharedFunction(() => fetch('/api/me').then(r => r.json()));
|
|
445
|
+
export const chatSubscription = createSharedSubscription((set, error, complete) => {/* ... */});
|
|
446
|
+
|
|
447
|
+
// Use anywhere in your app
|
|
448
|
+
const [count, setCount] = useSharedState(counterState);
|
|
449
|
+
const { state, trigger } = useSharedFunction(fetchUserFunction);
|
|
450
|
+
const { state, trigger, unsubscribe } = useSharedSubscription(chatSubscription);
|
|
451
|
+
```
|
|
452
|
+
Useful for SSR hydration, event listeners, debugging, imperative workflows.
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
import { sharedStatesApi, sharedFunctionsApi, sharedSubscriptionsApi } from 'react-shared-states';
|
|
456
|
+
|
|
457
|
+
// Preload state (global scope by default)
|
|
458
|
+
sharedStatesApi.set('bootstrap-data', { user: {...} });
|
|
459
|
+
|
|
460
|
+
// Preload state in a named scope
|
|
461
|
+
sharedStatesApi.set('bootstrap-data', { user: {...} }, 'myScope');
|
|
462
|
+
|
|
463
|
+
// Read later
|
|
464
|
+
const user = sharedStatesApi.get('bootstrap-data'); // global
|
|
465
|
+
const userScoped = sharedStatesApi.get('bootstrap-data', 'myScope');
|
|
466
|
+
|
|
467
|
+
// Inspect all (returns nested object: { [scope]: { [key]: value } })
|
|
468
|
+
console.log(sharedStatesApi.getAll());
|
|
469
|
+
|
|
470
|
+
// Clear all keys in a scope
|
|
471
|
+
sharedStatesApi.clearScope('myScope');
|
|
472
|
+
|
|
473
|
+
// For shared functions
|
|
474
|
+
const fnState = sharedFunctionsApi.get('profile-123');
|
|
475
|
+
const fnStateScoped = sharedFunctionsApi.get('profile-123', 'myScope');
|
|
476
|
+
|
|
477
|
+
// For shared subscriptions
|
|
478
|
+
const subState = sharedSubscriptionsApi.get('live-chat');
|
|
479
|
+
const subStateScoped = sharedSubscriptionsApi.get('live-chat', 'myScope');
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
## API summary:
|
|
483
|
+
|
|
484
|
+
| API | Methods |
|
|
485
|
+
|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
486
|
+
| `sharedStatesApi` | `get(key, scopeName?)`, `set(key, val, scopeName?)`, `has(key, scopeName?)`, `clear(key, scopeName?)`, `clearAll(withoutListeners?, withStatic?)`, `clearScope(scopeName?)`, `getAll()` |
|
|
487
|
+
| `sharedFunctionsApi` | `get(key, scopeName?)`, `set(key, val, scopeName?)`, `has(key, scopeName?)`, `clear(key, scopeName?)`, `clearAll(withoutListeners?, withStatic?)`, `clearScope(scopeName?)`, `getAll()` |
|
|
488
|
+
| `sharedSubscriptionsApi` | `get(key, scopeName?)`, `set(key, val, scopeName?)`, `has(key, scopeName?)`, `clear(key, scopeName?)`, `clearAll(withoutListeners?, withStatic?)`, `clearScope(scopeName?)`, `getAll()` |
|
|
489
|
+
|
|
490
|
+
`scopeName` defaults to `"_global"`. Internally, keys are stored as `${scope}//${key}`. The `.getAll()` method returns a nested object: `{ [scope]: { [key]: value } }`.
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
## 🧩 Scoping Rules Deep Dive
|
|
494
|
+
Resolution order used inside hooks:
|
|
495
|
+
1. Explicit 3rd parameter (`scopeName`)
|
|
496
|
+
2. Nearest `SharedStatesProvider` above the component
|
|
497
|
+
3. The implicit global scope (`_global`)
|
|
498
|
+
|
|
499
|
+
Unnamed providers auto‑generate a random scope name: each mount = isolated island.
|
|
500
|
+
|
|
501
|
+
Two providers sharing the same `scopeName` act as a single logical scope even if they are disjoint in the tree (great for portals / microfrontends).
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
## 🆚 Comparison Snapshot
|
|
505
|
+
| Criterion | react-shared-states | Redux Toolkit | Zustand |
|
|
506
|
+
|----------------|------------------------------------------|----------------------|----------------------------------|
|
|
507
|
+
| Setup | Install & call hook | Slice + store config | Create store function |
|
|
508
|
+
| Global state | Yes (by key) | Yes | Yes |
|
|
509
|
+
| Scoped state | Built-in (providers + names + overrides) | Needs custom logic | Needs multiple stores / contexts |
|
|
510
|
+
| Async helper | `useSharedFunction` (cache + status) | Thunks / RTK Query | Manual or middleware |
|
|
511
|
+
| Boilerplate | Near zero | Moderate | Low |
|
|
512
|
+
| Static access | Yes (APIs) | Yes (store) | Yes (store) |
|
|
513
|
+
| Learning curve | Minutes | Higher | Low |
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
## 🧪 Testing Tips
|
|
517
|
+
* Use static APIs to assert state after component interactions.
|
|
518
|
+
* `sharedStatesApi.clearAll(false, true)`, `sharedFunctionsApi.clearAll(false, true)`, `sharedSubscriptionsApi.clearAll(false, true)` in `afterEach` to isolate tests and clear static states.
|
|
519
|
+
* For async functions: trigger once, await UI stabilization, assert `results` present.
|
|
520
|
+
* For subscriptions: mock the subscription source (Firebase, WebSocket, etc.) and verify data flow.
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
## ❓ FAQ
|
|
524
|
+
**Q: How do I reset a single shared state?**
|
|
525
|
+
`sharedStatesApi.clear('key')`. If the state was created with `createSharedState`, it will reset to its initial value. Otherwise, it will be removed.
|
|
526
|
+
|
|
527
|
+
**Q: Can I pre-hydrate data on the server?**
|
|
528
|
+
Yes. Call `sharedStatesApi.set(...)` during bootstrap, then first client hook usage will pick it up.
|
|
529
|
+
|
|
530
|
+
**Q: How do I avoid accidental key collisions?**
|
|
531
|
+
Prefix keys by domain (e.g. `user:profile`, `cart:items`) or rely on provider scoping.
|
|
532
|
+
|
|
533
|
+
**Q: Why is my async function not re-running?**
|
|
534
|
+
It's cached. Use `forceTrigger()` or `clear()`.
|
|
535
|
+
|
|
536
|
+
**Q: How do I handle subscription cleanup?**
|
|
537
|
+
Subscriptions auto-cleanup when no components are listening. You can also manually call `unsubscribe()`.
|
|
538
|
+
|
|
539
|
+
**Q: Can I use it with Suspense?**
|
|
540
|
+
Currently no built-in Suspense wrappers; wrap `useSharedFunction` yourself if desired.
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
## 📚 Full API Reference
|
|
544
|
+
### `useSharedState(key, initialValue, scopeName?)`
|
|
545
|
+
Returns `[value, setValue]`.
|
|
546
|
+
|
|
547
|
+
### `useSharedState(sharedStateCreated)`
|
|
548
|
+
Returns `[value, setValue]`.
|
|
549
|
+
|
|
550
|
+
### `useSharedFunction(key, fn, scopeName?)`
|
|
551
|
+
Returns `{ state, trigger, forceTrigger, clear }`.
|
|
552
|
+
|
|
553
|
+
### `useSharedFunction(sharedFunctionCreated)`
|
|
554
|
+
Returns `{ state, trigger, forceTrigger, clear }`.
|
|
555
|
+
|
|
556
|
+
### `useSharedSubscription(key, subscriber, scopeName?)`
|
|
557
|
+
Returns `{ state, trigger, unsubscribe }`.
|
|
558
|
+
|
|
559
|
+
### `useSharedSubscription(sharedSubscriptionCreated)`
|
|
560
|
+
Returns `{ state, trigger, unsubscribe }`.
|
|
561
|
+
|
|
562
|
+
### `<SharedStatesProvider scopeName?>`
|
|
563
|
+
Wrap children; optional `scopeName` (string). If omitted a random unique one is generated.
|
|
564
|
+
|
|
565
|
+
### Static APIs
|
|
566
|
+
`sharedStatesApi`, `sharedFunctionsApi`, `sharedSubscriptionsApi` (see earlier table).
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
## 🤝 Contributions
|
|
571
|
+
|
|
572
|
+
We welcome contributions!
|
|
573
|
+
If you'd like to improve `react-shared-states`,
|
|
574
|
+
feel free to [open an issue](https://github.com/HichemTab-tech/react-shared-states/issues) or [submit a pull request](https://github.com/HichemTab-tech/react-shared-states/pulls).
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
## Author
|
|
578
|
+
|
|
579
|
+
- [@HichemTab-tech](https://www.github.com/HichemTab-tech)
|
|
580
|
+
|
|
581
|
+
## License
|
|
582
|
+
|
|
583
|
+
[MIT](https://github.com/HichemTab-tech/react-shared-states/blob/master/LICENSE)
|
|
584
|
+
|
|
585
|
+
## 🌟 Acknowledgements
|
|
586
|
+
|
|
587
|
+
Inspired by React's built-in primitives and the ergonomics of modern lightweight state libraries.
|
|
588
|
+
Thanks to early adopters for feedback.
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
## 🏗️ Sharing State (`useSharedState`)
|
|
593
|
+
Signature:
|
|
594
|
+
- `const [value, setValue] = useSharedState(key, initialValue, scopeName?)`
|
|
595
|
+
- `const [value, setValue] = useSharedState(sharedStateCreated)`
|
|
596
|
+
|
|
597
|
+
Behavior:
|
|
598
|
+
* First hook call (per key + scope) seeds with `initialValue`.
|
|
599
|
+
* Subsequent mounts with same key+scope ignore their `initialValue` (consistent source of truth).
|
|
600
|
+
* Setter accepts either value or updater `(prev)=>next`.
|
|
601
|
+
* React batching + equality check: listeners fire only when the value reference actually changes.
|
|
602
|
+
|
|
603
|
+
### Examples
|
|
604
|
+
1. Global theme (recommended for large apps)
|
|
605
|
+
```tsx
|
|
606
|
+
// themeState.ts
|
|
607
|
+
export const themeState = createSharedState('light');
|
|
608
|
+
// In components
|
|
609
|
+
const [theme, setTheme] = useSharedState(themeState);
|
|
610
|
+
```
|
|
611
|
+
2. Isolated wizard progress
|
|
612
|
+
```tsx
|
|
613
|
+
const wizardProgress = createSharedState(0);
|
|
614
|
+
<SharedStatesProvider>
|
|
615
|
+
<Wizard/>
|
|
616
|
+
</SharedStatesProvider>
|
|
617
|
+
// In Wizard
|
|
618
|
+
const [step, setStep] = useSharedState(wizardProgress);
|
|
619
|
+
```
|
|
620
|
+
3. Forcing cross‑portal sync
|
|
621
|
+
```tsx
|
|
622
|
+
const navState = createSharedState('closed', 'nav');
|
|
623
|
+
<SharedStatesProvider scopeName="nav" children={<PrimaryNav/>} />
|
|
624
|
+
<Portal>
|
|
625
|
+
<SharedStatesProvider scopeName="nav" children={<MobileNav/>} />
|
|
626
|
+
</Portal>
|
|
627
|
+
// In both navs
|
|
628
|
+
const [navOpen, setNavOpen] = useSharedState(navState);
|
|
629
|
+
```
|
|
630
|
+
4. Overriding nearest provider
|
|
631
|
+
```tsx
|
|
632
|
+
// Even if inside a provider, this explicitly binds to global
|
|
633
|
+
const globalFlag = createSharedState(false, '_global');
|
|
634
|
+
const [flag, setFlag] = useSharedState(globalFlag);
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
## ⚡ Shared Async Functions (`useSharedFunction`)
|
|
639
|
+
Signature:
|
|
640
|
+
- `const { state, trigger, forceTrigger, clear } = useSharedFunction(key, asyncFn, scopeName?)`
|
|
641
|
+
- `const { state, trigger, forceTrigger, clear } = useSharedFunction(sharedFunctionCreated)`
|
|
642
|
+
`state` shape: `{ results?: T; isLoading: boolean; error?: unknown }`
|
|
643
|
+
|
|
644
|
+
Semantics:
|
|
645
|
+
* First `trigger()` (implicit or manual) runs the function; subsequent calls do nothing while loading or after success (cached) unless you `forceTrigger()`.
|
|
646
|
+
* Multiple components with the same key+scope share one execution + result.
|
|
647
|
+
* `clear()` deletes the cache (next trigger re-runs).
|
|
648
|
+
* You decide when to invoke `trigger` (e.g. on mount, on button click, when dependencies change, etc.).
|
|
649
|
+
|
|
650
|
+
### Pattern: lazy load on first render
|
|
651
|
+
```tsx
|
|
652
|
+
// profileFunction.ts
|
|
653
|
+
export const profileFunction = createSharedFunction((id: string) => fetch(`/api/p/${id}`).then(r=>r.json()));
|
|
654
|
+
|
|
655
|
+
function Profile({id}:{id:string}){
|
|
656
|
+
const { state, trigger } = useSharedFunction(profileFunction);
|
|
657
|
+
// ...same as before
|
|
658
|
+
}
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
### Pattern: always fetch fresh
|
|
662
|
+
```tsx
|
|
663
|
+
const { state, forceTrigger } = useSharedFunction('server-time', () => fetch('/time').then(r=>r.text()));
|
|
664
|
+
const refresh = () => forceTrigger();
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
## 📡 Real-time Subscriptions (`useSharedSubscription`)
|
|
669
|
+
Perfect for Firebase listeners, WebSocket connections,
|
|
670
|
+
Server-Sent Events, or any streaming data source that needs cleanup.
|
|
671
|
+
|
|
672
|
+
Signature:
|
|
673
|
+
- `const { state, trigger, unsubscribe } = useSharedSubscription(key, subscriber, scopeName?)`
|
|
674
|
+
- `const { state, trigger, unsubscribe } = useSharedSubscription(sharedSubscriptionCreated)`
|
|
675
|
+
|
|
676
|
+
`state` shape: `{ data?: T; isLoading: boolean; error?: unknown; subscribed: boolean }`
|
|
677
|
+
|
|
678
|
+
The `subscriber` function receives three callbacks:
|
|
679
|
+
- `set(data)`: Update the shared data
|
|
680
|
+
- `error(error)`: Handle errors
|
|
681
|
+
- `complete()`: Mark loading as complete
|
|
682
|
+
- Returns: Optional cleanup function (called on unsubscribe/unmount)
|
|
683
|
+
|
|
684
|
+
### Pattern: Firebase Firestore real-time listener
|
|
685
|
+
```tsx
|
|
686
|
+
// userSubscription.ts
|
|
687
|
+
import { onSnapshot, doc } from 'firebase/firestore';
|
|
688
|
+
import { createSharedSubscription } from 'react-shared-states';
|
|
689
|
+
import { db } from './firebase-config';
|
|
690
|
+
|
|
691
|
+
export const userSubscription = createSharedSubscription(
|
|
692
|
+
(set, error, complete) => {
|
|
693
|
+
const userDocRef = doc(db, 'users', 'some-user-id');
|
|
694
|
+
const unsubscribe = onSnapshot(userDocRef,
|
|
695
|
+
(doc) => {
|
|
696
|
+
if (doc.exists()) {
|
|
697
|
+
set(doc.data());
|
|
698
|
+
} else {
|
|
699
|
+
error(new Error('User not found'));
|
|
700
|
+
}
|
|
701
|
+
complete();
|
|
702
|
+
},
|
|
703
|
+
(err) => {
|
|
704
|
+
error(err);
|
|
705
|
+
complete();
|
|
706
|
+
}
|
|
707
|
+
);
|
|
708
|
+
return unsubscribe;
|
|
709
|
+
}
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
function UserProfile({ userId }: { userId: string }) {
|
|
713
|
+
const { state, trigger, unsubscribe } = useSharedSubscription(userSubscription);
|
|
301
714
|
// Start listening when component mounts
|
|
302
715
|
useEffect(() => {
|
|
303
716
|
trigger();
|
|
@@ -325,15 +738,15 @@ import { useSharedSubscription } from 'react-shared-states';
|
|
|
325
738
|
function ChatRoom({ roomId }: { roomId: string }) {
|
|
326
739
|
const { state, trigger } = useSharedSubscription(
|
|
327
740
|
`chat-${roomId}`,
|
|
328
|
-
(set,
|
|
741
|
+
(set, error, complete) => {
|
|
329
742
|
const ws = new WebSocket(`ws://chat-server/${roomId}`);
|
|
330
743
|
|
|
331
|
-
ws.onopen = () =>
|
|
744
|
+
ws.onopen = () => complete();
|
|
332
745
|
ws.onmessage = (event) => {
|
|
333
746
|
const message = JSON.parse(event.data);
|
|
334
747
|
set(prev => [...(prev || []), message]);
|
|
335
748
|
};
|
|
336
|
-
ws.onerror =
|
|
749
|
+
ws.onerror = error;
|
|
337
750
|
|
|
338
751
|
return () => ws.close();
|
|
339
752
|
}
|
|
@@ -365,14 +778,14 @@ import { useSharedSubscription } from 'react-shared-states';
|
|
|
365
778
|
function LiveUpdates() {
|
|
366
779
|
const { state, trigger } = useSharedSubscription(
|
|
367
780
|
'live-updates',
|
|
368
|
-
(set,
|
|
781
|
+
(set, error, complete) => {
|
|
369
782
|
const eventSource = new EventSource('/api/live-updates');
|
|
370
783
|
|
|
371
|
-
eventSource.onopen = () =>
|
|
784
|
+
eventSource.onopen = () => complete();
|
|
372
785
|
eventSource.onmessage = (event) => {
|
|
373
786
|
set(JSON.parse(event.data));
|
|
374
787
|
};
|
|
375
|
-
eventSource.onerror =
|
|
788
|
+
eventSource.onerror = error;
|
|
376
789
|
|
|
377
790
|
return () => eventSource.close();
|
|
378
791
|
}
|
|
@@ -395,36 +808,62 @@ Subscription semantics:
|
|
|
395
808
|
|
|
396
809
|
|
|
397
810
|
## 🛰️ Static APIs (outside React)
|
|
811
|
+
## 🏛️ Static/Global Shared Resource Creation
|
|
812
|
+
|
|
813
|
+
For large apps, you can create and export shared state, function, or subscription objects for type safety and to avoid key collisions. This pattern is similar to Zustand or Jotai stores:
|
|
814
|
+
|
|
815
|
+
```ts
|
|
816
|
+
import { createSharedState, createSharedFunction, createSharedSubscription, useSharedState, useSharedFunction, useSharedSubscription } from 'react-shared-states';
|
|
817
|
+
|
|
818
|
+
// Create and export shared resources
|
|
819
|
+
export const counterState = createSharedState(0);
|
|
820
|
+
export const fetchUserFunction = createSharedFunction(() => fetch('/api/me').then(r => r.json()));
|
|
821
|
+
export const chatSubscription = createSharedSubscription((set, error, complete) => {/* ... */});
|
|
822
|
+
|
|
823
|
+
// Use anywhere in your app
|
|
824
|
+
const [count, setCount] = useSharedState(counterState);
|
|
825
|
+
const { state, trigger } = useSharedFunction(fetchUserFunction);
|
|
826
|
+
const { state, trigger, unsubscribe } = useSharedSubscription(chatSubscription);
|
|
827
|
+
```
|
|
398
828
|
Useful for SSR hydration, event listeners, debugging, imperative workflows.
|
|
399
829
|
|
|
400
830
|
```ts
|
|
401
831
|
import { sharedStatesApi, sharedFunctionsApi, sharedSubscriptionsApi } from 'react-shared-states';
|
|
402
832
|
|
|
403
|
-
// Preload state
|
|
833
|
+
// Preload state (global scope by default)
|
|
404
834
|
sharedStatesApi.set('bootstrap-data', { user: {...} });
|
|
405
835
|
|
|
836
|
+
// Preload state in a named scope
|
|
837
|
+
sharedStatesApi.set('bootstrap-data', { user: {...} }, 'myScope');
|
|
838
|
+
|
|
406
839
|
// Read later
|
|
407
|
-
const user = sharedStatesApi.get('bootstrap-data');
|
|
840
|
+
const user = sharedStatesApi.get('bootstrap-data'); // global
|
|
841
|
+
const userScoped = sharedStatesApi.get('bootstrap-data', 'myScope');
|
|
408
842
|
|
|
409
|
-
// Inspect all
|
|
410
|
-
console.log(sharedStatesApi.getAll());
|
|
843
|
+
// Inspect all (returns nested object: { [scope]: { [key]: value } })
|
|
844
|
+
console.log(sharedStatesApi.getAll());
|
|
845
|
+
|
|
846
|
+
// Clear all keys in a scope
|
|
847
|
+
sharedStatesApi.clearScope('myScope');
|
|
411
848
|
|
|
412
849
|
// For shared functions
|
|
413
850
|
const fnState = sharedFunctionsApi.get('profile-123');
|
|
851
|
+
const fnStateScoped = sharedFunctionsApi.get('profile-123', 'myScope');
|
|
414
852
|
|
|
415
853
|
// For shared subscriptions
|
|
416
854
|
const subState = sharedSubscriptionsApi.get('live-chat');
|
|
855
|
+
const subStateScoped = sharedSubscriptionsApi.get('live-chat', 'myScope');
|
|
417
856
|
```
|
|
418
857
|
|
|
419
858
|
## API summary:
|
|
420
859
|
|
|
421
|
-
| API | Methods
|
|
422
|
-
|
|
423
|
-
| `sharedStatesApi` | `get(key,
|
|
424
|
-
| `sharedFunctionsApi` | `get(key,
|
|
425
|
-
| `sharedSubscriptionsApi` | `get(key,
|
|
860
|
+
| API | Methods |
|
|
861
|
+
|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
862
|
+
| `sharedStatesApi` | `get(key, scopeName?)`, `set(key, val, scopeName?)`, `has(key, scopeName?)`, `clear(key, scopeName?)`, `clearAll(withoutListeners?, withStatic?)`, `clearScope(scopeName?)`, `getAll()` |
|
|
863
|
+
| `sharedFunctionsApi` | `get(key, scopeName?)`, `set(key, val, scopeName?)`, `has(key, scopeName?)`, `clear(key, scopeName?)`, `clearAll(withoutListeners?, withStatic?)`, `clearScope(scopeName?)`, `getAll()` |
|
|
864
|
+
| `sharedSubscriptionsApi` | `get(key, scopeName?)`, `set(key, val, scopeName?)`, `has(key, scopeName?)`, `clear(key, scopeName?)`, `clearAll(withoutListeners?, withStatic?)`, `clearScope(scopeName?)`, `getAll()` |
|
|
426
865
|
|
|
427
|
-
`
|
|
866
|
+
`scopeName` defaults to `"_global"`. Internally, keys are stored as `${scope}//${key}`. The `.getAll()` method returns a nested object: `{ [scope]: { [key]: value } }`.
|
|
428
867
|
|
|
429
868
|
|
|
430
869
|
## 🧩 Scoping Rules Deep Dive
|
|
@@ -452,14 +891,15 @@ Two providers sharing the same `scopeName` act as a single logical scope even if
|
|
|
452
891
|
|
|
453
892
|
## 🧪 Testing Tips
|
|
454
893
|
* Use static APIs to assert state after component interactions.
|
|
455
|
-
* `sharedStatesApi.clearAll()`, `sharedFunctionsApi.clearAll()`, `sharedSubscriptionsApi.clearAll()` in `afterEach` to isolate tests.
|
|
894
|
+
* `sharedStatesApi.clearAll(false, true)`, `sharedFunctionsApi.clearAll(false, true)`, `sharedSubscriptionsApi.clearAll(false, true)` in `afterEach` to isolate tests and clear static states.
|
|
456
895
|
* For async functions: trigger once, await UI stabilization, assert `results` present.
|
|
457
896
|
* For subscriptions: mock the subscription source (Firebase, WebSocket, etc.) and verify data flow.
|
|
458
897
|
|
|
459
898
|
|
|
460
899
|
## ❓ FAQ
|
|
461
900
|
**Q: How do I reset a single shared state?**
|
|
462
|
-
`sharedStatesApi.clear('key')
|
|
901
|
+
`sharedStatesApi.clear('key')`. If the state was created with `createSharedState`, it will reset to its initial value.
|
|
902
|
+
Otherwise, it will be removed.
|
|
463
903
|
|
|
464
904
|
**Q: Can I pre-hydrate data on the server?**
|
|
465
905
|
Yes. Call `sharedStatesApi.set(...)` during bootstrap, then first client hook usage will pick it up.
|
|
@@ -481,16 +921,25 @@ Currently no built-in Suspense wrappers; wrap `useSharedFunction` yourself if de
|
|
|
481
921
|
### `useSharedState(key, initialValue, scopeName?)`
|
|
482
922
|
Returns `[value, setValue]`.
|
|
483
923
|
|
|
924
|
+
### `useSharedState(sharedStateCreated)`
|
|
925
|
+
Returns `[value, setValue]`.
|
|
926
|
+
|
|
484
927
|
### `useSharedFunction(key, fn, scopeName?)`
|
|
485
928
|
Returns `{ state, trigger, forceTrigger, clear }`.
|
|
486
929
|
|
|
930
|
+
### `useSharedFunction(sharedFunctionCreated)`
|
|
931
|
+
Returns `{ state, trigger, forceTrigger, clear }`.
|
|
932
|
+
|
|
487
933
|
### `useSharedSubscription(key, subscriber, scopeName?)`
|
|
488
934
|
Returns `{ state, trigger, unsubscribe }`.
|
|
489
935
|
|
|
936
|
+
### `useSharedSubscription(sharedSubscriptionCreated)`
|
|
937
|
+
Returns `{ state, trigger, unsubscribe }`.
|
|
938
|
+
|
|
490
939
|
### `<SharedStatesProvider scopeName?>`
|
|
491
940
|
Wrap children; optional `scopeName` (string). If omitted a random unique one is generated.
|
|
492
941
|
|
|
493
|
-
### Static
|
|
942
|
+
### Static APIs
|
|
494
943
|
`sharedStatesApi`, `sharedFunctionsApi`, `sharedSubscriptionsApi` (see earlier table).
|
|
495
944
|
|
|
496
945
|
|