1212[ ![ license] ( https://img.shields.io/github/license/HichemTab-tech/react-shared-states )] ( LICENSE )
1313
1414
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.
1616
1717## 🔥 Why this instead of Redux / Zustand / Context soup?
1818* 0 config. Just pick a key: ` useSharedState('cart', []) ` .
@@ -173,14 +173,15 @@ export default function App(){
173173
174174
175175## 🧠 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 ` ). |
184185
185186
186187## 🏗️ Sharing State (` useSharedState ` )
@@ -248,13 +249,158 @@ const refresh = () => forceTrigger();
248249` ` `
249250
250251
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+
251397## 🛰️ Static APIs (outside React)
252398Useful for SSR hydration, event listeners, debugging, imperative workflows.
253399
254400``` ts
255- import { sharedStatesApi, sharedFunctionsApi } from 'react-shared-states';
401+ import { sharedStatesApi , sharedFunctionsApi , sharedSubscriptionsApi } from ' react-shared-states' ;
256402
257- // Preload
403+ // Preload state
258404sharedStatesApi .set (' bootstrap-data' , { user: {... } });
259405
260406// Read later
@@ -265,14 +411,18 @@ console.log(sharedStatesApi.getAll()); // Map with prefixed keys
265411
266412// For shared functions
267413const fnState = sharedFunctionsApi .get (' profile-123' );
414+
415+ // For shared subscriptions
416+ const subState = sharedSubscriptionsApi .get (' live-chat' );
268417```
269418
270419## API summary:
271420
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() ` |
276426
277427` scope ` defaults to ` "_global" ` . Internally keys are stored as ` ${scope}_${key} ` .
278428
@@ -302,8 +452,9 @@ Two providers sharing the same `scopeName` act as a single logical scope even if
302452
303453## 🧪 Testing Tips
304454* 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.
306456* 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.
307458
308459
309460## ❓ FAQ
@@ -319,6 +470,9 @@ Prefix keys by domain (e.g. `user:profile`, `cart:items`) or rely on provider sc
319470** Q: Why is my async function not re-running?**
320471It's cached. Use ` forceTrigger() ` or ` clear() ` .
321472
473+ ** Q: How do I handle subscription cleanup?**
474+ Subscriptions auto-cleanup when no components are listening. You can also manually call ` unsubscribe() ` .
475+
322476** Q: Can I use it with Suspense?**
323477Currently no built-in Suspense wrappers; wrap ` useSharedFunction ` yourself if desired.
324478
@@ -330,11 +484,14 @@ Returns `[value, setValue]`.
330484### ` useSharedFunction(key, fn, scopeName?) `
331485Returns ` { state, trigger, forceTrigger, clear } ` .
332486
487+ ### ` useSharedSubscription(key, subscriber, scopeName?) `
488+ Returns ` { state, trigger, unsubscribe } ` .
489+
333490### ` <SharedStatesProvider scopeName?> `
334491Wrap children; optional ` scopeName ` (string). If omitted a random unique one is generated.
335492
336493### Static
337- ` sharedStatesApi ` , ` sharedFunctionsApi ` (see earlier table ).
494+ ` sharedStatesApi ` , ` sharedFunctionsApi ` , ` sharedSubscriptionsApi ` (see earlier table).
338495
339496
340497
0 commit comments