react-shared-states 1.0.4 → 1.0.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.
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  [![license](https://img.shields.io/github/license/HichemTab-tech/react-shared-states)](LICENSE)
13
13
 
14
14
 
15
- Tiny, ergonomic, convention‑over‑configuration state & async function sharing for React. Global by default, trivially scoped when you need isolation, and opt‑in static APIs when you must touch state outside components. As simple as `useState`, as flexible as Zustand, without boilerplate like Redux.
15
+ Tiny, ergonomic, convention‑over‑configuration state, async function, and real-time subscription sharing for React. Global by default, trivially scoped when you need isolation, and opt‑in static APIs when you must touch state outside components. As simple as `useState`, as flexible as Zustand, without boilerplate like Redux.
16
16
 
17
17
  ## 🔥 Why this instead of Redux / Zustand / Context soup?
18
18
  * 0 config. Just pick a key: `useSharedState('cart', [])`.
@@ -173,14 +173,15 @@ export default function App(){
173
173
 
174
174
 
175
175
  ## 🧠 Core Concepts
176
- | Concept | Summary |
177
- |-------------------|---------------------------------------------------------------------------------------------------------------------------------|
178
- | Global by default | No provider necessary. Same key => shared state. |
179
- | Scoping | Wrap with `SharedStatesProvider` to isolate. Nearest provider wins. |
180
- | Named scopes | `scopeName` prop lets distant providers sync (same name ⇒ same bucket). Unnamed providers auto‑generate a random isolated name. |
181
- | Manual override | Third param in `useSharedState` / `useSharedFunction` enforces a specific scope ignoring tree search. |
182
- | Shared functions | Encapsulate async logic: single flight + cached result + `error` + `isLoading` + opt‑in refresh. |
183
- | Static APIs | Access state/functions outside components (`sharedStatesApi`, `sharedFunctionsApi`). |
176
+ | Concept | Summary |
177
+ |----------------------|---------------------------------------------------------------------------------------------------------------------------------|
178
+ | Global by default | No provider necessary. Same key => shared state. |
179
+ | Scoping | Wrap with `SharedStatesProvider` to isolate. Nearest provider wins. |
180
+ | Named scopes | `scopeName` prop lets distant providers sync (same name ⇒ same bucket). Unnamed providers auto‑generate a random isolated name. |
181
+ | Manual override | Third param in `useSharedState` / `useSharedFunction` / `useSharedSubscription` enforces a specific scope ignoring tree search. |
182
+ | Shared functions | Encapsulate async logic: single flight + cached result + `error` + `isLoading` + opt‑in refresh. |
183
+ | Shared subscriptions | Real-time data streams: automatic cleanup + shared connections + `error` + `isLoading` + subscription state. |
184
+ | Static APIs | Access state/functions/subscriptions outside components (`sharedStatesApi`, `sharedFunctionsApi`, `sharedSubscriptionsApi`). |
184
185
 
185
186
 
186
187
  ## 🏗️ Sharing State (`useSharedState`)
@@ -248,13 +249,158 @@ const refresh = () => forceTrigger();
248
249
  ```
249
250
 
250
251
 
252
+ ## 📡 Real-time Subscriptions (`useSharedSubscription`)
253
+ Perfect for Firebase listeners, WebSocket connections,
254
+ Server-Sent Events, or any streaming data source that needs cleanup.
255
+
256
+ Signature:
257
+ ```ts
258
+ const { state, trigger, unsubscribe } = useSharedSubscription(key, subscriber, scopeName?);
259
+ ```
260
+
261
+ `state` shape: `{ data?: T; isLoading: boolean; error?: unknown; subscribed: boolean }`
262
+
263
+ The `subscriber` function receives three callbacks:
264
+ - `set(data)`: Update the shared data
265
+ - `onError(error)`: Handle errors
266
+ - `onCompletion()`: Mark loading as complete
267
+ - Returns: Optional cleanup function (called on unsubscribe/unmount)
268
+
269
+ ### Pattern: Firebase Firestore real-time listener
270
+ ```tsx
271
+ import { useEffect } from 'react';
272
+ import { onSnapshot, doc } from 'firebase/firestore';
273
+ import { useSharedSubscription } from 'react-shared-states';
274
+ import { db } from './firebase-config'; // your Firebase config
275
+
276
+ function UserProfile({ userId }: { userId: string }) {
277
+ const { state, trigger, unsubscribe } = useSharedSubscription(
278
+ `user-${userId}`,
279
+ async (set, onError, onCompletion) => {
280
+ const userRef = doc(db, 'users', userId);
281
+
282
+ // Set up the real-time listener
283
+ const unsubscribe = onSnapshot(
284
+ userRef,
285
+ (snapshot) => {
286
+ if (snapshot.exists()) {
287
+ set({ id: snapshot.id, ...snapshot.data() });
288
+ } else {
289
+ set(null);
290
+ }
291
+ },
292
+ onError,
293
+ onCompletion
294
+ );
295
+
296
+ // Return cleanup function
297
+ return unsubscribe;
298
+ }
299
+ );
300
+
301
+ // Start listening when component mounts
302
+ useEffect(() => {
303
+ trigger();
304
+ }, []);
305
+
306
+ if (state.isLoading) return <div>Connecting...</div>;
307
+ if (state.error) return <div>Error: {state.error.message}</div>;
308
+ if (!state.data) return <div>User not found</div>;
309
+
310
+ return (
311
+ <div>
312
+ <h1>{state.data.name}</h1>
313
+ <p>{state.data.email}</p>
314
+ <button onClick={unsubscribe}>Stop listening</button>
315
+ </div>
316
+ );
317
+ }
318
+ ```
319
+
320
+ ### Pattern: WebSocket connection
321
+ ```tsx
322
+ import { useEffect } from 'react';
323
+ import { useSharedSubscription } from 'react-shared-states';
324
+
325
+ function ChatRoom({ roomId }: { roomId: string }) {
326
+ const { state, trigger } = useSharedSubscription(
327
+ `chat-${roomId}`,
328
+ (set, onError, onCompletion) => {
329
+ const ws = new WebSocket(`ws://chat-server/${roomId}`);
330
+
331
+ ws.onopen = () => onCompletion();
332
+ ws.onmessage = (event) => {
333
+ const message = JSON.parse(event.data);
334
+ set(prev => [...(prev || []), message]);
335
+ };
336
+ ws.onerror = onError;
337
+
338
+ return () => ws.close();
339
+ }
340
+ );
341
+
342
+ useEffect(() => {
343
+ trigger();
344
+ }, []);
345
+
346
+ return (
347
+ <div>
348
+ {state.isLoading && <p>Connecting to chat...</p>}
349
+ {state.error && <p>Connection failed</p>}
350
+ <div>
351
+ {state.data?.map(msg => (
352
+ <div key={msg.id}>{msg.text}</div>
353
+ ))}
354
+ </div>
355
+ </div>
356
+ );
357
+ }
358
+ ```
359
+
360
+ ### Pattern: Server-Sent Events
361
+ ```tsx
362
+ import { useEffect } from 'react';
363
+ import { useSharedSubscription } from 'react-shared-states';
364
+
365
+ function LiveUpdates() {
366
+ const { state, trigger } = useSharedSubscription(
367
+ 'live-updates',
368
+ (set, onError, onCompletion) => {
369
+ const eventSource = new EventSource('/api/live-updates');
370
+
371
+ eventSource.onopen = () => onCompletion();
372
+ eventSource.onmessage = (event) => {
373
+ set(JSON.parse(event.data));
374
+ };
375
+ eventSource.onerror = onError;
376
+
377
+ return () => eventSource.close();
378
+ }
379
+ );
380
+
381
+ useEffect(() => {
382
+ trigger();
383
+ }, []);
384
+
385
+ return <div>Latest: {JSON.stringify(state.data)}</div>;
386
+ }
387
+ ```
388
+
389
+ Subscription semantics:
390
+ * First `trigger()` establishes the subscription; subsequent calls do nothing if already subscribed.
391
+ * Multiple components with the same key+scope share one subscription + data stream.
392
+ * `unsubscribe()` closes the connection and clears the subscribed state.
393
+ * Automatic cleanup on component unmount when no other components are listening.
394
+ * Components mounting later instantly get the latest `data` without re-subscribing.
395
+
396
+
251
397
  ## 🛰️ Static APIs (outside React)
252
398
  Useful for SSR hydration, event listeners, debugging, imperative workflows.
253
399
 
254
400
  ```ts
255
- import { sharedStatesApi, sharedFunctionsApi } from 'react-shared-states';
401
+ import { sharedStatesApi, sharedFunctionsApi, sharedSubscriptionsApi } from 'react-shared-states';
256
402
 
257
- // Preload
403
+ // Preload state
258
404
  sharedStatesApi.set('bootstrap-data', { user: {...} });
259
405
 
260
406
  // Read later
@@ -265,14 +411,18 @@ console.log(sharedStatesApi.getAll()); // Map with prefixed keys
265
411
 
266
412
  // For shared functions
267
413
  const fnState = sharedFunctionsApi.get('profile-123');
414
+
415
+ // For shared subscriptions
416
+ const subState = sharedSubscriptionsApi.get('live-chat');
268
417
  ```
269
418
 
270
419
  ## API summary:
271
420
 
272
- | API | Methods |
273
- |----------------------|--------------------------------------------------------------------------------------|
274
- | `sharedStatesApi` | `get(key, scope?)`, `set(key,val,scope?)`, `has`, `clear`, `clearAll`, `getAll()` |
275
- | `sharedFunctionsApi` | `get(key, scope?)` (returns fn state), `set`, `has`, `clear`, `clearAll`, `getAll()` |
421
+ | API | Methods |
422
+ |--------------------------|---------------------------------------------------------------------------------------|
423
+ | `sharedStatesApi` | `get(key, scope?)`, `set(key,val,scope?)`, `has`, `clear`, `clearAll`, `getAll()` |
424
+ | `sharedFunctionsApi` | `get(key, scope?)` (returns fn state), `set`, `has`, `clear`, `clearAll`, `getAll()` |
425
+ | `sharedSubscriptionsApi` | `get(key, scope?)` (returns sub state), `set`, `has`, `clear`, `clearAll`, `getAll()` |
276
426
 
277
427
  `scope` defaults to `"_global"`. Internally keys are stored as `${scope}_${key}`.
278
428
 
@@ -302,8 +452,9 @@ Two providers sharing the same `scopeName` act as a single logical scope even if
302
452
 
303
453
  ## 🧪 Testing Tips
304
454
  * Use static APIs to assert state after component interactions.
305
- * `sharedStatesApi.clearAll()` in `afterEach` to isolate tests.
455
+ * `sharedStatesApi.clearAll()`, `sharedFunctionsApi.clearAll()`, `sharedSubscriptionsApi.clearAll()` in `afterEach` to isolate tests.
306
456
  * For async functions: trigger once, await UI stabilization, assert `results` present.
457
+ * For subscriptions: mock the subscription source (Firebase, WebSocket, etc.) and verify data flow.
307
458
 
308
459
 
309
460
  ## ❓ FAQ
@@ -319,6 +470,9 @@ Prefix keys by domain (e.g. `user:profile`, `cart:items`) or rely on provider sc
319
470
  **Q: Why is my async function not re-running?**
320
471
  It's cached. Use `forceTrigger()` or `clear()`.
321
472
 
473
+ **Q: How do I handle subscription cleanup?**
474
+ Subscriptions auto-cleanup when no components are listening. You can also manually call `unsubscribe()`.
475
+
322
476
  **Q: Can I use it with Suspense?**
323
477
  Currently no built-in Suspense wrappers; wrap `useSharedFunction` yourself if desired.
324
478
 
@@ -330,11 +484,14 @@ Returns `[value, setValue]`.
330
484
  ### `useSharedFunction(key, fn, scopeName?)`
331
485
  Returns `{ state, trigger, forceTrigger, clear }`.
332
486
 
487
+ ### `useSharedSubscription(key, subscriber, scopeName?)`
488
+ Returns `{ state, trigger, unsubscribe }`.
489
+
333
490
  ### `<SharedStatesProvider scopeName?>`
334
491
  Wrap children; optional `scopeName` (string). If omitted a random unique one is generated.
335
492
 
336
493
  ### Static
337
- `sharedStatesApi`, `sharedFunctionsApi` (see earlier table).
494
+ `sharedStatesApi`, `sharedFunctionsApi`, `sharedSubscriptionsApi` (see earlier table).
338
495
 
339
496
 
340
497
 
@@ -1,4 +1,4 @@
1
- import { AFunction, DataMapValue, NonEmptyString, Prefix } from './types';
1
+ import { AFunction, DataMapValue, Prefix } from './types';
2
2
  type SharedDataType<T> = DataMapValue & T;
3
3
  export declare abstract class SharedData<T> {
4
4
  data: Map<string, SharedDataType<T>>;
@@ -13,10 +13,11 @@ export declare abstract class SharedData<T> {
13
13
  setValue(key: string, prefix: Prefix, data: T): void;
14
14
  has(key: string, prefix: Prefix): string | undefined;
15
15
  static prefix(key: string, prefix: Prefix): string;
16
+ useEffect(key: string, prefix: Prefix, unsub?: (() => void) | null): void;
16
17
  }
17
18
  export interface SharedApi<T> {
18
- get: <S extends string = string>(key: NonEmptyString<S>, scopeName: Prefix) => T;
19
- set: <S extends string = string>(key: NonEmptyString<S>, value: T, scopeName: Prefix) => void;
19
+ get: <S extends string = string>(key: S, scopeName: Prefix) => T;
20
+ set: <S extends string = string>(key: S, value: T, scopeName: Prefix) => void;
20
21
  clearAll: () => void;
21
22
  clear: (key: string, scopeName: Prefix) => void;
22
23
  has: (key: string, scopeName: Prefix) => boolean;
@@ -1,2 +1,3 @@
1
1
  export { useSharedState, sharedStatesApi } from './use-shared-state';
2
2
  export { useSharedFunction, sharedFunctionsApi } from './use-shared-function';
3
+ export { useSharedSubscription, sharedSubscriptionsApi } from './use-shared-subscription';
@@ -1,4 +1,4 @@
1
- import { AFunction, NonEmptyString, Prefix } from '../types';
1
+ import { AFunction, Prefix } from '../types';
2
2
  import { SharedApi } from '../SharedData';
3
3
  type SharedFunctionsState<T> = {
4
4
  fnState: {
@@ -8,15 +8,15 @@ type SharedFunctionsState<T> = {
8
8
  };
9
9
  };
10
10
  export declare class SharedFunctionsApi implements SharedApi<SharedFunctionsState<unknown>> {
11
- get<T, S extends string = string>(key: NonEmptyString<S>, scopeName?: Prefix): T;
12
- set<T, S extends string = string>(key: NonEmptyString<S>, fnState: SharedFunctionsState<T>, scopeName?: Prefix): void;
11
+ get<T, S extends string = string>(key: S, scopeName?: Prefix): T;
12
+ set<T, S extends string = string>(key: S, fnState: SharedFunctionsState<T>, scopeName?: Prefix): void;
13
13
  clearAll(): void;
14
14
  clear(key: string, scopeName?: Prefix): void;
15
15
  has(key: string, scopeName?: Prefix): boolean;
16
16
  getAll(): Map<string, import('..').DataMapValue & SharedFunctionsState<unknown>>;
17
17
  }
18
18
  export declare const sharedFunctionsApi: SharedFunctionsApi;
19
- export declare const useSharedFunction: <T, Args extends unknown[], S extends string = string>(key: NonEmptyString<S>, fn: AFunction<T, Args>, scopeName?: Prefix) => {
19
+ export declare const useSharedFunction: <T, Args extends unknown[], S extends string = string>(key: S, fn: AFunction<T, Args>, scopeName?: Prefix) => {
20
20
  readonly state: {
21
21
  results?: T | undefined;
22
22
  isLoading: boolean;
@@ -1,10 +1,10 @@
1
- import { NonEmptyString, Prefix } from '../types';
1
+ import { Prefix } from '../types';
2
2
  import { SharedApi } from '../SharedData';
3
3
  declare class SharedStatesApi implements SharedApi<{
4
4
  value: unknown;
5
5
  }> {
6
- get<T, S extends string = string>(key: NonEmptyString<S>, scopeName?: Prefix): T;
7
- set<T, S extends string = string>(key: NonEmptyString<S>, value: T, scopeName?: Prefix): void;
6
+ get<T, S extends string = string>(key: S, scopeName?: Prefix): T;
7
+ set<T, S extends string = string>(key: S, value: T, scopeName?: Prefix): void;
8
8
  clearAll(): void;
9
9
  clear(key: string, scopeName?: Prefix): void;
10
10
  has(key: string, scopeName?: Prefix): boolean;
@@ -13,5 +13,5 @@ declare class SharedStatesApi implements SharedApi<{
13
13
  }>;
14
14
  }
15
15
  export declare const sharedStatesApi: SharedStatesApi;
16
- export declare const useSharedState: <T, S extends string = string>(key: NonEmptyString<S>, value: T, scopeName?: Prefix) => readonly [T, (newValueOrCallbackToNewValue: T | ((prev: T) => T)) => void];
16
+ export declare const useSharedState: <T, S extends string = string>(key: S, value: T, scopeName?: Prefix) => readonly [T, (newValueOrCallbackToNewValue: T | ((prev: T) => T)) => void];
17
17
  export {};
@@ -0,0 +1,39 @@
1
+ import { PotentialPromise, Prefix } from '../types';
2
+ import { SharedApi } from '../SharedData';
3
+ type Unsubscribe = () => void;
4
+ export declare namespace SubscriberEvents {
5
+ type OnError = (error: unknown) => void;
6
+ type OnCompletion = () => void;
7
+ type Set<T> = (value: T) => void;
8
+ }
9
+ type Subscriber<T> = (set: SubscriberEvents.Set<T>, onError: SubscriberEvents.OnError, onCompletion: SubscriberEvents.OnCompletion) => PotentialPromise<Unsubscribe | void | undefined>;
10
+ type SharedSubscriptionsState<T> = {
11
+ fnState: {
12
+ data?: T;
13
+ isLoading: boolean;
14
+ error?: unknown;
15
+ subscribed: boolean;
16
+ };
17
+ unsubscribe?: Unsubscribe | void;
18
+ };
19
+ export declare class SharedSubscriptionsApi implements SharedApi<SharedSubscriptionsState<unknown>> {
20
+ get<T, S extends string = string>(key: S, scopeName?: Prefix): T;
21
+ set<T, S extends string = string>(key: S, fnState: SharedSubscriptionsState<T>, scopeName?: Prefix): void;
22
+ clearAll(): void;
23
+ clear(key: string, scopeName?: Prefix): void;
24
+ has(key: string, scopeName?: Prefix): boolean;
25
+ getAll(): Map<string, import('..').DataMapValue & SharedSubscriptionsState<unknown>>;
26
+ }
27
+ export declare const sharedSubscriptionsApi: SharedSubscriptionsApi;
28
+ export declare const useSharedSubscription: <T, S extends string = string>(key: S, subscriber: Subscriber<T>, scopeName?: Prefix) => {
29
+ readonly state: {
30
+ data?: T | undefined;
31
+ isLoading: boolean;
32
+ error?: unknown;
33
+ subscribed: boolean;
34
+ };
35
+ readonly trigger: () => void;
36
+ readonly forceTrigger: () => void;
37
+ readonly unsubscribe: () => void;
38
+ };
39
+ export {};
@@ -0,0 +1,3 @@
1
+ import { NonEmptyString } from '../types';
2
+ export declare const log: (...args: any[]) => void;
3
+ export declare const ensureNonEmptyString: <T extends string>(value: T) => NonEmptyString<T>;