@choksheak/react-utils 0.1.1 → 0.1.2

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.
@@ -0,0 +1,272 @@
1
+ import { kvStore } from "@choksheak/ts-utils/kvStore";
2
+ import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ /**
6
+ * The "shared state" is a React state that shares the same value across the
7
+ * entire app. It is largely similar to the jotai library, but is much smaller
8
+ * and much easier to work with when used for persisting in local storage or
9
+ * indexed db, with an automatic expiration for persisted values.
10
+ *
11
+ * This library aims to be the shortest, simplest, easiest to use, and most
12
+ * efficient implementation of shared global React states with seamless support
13
+ * for client persistence.
14
+ */
15
+
16
+ /************************************************************************/
17
+ /* Global config */
18
+ /************************************************************************/
19
+
20
+ /**
21
+ * These values are configurable by the user at any time.
22
+ */
23
+ export class SharedStateConfig {
24
+ /**
25
+ * Default duration for persistence expiration in milliseconds.
26
+ */
27
+ public static defaultPersistExpiryMs = MS_PER_DAY;
28
+
29
+ /**
30
+ * True to load from persistence only on mount, false (default) to load in
31
+ * the top level scope when the shared state is defined.
32
+ */
33
+ public static lazyLoad = false;
34
+ }
35
+
36
+ /************************************************************************/
37
+ /* Shared state implementation */
38
+ /************************************************************************/
39
+
40
+ type Subscriber<T> = (next: T, prev: T | undefined) => void;
41
+
42
+ type PersistenceAdapter<T> = {
43
+ save?: (value: T) => Promise<void> | void;
44
+ load?: () => Promise<T | undefined> | T | undefined;
45
+ };
46
+
47
+ class PubSubStore {
48
+ private dataByKey = new Map<string, unknown>();
49
+ private subscribersByKey = new Map<string, Set<Subscriber<unknown>>>();
50
+
51
+ public get<T>(key: string): T | undefined {
52
+ return this.dataByKey.get(key) as T | undefined;
53
+ }
54
+
55
+ public set<T>(key: string, value: T): void {
56
+ const prev = this.dataByKey.get(key) as T | undefined;
57
+
58
+ // Do nothing if no change.
59
+ if (prev === value) return;
60
+
61
+ this.dataByKey.set(key, value);
62
+ this.notify(key, value, prev);
63
+ }
64
+
65
+ // Subscribe to changes of a specific key
66
+ public subscribe<T>(key: string, subscriber: Subscriber<T>): () => void {
67
+ let subscribers = this.subscribersByKey.get(key);
68
+ if (!subscribers) {
69
+ subscribers = new Set();
70
+ this.subscribersByKey.set(key, subscribers);
71
+ }
72
+
73
+ // Type coercion: we store Subscriber<unknown>
74
+ subscribers.add(subscriber as Subscriber<unknown>);
75
+
76
+ // Return unsubscribe function
77
+ return () => {
78
+ subscribers.delete(subscriber as Subscriber<unknown>);
79
+ if (subscribers.size === 0) this.subscribersByKey.delete(key);
80
+ };
81
+ }
82
+
83
+ private notify<T>(key: string, next: T, prev: T | undefined): void {
84
+ const subscribers = this.subscribersByKey.get(key);
85
+ if (!subscribers) return;
86
+
87
+ // Make copy to protect against mutation during iteration.
88
+ const copy = Array.from(subscribers.values());
89
+ for (const subscriber of copy) {
90
+ try {
91
+ subscriber(next, prev);
92
+ } catch (e) {
93
+ console.error(`Error invoking subscriber:`, e);
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ const store = new PubSubStore();
100
+
101
+ export type SharedState<T> = {
102
+ subscribe: (subscriber: () => void) => () => void;
103
+ getSnapshot: () => T | undefined;
104
+ initDefaultValueOnce: () => Promise<void> | void;
105
+ initDone: boolean;
106
+ setValue: (next: T | ((prev: T | undefined) => T)) => void;
107
+ };
108
+
109
+ let stateKey = 0;
110
+
111
+ /**
112
+ * Create a new shared state object. Put this code at the top level scope:
113
+ *
114
+ * const myState = sharedState<number>(0);
115
+ */
116
+ export function sharedState<T>(
117
+ defaultValue: T,
118
+ options?: {
119
+ localStorageKey?: string;
120
+ indexedDbKey?: string;
121
+ persistExpiryMs?: number;
122
+ lazyLoad?: boolean;
123
+ },
124
+ ): SharedState<T> {
125
+ const { localStorageKey, indexedDbKey, persistExpiryMs, lazyLoad } =
126
+ options ?? {};
127
+
128
+ const key = String(stateKey++);
129
+
130
+ // Use up to 1 persistence adapter per shared state.
131
+ let persistenceAdapter: PersistenceAdapter<T> | undefined;
132
+ const expiryMs = persistExpiryMs ?? SharedStateConfig.defaultPersistExpiryMs;
133
+
134
+ if (localStorageKey) {
135
+ persistenceAdapter = new LocalStorageAdapter<T>(localStorageKey, expiryMs);
136
+ } else if (indexedDbKey) {
137
+ persistenceAdapter = new IndexedDbAdapter<T>(indexedDbKey, expiryMs);
138
+ }
139
+
140
+ // `subscribe` function required by useSyncExternalStore
141
+ function subscribe(subscriber: () => void): () => void {
142
+ return store.subscribe(key, () => subscriber());
143
+ }
144
+
145
+ // `getSnapshot` function — must return same ref if value unchanged
146
+ function getSnapshot(): T | undefined {
147
+ return store.get(key);
148
+ }
149
+
150
+ function setValue(next: T | ((prev: T | undefined) => T)): void {
151
+ if (typeof next === "function") {
152
+ const prev = store.get<T>(key);
153
+ next = (next as (p: T | undefined) => T)(prev);
154
+ }
155
+
156
+ store.set(key, next);
157
+ void persistenceAdapter?.save?.(next);
158
+ }
159
+
160
+ const state = {
161
+ subscribe,
162
+ getSnapshot,
163
+
164
+ // initial default handling
165
+ initDefaultValueOnce: async () => {
166
+ if (state.initDone) return;
167
+ state.initDone = true;
168
+
169
+ const v = await persistenceAdapter?.load?.();
170
+ store.set(key, v !== undefined ? v : defaultValue);
171
+ },
172
+ initDone: false,
173
+
174
+ setValue,
175
+ };
176
+
177
+ const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;
178
+
179
+ if (!lazy) {
180
+ void state.initDefaultValueOnce();
181
+ }
182
+
183
+ return state;
184
+ }
185
+
186
+ /**
187
+ * React hook for subscribing to a key in the global store.
188
+ * const [my, setMy] = useSharedState(myState);
189
+ */
190
+ export function useSharedState<T>(
191
+ state: SharedState<T>,
192
+ ): [T | undefined, (next: T | ((prev: T | undefined) => T)) => void] {
193
+ state.initDefaultValueOnce();
194
+
195
+ const value = useSyncExternalStore(state.subscribe, state.getSnapshot);
196
+
197
+ return [value, state.setValue];
198
+ }
199
+
200
+ /** E.g. const my = useSharedStateValue(myState) */
201
+ export function useSharedStateValue<T>(state: SharedState<T>) {
202
+ return useSharedState(state)[0];
203
+ }
204
+
205
+ /** E.g. const setMy = useSharedStateSetter(myState) */
206
+ export function useSharedStateSetter<T>(state: SharedState<T>) {
207
+ return useSharedState(state)[1];
208
+ }
209
+
210
+ /************************************************************************/
211
+ /* Persistence adapters */
212
+ /************************************************************************/
213
+
214
+ /** Save state to local storage. */
215
+ type LSBox<T> = { value: T; expiresAt: number };
216
+
217
+ class LocalStorageAdapter<T> implements PersistenceAdapter<T> {
218
+ public constructor(
219
+ public readonly key: string,
220
+ public readonly expiryMs: number,
221
+ ) {}
222
+
223
+ public save(value: T): void {
224
+ if (typeof window === "undefined") return;
225
+
226
+ window.localStorage.setItem(
227
+ this.key,
228
+ JSON.stringify({
229
+ value,
230
+ expiresAt: Date.now() + this.expiryMs,
231
+ } satisfies LSBox<T>),
232
+ );
233
+ }
234
+
235
+ public load(): T | undefined {
236
+ if (typeof window === "undefined") return undefined;
237
+
238
+ const raw = window.localStorage.getItem(this.key);
239
+ if (!raw) return undefined;
240
+
241
+ const box = JSON.parse(raw) as LSBox<T>;
242
+ if (!box || !box?.expiresAt || Date.now() >= box.expiresAt) {
243
+ window.localStorage.removeItem(this.key);
244
+ return undefined;
245
+ }
246
+
247
+ return box.value;
248
+ }
249
+ }
250
+
251
+ /** Save state to indexed db. */
252
+ class IndexedDbAdapter<T> implements PersistenceAdapter<T> {
253
+ public constructor(
254
+ public readonly key: string,
255
+ public readonly expiryMs: number,
256
+ ) {}
257
+
258
+ public async save(value: T): Promise<void> {
259
+ if (typeof window === "undefined") return;
260
+
261
+ await kvStore.set(this.key, JSON.stringify(value), this.expiryMs);
262
+ }
263
+
264
+ public async load(): Promise<T | undefined> {
265
+ if (typeof window === "undefined") return undefined;
266
+
267
+ const raw = await kvStore.get<string>(this.key);
268
+ if (raw === undefined) return undefined;
269
+
270
+ return JSON.parse(raw);
271
+ }
272
+ }
package/index.js DELETED
@@ -1,4 +0,0 @@
1
- "use strict";
2
-
3
- // src/index.ts
4
- console.log(`Hello from Chok's React Utils`);