Skip to content

Commit 88502c1

Browse files
Enhance shared subscription API with unsubscribe functionality and improve state management
1 parent 1e49f42 commit 88502c1

File tree

8 files changed

+192
-71
lines changed

8 files changed

+192
-71
lines changed

demo/FakeSharedEmitter.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
type Callback<T> = {
2-
onSuccess: (data: T) => void
2+
onNext: (data: T) => void
3+
onCompletion?: () => void
34
onError?: (error: unknown) => void
45
}
56

@@ -17,7 +18,7 @@ export const FakeSharedEmitter = (() => {
1718
const interval = setInterval(() => {
1819
const data = `${key} - ${Math.floor(Math.random() * 1000)}`;
1920
console.log("pushing through subscriber...");
20-
listeners.get(key)?.callbacks.forEach((cb) => cb.onSuccess(data));
21+
listeners.get(key)?.callbacks.forEach((cb) => cb.onNext(data));
2122
}, intervalDuration ?? (1000 + Math.random() * 2000));
2223

2324
listeners.set(key, {
@@ -26,13 +27,18 @@ export const FakeSharedEmitter = (() => {
2627
});
2728
}
2829

29-
function subscribe<T>(key: string, onSuccess: Callback<T>['onSuccess'], onError?: Callback<T>['onError']) {
30+
async function subscribe<T>(key: string, onNext: Callback<T>['onNext'], onError?: Callback<T>['onError'], onCompletion?: Callback<T>['onCompletion']) {
3031
if (!listeners.has(key)) start(key);
3132

3233
const callback = {
33-
onSuccess,
34+
onNext,
3435
onError,
36+
onCompletion
3537
}
38+
39+
await fakeAwait(1000);
40+
callback.onCompletion?.();
41+
3642
const entry = listeners.get(key)!;
3743
entry.callbacks.push(callback);
3844

@@ -47,7 +53,7 @@ export const FakeSharedEmitter = (() => {
4753

4854
function forcePush(key: string, value: any) {
4955
if (listeners.has(key)) {
50-
listeners.get(key)!.callbacks.forEach((cb) => cb.onSuccess(value));
56+
listeners.get(key)!.callbacks.forEach((cb) => cb.onNext(value));
5157
}
5258
}
5359

@@ -73,4 +79,6 @@ export const FakeSharedEmitter = (() => {
7379
};
7480
})();
7581

82+
const fakeAwait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
83+
7684
window.FakeSharedEmitter = FakeSharedEmitter;

demo/app.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22
import {
3+
sharedFunctionsApi, sharedStatesApi,
34
SharedStatesProvider,
45
sharedSubscriptionsApi,
56
useSharedFunction,
@@ -8,14 +9,17 @@ import {
89
} from 'react-shared-states';
910
import './FakeSharedEmitter';
1011
import {FakeSharedEmitter} from "./FakeSharedEmitter";
12+
import {useEffect, useState} from "react";
1113

1214
FakeSharedEmitter.intervalDuration = 3000;
1315
window.sharedSubscriptionsApi = sharedSubscriptionsApi;
16+
window.sharedFunctionsApi = sharedFunctionsApi;
17+
window.sharedStatesApi = sharedStatesApi;
1418

1519
const Comp1 = () => {
1620
const [x, setX] = useSharedState('x', 0);
17-
const handle = () => {
18-
setX(x+1)
21+
const handle = (by = 1) => {
22+
setX(x+by)
1923
}
2024

2125
const {state, trigger, forceTrigger} = useSharedFunction("tt", (logMessage: string) => new Promise((resolve) => {
@@ -29,6 +33,7 @@ const Comp1 = () => {
2933
return (
3034
<div>
3135
<button onClick={() => handle()}>Increment x: {x}</button>
36+
<button onClick={() => handle(-1)}>Decrement x: {x}</button>
3237
{state.isLoading && <p>Loading...</p>}
3338
<button onClick={() => trigger("Hello world")}>Hello</button>
3439
<button onClick={() => forceTrigger("Hello world")}>Force Hello</button>
@@ -38,44 +43,49 @@ const Comp1 = () => {
3843
}
3944

4045
const Comp2 = () => {
41-
const {trigger} = useSharedSubscription('test-sub', (set, onError) => {
46+
const {state, trigger, unsubscribe} = useSharedSubscription<string>('test-sub', (set, onError, onCompletion) => {
4247
return FakeSharedEmitter.subscribe("x", (data: string) => {
4348
if (data === "do-error") {
4449
onError(new Error("Error"));
4550
return;
4651
}
4752
set(data);
4853
console.log("data loaded...", data);
49-
}, onError)
54+
}, onError, onCompletion)
5055
});
5156

5257

5358
return (
5459
<div>
5560
<h1 className="text-red-600">Comp2</h1>
5661
<button onClick={() => trigger()}>subscribe</button>
62+
<button onClick={() => unsubscribe()}>unsubscribe</button>
63+
results: {state.data}
5764
<br/>
5865
</div>
5966
)
6067
}
6168

6269
const App = () => {
6370

71+
const [hide, setHide] = useState(false);
6472
const [x, setX] = useSharedState('x', 0);
65-
const [x2, setX2] = useSharedState('x', 0);
73+
//const [x, setX] = useState(0);
6674
const handle = () => {
6775
setX(x+1)
6876
}
69-
const handle2 = () => {
70-
setX2(x-1);
71-
}
77+
78+
useEffect(() => {
79+
window.hide = () => setHide(a => !a);
80+
}, []);
81+
82+
if (hide) return null;
7283

7384
return (
7485
<div>
7586
<h1 className="text-red-600">React shared states Demo</h1>
7687
<button onClick={() => handle()}>Increment x: {x}</button>
7788
<br/>
78-
<button onClick={() => handle2()}>Increment x: {x2}</button>
7989
<Comp1/>
8090
<SharedStatesProvider>
8191
<Comp1/>
@@ -86,6 +96,7 @@ const App = () => {
8696
</SharedStatesProvider>
8797
</SharedStatesProvider>
8898
{x > 3 && <Comp2/>}
99+
{x > 6 && <Comp2/>}
89100
</div>
90101
);
91102
};

demo/env.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
// noinspection JSUnusedGlobalSymbols
2+
13
import {FakeSharedEmitter} from "./FakeSharedEmitter";
4+
import {sharedStatesApi, sharedSubscriptionsApi, sharedFunctionsApi} from "react-shared-states";
25

36
declare global {
47
interface Window {
58
FakeSharedEmitter: typeof FakeSharedEmitter
9+
sharedSubscriptionsApi: typeof sharedSubscriptionsApi
10+
sharedStatesApi: typeof sharedStatesApi
11+
sharedFunctionsApi: typeof sharedFunctionsApi
12+
hide: () => void
613
}
714
}

src/SharedData.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type {AFunction, DataMapValue, NonEmptyString, Prefix} from "./types";
1+
import type {AFunction, DataMapValue, Prefix} from "./types";
2+
import {useEffect} from "react";
3+
import {log} from "./lib/utils";
24

35

46
type SharedDataType<T> = DataMapValue & T;
@@ -79,11 +81,23 @@ export abstract class SharedData<T> {
7981
static prefix(key: string, prefix: Prefix) {
8082
return `${prefix}_${key}`;
8183
}
84+
85+
useEffect(key: string, prefix: Prefix, unsub: (() => void)|null = null) {
86+
useEffect(() => {
87+
return () => {
88+
unsub?.();
89+
log(`[${SharedData.prefix(key, prefix)}]`, "unmount effect");
90+
if (this.data.get(SharedData.prefix(key, prefix))!.listeners?.length === 0) {
91+
this.clear(key, prefix);
92+
}
93+
}
94+
}, []);
95+
}
8296
}
8397

8498
export interface SharedApi<T> {
85-
get: <S extends string = string>(key: NonEmptyString<S>, scopeName: Prefix) => T;
86-
set: <S extends string = string>(key: NonEmptyString<S>, value: T, scopeName: Prefix) => void;
99+
get: <S extends string = string>(key: S, scopeName: Prefix) => T;
100+
set: <S extends string = string>(key: S, value: T, scopeName: Prefix) => void;
87101
clearAll: () => void;
88102
clear: (key: string, scopeName: Prefix) => void;
89103
has: (key: string, scopeName: Prefix) => boolean;

src/hooks/use-shared-function.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type {AFunction, NonEmptyString, Prefix} from "../types";
2-
import {useSyncExternalStore} from "react";
1+
import type {AFunction, Prefix} from "../types";
2+
import {useMemo, useSyncExternalStore} from "react";
33
import {type SharedApi, SharedData} from "../SharedData";
44
import useShared from "./use-shared";
5+
import {ensureNonEmptyString} from "../lib/utils";
56

67
type SharedFunctionsState<T> = {
78
fnState: {
@@ -32,11 +33,13 @@ class SharedFunctionsData extends SharedData<SharedFunctionsState<unknown>> {
3233
}
3334

3435
export class SharedFunctionsApi implements SharedApi<SharedFunctionsState<unknown>>{
35-
get<T, S extends string = string>(key: NonEmptyString<S>, scopeName: Prefix = "_global") {
36+
get<T, S extends string = string>(key: S, scopeName: Prefix = "_global") {
37+
key = ensureNonEmptyString(key);
3638
const prefix: Prefix = scopeName || "_global";
3739
return sharedFunctionsData.get(key, prefix)?.fnState as T;
3840
}
39-
set<T, S extends string = string>(key: NonEmptyString<S>, fnState: SharedFunctionsState<T>, scopeName: Prefix = "_global") {
41+
set<T, S extends string = string>(key: S, fnState: SharedFunctionsState<T>, scopeName: Prefix = "_global") {
42+
key = ensureNonEmptyString(key);
4043
const prefix: Prefix = scopeName || "_global";
4144
sharedFunctionsData.setValue(key, prefix, fnState);
4245
}
@@ -60,20 +63,34 @@ export const sharedFunctionsApi = new SharedFunctionsApi();
6063

6164
const sharedFunctionsData = new SharedFunctionsData();
6265

63-
export const useSharedFunction = <T, Args extends unknown[], S extends string = string>(key: NonEmptyString<S>, fn: AFunction<T, Args>, scopeName?: Prefix) => {
66+
export const useSharedFunction = <T, Args extends unknown[], S extends string = string>(key: S, fn: AFunction<T, Args>, scopeName?: Prefix) => {
6467

68+
key = ensureNonEmptyString(key);
6569
const {prefix} = useShared(scopeName);
6670

6771
sharedFunctionsData.init(key, prefix);
6872

69-
const state = useSyncExternalStore<NonNullable<SharedFunctionsState<T>['fnState']>>((listener) => {
70-
sharedFunctionsData.init(key, prefix);
71-
sharedFunctionsData.addListener(key, prefix, listener);
73+
const externalStoreSubscriber = useMemo<Parameters<typeof useSyncExternalStore<NonNullable<SharedFunctionsState<T>['fnState']>>>[0]>(
74+
() =>
75+
(listener) => {
76+
sharedFunctionsData.init(key, prefix);
77+
sharedFunctionsData.addListener(key, prefix, listener);
7278

73-
return () => {
74-
sharedFunctionsData.removeListener(key, prefix, listener);
75-
}
76-
}, () => sharedFunctionsData.get(key, prefix)!.fnState as NonNullable<SharedFunctionsState<T>['fnState']>);
79+
return () => {
80+
sharedFunctionsData.removeListener(key, prefix, listener);
81+
}
82+
},
83+
[]
84+
);
85+
86+
const externalStoreSnapshotGetter = useMemo<Parameters<typeof useSyncExternalStore<NonNullable<SharedFunctionsState<T>['fnState']>>>[1]>(
87+
() =>
88+
() =>
89+
sharedFunctionsData.get(key, prefix)!.fnState as NonNullable<SharedFunctionsState<T>['fnState']>,
90+
[]
91+
);
92+
93+
const state = useSyncExternalStore<NonNullable<SharedFunctionsState<T>['fnState']>>(externalStoreSubscriber, externalStoreSnapshotGetter);
7794

7895
const trigger = async (force: boolean, ...args: Args) => {
7996
const entry = sharedFunctionsData.get(key, prefix)!;
@@ -89,6 +106,8 @@ export const useSharedFunction = <T, Args extends unknown[], S extends string =
89106
entry.listeners.forEach(l => l());
90107
};
91108

109+
sharedFunctionsData.useEffect(key, prefix);
110+
92111
// noinspection JSUnusedGlobalSymbols
93112
return {
94113
state,

src/hooks/use-shared-state.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import {useSyncExternalStore} from "react";
2-
import type {NonEmptyString, Prefix} from "../types";
1+
import {useMemo, useSyncExternalStore} from "react";
2+
import type {AFunction, Prefix} from "../types";
33
import {type SharedApi, SharedData} from "../SharedData";
44
import useShared from "./use-shared";
5+
import {ensureNonEmptyString} from "../lib/utils";
56

67
class SharedStatesData extends SharedData<{
78
value: unknown
@@ -17,16 +18,22 @@ class SharedStatesData extends SharedData<{
1718
setValue(key: string, prefix: Prefix, value: unknown) {
1819
super.setValue(key, prefix, {value});
1920
}
21+
22+
removeListener(key: string, prefix: Prefix, listener: AFunction) {
23+
super.removeListener(key, prefix, listener);
24+
}
2025
}
2126

2227
class SharedStatesApi implements SharedApi<{
2328
value: unknown
2429
}>{
25-
get<T, S extends string = string>(key: NonEmptyString<S>, scopeName: Prefix = "_global") {
30+
get<T, S extends string = string>(key: S, scopeName: Prefix = "_global") {
31+
key = ensureNonEmptyString(key);
2632
const prefix: Prefix = scopeName || "_global";
2733
return sharedStatesData.get(key, prefix)?.value as T;
2834
}
29-
set<T, S extends string = string>(key: NonEmptyString<S>, value: T, scopeName: Prefix = "_global") {
35+
set<T, S extends string = string>(key: S, value: T, scopeName: Prefix = "_global") {
36+
key = ensureNonEmptyString(key);
3037
const prefix: Prefix = scopeName || "_global";
3138
sharedStatesData.setValue(key, prefix, {value});
3239
}
@@ -50,20 +57,27 @@ export const sharedStatesApi = new SharedStatesApi();
5057

5158
const sharedStatesData = new SharedStatesData();
5259

53-
export const useSharedState = <T, S extends string = string>(key: NonEmptyString<S>, value: T, scopeName?: Prefix) => {
5460

61+
62+
export const useSharedState = <T, S extends string = string>(key: S, value: T, scopeName?: Prefix) => {
63+
64+
key = ensureNonEmptyString(key);
5565
const {prefix} = useShared(scopeName);
5666

5767
sharedStatesData.init(key, prefix, value);
5868

59-
const dataValue = useSyncExternalStore((listener) => {
69+
const externalStoreSubscriber = useMemo<Parameters<typeof useSyncExternalStore>[0]>(() => (listener) => {
6070
sharedStatesData.init(key, prefix, value);
6171
sharedStatesData.addListener(key, prefix, listener);
6272

6373
return () => {
6474
sharedStatesData.removeListener(key, prefix, listener);
6575
}
66-
}, () => sharedStatesData.get(key, prefix)?.value as T);
76+
}, []);
77+
78+
const externalStoreSnapshotGetter = useMemo(() => () => sharedStatesData.get(key, prefix)?.value as T, []);
79+
80+
const dataValue = useSyncExternalStore(externalStoreSubscriber, externalStoreSnapshotGetter);
6781

6882
const setData = (newValueOrCallbackToNewValue: T|((prev: T) => T)) => {
6983
const newValue = (typeof newValueOrCallbackToNewValue === "function") ? (newValueOrCallbackToNewValue as (prev: T) => T)(sharedStatesData.get(key, prefix)?.value as T) : newValueOrCallbackToNewValue
@@ -73,6 +87,8 @@ export const useSharedState = <T, S extends string = string>(key: NonEmptyString
7387
}
7488
}
7589

90+
sharedStatesData.useEffect(key, prefix);
91+
7692
return [
7793
dataValue,
7894
setData

0 commit comments

Comments
 (0)