Skip to content

Commit b68d89d

Browse files
Merge pull request #1 from HichemTab-tech/add-shared-subscription
Introduce shared subscriptions feature
2 parents a8fb2fe + edcef1d commit b68d89d

File tree

11 files changed

+604
-49
lines changed

11 files changed

+604
-49
lines changed

README.md

Lines changed: 174 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
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)
252398
Useful 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
258404
sharedStatesApi.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
267413
const 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?**
320471
It'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?**
323477
Currently no built-in Suspense wrappers; wrap `useSharedFunction` yourself if desired.
324478

@@ -330,11 +484,14 @@ Returns `[value, setValue]`.
330484
### `useSharedFunction(key, fn, scopeName?)`
331485
Returns `{ state, trigger, forceTrigger, clear }`.
332486

487+
### `useSharedSubscription(key, subscriber, scopeName?)`
488+
Returns `{ state, trigger, unsubscribe }`.
489+
333490
### `<SharedStatesProvider scopeName?>`
334491
Wrap 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

demo/FakeSharedEmitter.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
type Callback<T> = {
2+
onNext: (data: T) => void
3+
onCompletion?: () => void
4+
onError?: (error: unknown) => void
5+
}
6+
7+
export const FakeSharedEmitter = (() => {
8+
const listeners = new Map<string, {
9+
callbacks: Callback<any>[],
10+
interval: NodeJS.Timeout
11+
}>();
12+
13+
let intervalDuration: undefined|number = undefined as undefined|number;
14+
15+
function start(key: string) {
16+
if (listeners.has(key)) return;
17+
18+
const interval = setInterval(() => {
19+
const data = `${key} - ${Math.floor(Math.random() * 1000)}`;
20+
console.log("pushing through subscriber...");
21+
listeners.get(key)?.callbacks.forEach((cb) => cb.onNext(data));
22+
}, intervalDuration ?? (1000 + Math.random() * 2000));
23+
24+
listeners.set(key, {
25+
callbacks: [],
26+
interval,
27+
});
28+
}
29+
30+
async function subscribe<T>(key: string, onNext: Callback<T>['onNext'], onError?: Callback<T>['onError'], onCompletion?: Callback<T>['onCompletion']) {
31+
if (!listeners.has(key)) start(key);
32+
33+
const callback = {
34+
onNext,
35+
onError,
36+
onCompletion
37+
}
38+
39+
await fakeAwait(1000);
40+
callback.onCompletion?.();
41+
42+
const entry = listeners.get(key)!;
43+
entry.callbacks.push(callback);
44+
45+
return () => {
46+
entry.callbacks = entry.callbacks.filter((cb) => cb !== callback);
47+
if (entry.callbacks.length === 0) {
48+
clearInterval(entry.interval);
49+
listeners.delete(key);
50+
}
51+
};
52+
}
53+
54+
function forcePush(key: string, value: any) {
55+
if (listeners.has(key)) {
56+
listeners.get(key)!.callbacks.forEach((cb) => cb.onNext(value));
57+
}
58+
}
59+
60+
// noinspection JSUnusedGlobalSymbols
61+
return {
62+
subscribe,
63+
forcePush,
64+
start,
65+
stop(key: string) {
66+
if (listeners.has(key)) {
67+
listeners.get(key)!.callbacks.forEach((cb) => cb.onError?.(new Error(`Stopped by user: ${key}`)));
68+
clearInterval(listeners.get(key)!.interval);
69+
listeners.delete(key);
70+
}
71+
},
72+
clearAll() {
73+
for (const key of listeners.keys()) {
74+
clearInterval(listeners.get(key)!.interval);
75+
}
76+
listeners.clear();
77+
},
78+
intervalDuration
79+
};
80+
})();
81+
82+
const fakeAwait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
83+
84+
window.FakeSharedEmitter = FakeSharedEmitter;

0 commit comments

Comments
 (0)