floppy-disk 3.0.0 → 3.1.0-beta.1

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
@@ -5,8 +5,6 @@ A lightweight, simple, and powerful state management library.
5
5
  This library was highly-inspired by [Zustand](https://www.npmjs.com/package/zustand) and [TanStack-Query](https://tanstack.com/query), they're awesome state manager.
6
6
  FloppyDisk provides a very similar developer experience (DX), while introducing additional features and a smaller bundle size.
7
7
 
8
- Comparison: https://github.com/afiiif/floppy-disk/tree/beta/comparison
9
-
10
8
  Demo: https://afiiif.github.io/floppy-disk/
11
9
 
12
10
  **Installation:**
@@ -23,7 +21,7 @@ Here's how to create and use a store:
23
21
  import { createStore } from 'floppy-disk/react';
24
22
 
25
23
  const useDigimon = createStore({
26
- age: 3,
24
+ age: 7,
27
25
  level: 'Rookie',
28
26
  });
29
27
  ```
@@ -41,10 +39,12 @@ function MyDigimon() {
41
39
  function Control() {
42
40
  return (
43
41
  <>
44
- <button onClick={() => {
45
- // You can setState directly
46
- useDigimon.setState(prev => ({ age: prev.age + 1 }));
47
- }}>
42
+ <button
43
+ onClick={() => {
44
+ // You can setState directly
45
+ useDigimon.setState((prev) => ({ age: prev.age + 1 }));
46
+ }}
47
+ >
48
48
  Increase digimon's age
49
49
  </button>
50
50
 
@@ -66,6 +66,43 @@ const evolve = () => {
66
66
  };
67
67
  ```
68
68
 
69
+ ### Store Subscription
70
+
71
+ At its core, FloppyDisk is a **pub-sub store**.
72
+
73
+ You can subscribe manually:
74
+
75
+ ```tsx
76
+ const unsubscribe = useMyStore.subscribe((state, prev) => {
77
+ console.log('New state:', state);
78
+ });
79
+
80
+ // Later
81
+ unsubscribe();
82
+ ```
83
+
84
+ FloppyDisk provides lifecycle hooks tied to subscription count.
85
+
86
+ ```tsx
87
+ const useTowerDefense = createStore(
88
+ { archers: 3, mages: 1, barracks: 2, artillery: 1 },
89
+ {
90
+ onFirstSubscribe: () => {
91
+ console.log('First subscriber! We’re officially popular 🎉');
92
+ },
93
+ onSubscribe: () => {
94
+ console.log('New subscriber joined. Welcome aboard 🫡');
95
+ },
96
+ onUnsubscribe: () => {
97
+ console.log('Subscriber left... was it something I said? 😭');
98
+ },
99
+ onLastUnsubscribe: () => {
100
+ console.log('Everyone left. Guess I’ll just exist quietly now...');
101
+ },
102
+ },
103
+ );
104
+ ```
105
+
69
106
  ### Differences from Zustand
70
107
 
71
108
  If you're coming from Zustand, this should feel very familiar.\
@@ -80,28 +117,28 @@ Key differences:
80
117
  Zustand examples:
81
118
 
82
119
  ```tsx
83
- const useExample1 = create(123);
120
+ const useDate = create(new Date(2021, 01, 11));
84
121
 
85
- const useExample2 = create(set => ({
122
+ const useCounter = create((set) => ({
86
123
  value: 1,
87
- inc: () => set(prev => ({ value: prev.value + 1 })),
124
+ increment: () => set((prev) => ({ value: prev.value + 1 })),
88
125
  }));
89
126
  ```
90
127
 
91
128
  FloppyDisk equivalents:
92
129
 
93
130
  ```tsx
94
- const useExample1 = createStore({ value: 123 });
131
+ const useDate = createStore({ value: new Date(2021, 01, 11) });
95
132
 
133
+ const useCounter = createStore({ value: 1 });
134
+ const increment = () => useCounter.setState((prev) => ({ value: prev.value + 1 }));
96
135
  // Unlike Zustand, defining actions inside the store is **discouraged** in FloppyDisk.
97
136
  // This improves tree-shakeability and keeps your store minimal.
98
- const useExample2 = createStore({ value: 1 });
99
- const inc = () => useExample2.setState(prev => ({ value: prev.value + 1 }));
100
137
 
101
- // However, it's still possible if you understand how closures work:
102
- const useExample2Alt = createStore({
138
+ // However, it's still possible to mix actions with the state if you understand how closures work:
139
+ const useCounterAlt = createStore({
103
140
  value: 1,
104
- inc: () => useExample2Alt.setState(prev => ({ value: prev.value + 1 })),
141
+ increment: () => useCounterAlt.setState((prev) => ({ value: prev.value + 1 })),
105
142
  });
106
143
  ```
107
144
 
@@ -272,14 +309,11 @@ type GetPostsResponse = {
272
309
  meta: { nextCursor: string };
273
310
  };
274
311
 
275
- const postsQuery = createQuery<GetPostsResponse, GetPostParams>(
276
- getPosts,
277
- {
278
- staleTime: Infinity,
279
- revalidateOnFocus: false,
280
- revalidateOnReconnect: false,
281
- },
282
- );
312
+ const postsQuery = createQuery<GetPostsResponse, GetPostParams>(getPosts, {
313
+ staleTime: Infinity,
314
+ revalidateOnFocus: false,
315
+ revalidateOnReconnect: false,
316
+ });
283
317
 
284
318
  function Main() {
285
319
  return <Page cursor={undefined} />;
@@ -294,12 +328,10 @@ function Page({ cursor }: { cursor?: string }) {
294
328
 
295
329
  return (
296
330
  <>
297
- {data.posts.map(post => (
331
+ {data.posts.map((post) => (
298
332
  <PostCard key={post.id} post={post} />
299
333
  ))}
300
- {data.meta.nextCursor && (
301
- <LoadMore nextCursor={data.meta.nextCursor} />
302
- )}
334
+ {data.meta.nextCursor && <LoadMore nextCursor={data.meta.nextCursor} />}
303
335
  </>
304
336
  );
305
337
  }
@@ -314,11 +346,7 @@ function LoadMore({ nextCursor }: { nextCursor?: string }) {
314
346
  return <Page cursor={nextCursor} />;
315
347
  }
316
348
 
317
- return (
318
- <ReachingBottomObserver
319
- onReachBottom={() => setIsNextPageRequested(true)}
320
- />
321
- );
349
+ return <BottomObserver onReachBottom={() => setIsNextPageRequested(true)} />;
322
350
  }
323
351
  ```
324
352
 
@@ -337,34 +365,39 @@ Revalidating dozens of previously viewed pages rarely provides value to the user
337
365
 
338
366
  ## SSR Guidance
339
367
 
340
- FloppyDisk is designed primarily for **client-side [sync/async] state**.
341
-
342
- If your data is already fetched on the server (e.g. via SSR/ISR, Server Components, or Server Actions), then:
368
+ Examples for using stores and queries in SSR with isolated data (no shared state between users).
343
369
 
344
- > **You most likely don't need this library.**
370
+ ### Initialize Store State from Server
345
371
 
346
- This is the same philosophy as TanStack Query. 💡
347
-
348
- In many cases, developers mix SSR/ISR with client-side state because they want:
349
-
350
- 1. Data to be rendered into HTML on the server
351
- 2. The ability to **revalidate it on the client**
352
-
353
- A common (but inefficient) approach is:
372
+ ```tsx
373
+ const useCountStore = createStore({ count: 0 });
354
374
 
355
- - fetch on the server
356
- - hydrate it into a client-side cache
357
- - then revalidate using a query library
375
+ function Page({ initialCount }) {
376
+ const { count } = useCountStore({
377
+ initialState: { count: initialCount }, // e.g. 3
378
+ });
358
379
 
359
- While this works, it introduces additional complexity.
380
+ return <>count is {count}</>; // Output: count is 3
381
+ }
382
+ ```
360
383
 
361
- Instead, we encourage a simpler approach:
384
+ ### Initialize Query Data from Server
362
385
 
363
- > If your data is fetched on the server, revalidate it using **your framework's built-in mechanism** (e.g. Next.js route revalidation).
386
+ ```tsx
387
+ async function MyServerComponent() {
388
+ const data = await getData(); // e.g. { count: 3 }
389
+ return <MyClientComponent initialData={data} />;
390
+ }
364
391
 
365
- Because of this philosophy, FloppyDisk **does not support** hydrating server-fetched data into the client store.
392
+ const myQuery = createQuery(getData);
393
+ const useMyQuery = myQuery();
366
394
 
367
- This keeps the mental model clean:
395
+ function MyClientComponent({ initialData }) {
396
+ const { data } = useMyQuery({
397
+ initialData: initialData,
398
+ // initialDataIsStale: true <-- Optional, default to false (no immediate revalidation)
399
+ });
368
400
 
369
- - server data handled by the framework
370
- - client async state → handled by FloppyDisk
401
+ return <>count is {data.count}</>; // Output: count is 3
402
+ }
403
+ ```
@@ -182,7 +182,33 @@ export declare const createQuery: <TData, TVariable extends Record<string, any>
182
182
  * // While loading userId=2, still show userId=1 data
183
183
  * useQuery({ id: userId }, { keepPreviousData: true });
184
184
  */ keepPreviousData?: boolean;
185
- }) => QueryState<TData, TError>) & {
185
+ } & ({
186
+ /**
187
+ * Initial data used on first render (and will also update the store state right after that)
188
+ *
189
+ * If provided, `initialData` will be applied **once per query-store instance**
190
+ */
191
+ initialData: TData;
192
+ /**
193
+ * Whether the provided `initialData` should be treated as stale.
194
+ *
195
+ * @remarks
196
+ * - If `true`, a revalidation (refetch) will be triggered immediately.
197
+ * - If `false` (default), `initialData` is treated as fresh and will not be revalidated.
198
+ *
199
+ * @default false
200
+ *
201
+ * @example
202
+ * useQuery({
203
+ * initialData: dataFromSSR,
204
+ * initialDataIsStale: true, // force refetch
205
+ * });
206
+ */
207
+ initialDataIsStale?: boolean;
208
+ } | {
209
+ initialData?: never;
210
+ initialDataIsStale?: never;
211
+ })) => QueryState<TData, TError>) & {
186
212
  metadata: {
187
213
  isInvalidated?: boolean;
188
214
  promise?: Promise<QueryState<TData, TError>> | undefined;
@@ -25,4 +25,9 @@ import { type InitStoreOptions } from 'floppy-disk/vanilla';
25
25
  *
26
26
  * useMyStore.setState({ foo: 2 }); // only components using foo will re-render
27
27
  */
28
- export declare const createStore: <TState extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (() => TState) & import("../vanilla.d.mts").StoreApi<TState>;
28
+ export declare const createStore: <TState extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => ((options?: {
29
+ /**
30
+ * Initial state used on first render (and will also update the store state right after that)
31
+ */
32
+ initialState?: Partial<TState>;
33
+ }) => TState) & import("../vanilla.d.mts").StoreApi<TState>;
@@ -30,7 +30,14 @@ import { type InitStoreOptions } from 'floppy-disk/vanilla';
30
30
  * return <div>{state.name}</div>;
31
31
  * }
32
32
  */
33
- export declare const createStores: <TState extends Record<string, any>, TKey extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (key?: TKey) => (() => TState) & {
33
+ export declare const createStores: <TState extends Record<string, any>, TKey extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (key?: TKey) => ((options?: {
34
+ /**
35
+ * Initial state used on first render (and will also update the store state right after that)
36
+ *
37
+ * If provided, `initialState` will be applied **once per store instance**
38
+ */
39
+ initialState?: Partial<TState>;
40
+ }) => TState) & {
34
41
  delete: () => boolean;
35
42
  setState: (value: import("../vanilla.d.mts").SetState<TState>) => void;
36
43
  getState: () => TState;
@@ -4,10 +4,14 @@ export declare const getValueByPath: (obj: any, path: Path) => any;
4
4
  export declare const isPrefixPath: (candidatePrefix: Path, targetPath: Path) => boolean;
5
5
  export declare const compressPaths: (paths: Path[]) => Path[];
6
6
  export declare const useStoreStateProxy: <TState extends Record<string, any>>(storeState: TState) => readonly [TState, import("react").RefObject<Path[]>];
7
+ export declare const NO_INITIAL_VALUE: {};
8
+ export declare const useStoreStateWithInitializer: <TState extends Record<string, any>>(store: StoreApi<TState>, initialState?: Partial<TState>) => readonly [TState, import("react").RefObject<WeakMap<StoreApi<TState>, number>>];
7
9
  /**
8
10
  * React hook for subscribing to a store using automatic dependency tracking.
9
11
  *
10
12
  * @param store - The store instance to subscribe to
13
+ * @param options - Optional configuration
14
+ * @param options.initialState - Initial state used on first render (and will also update the store state right after that)
11
15
  *
12
16
  * @returns A proxied version of the store state
13
17
  *
@@ -17,12 +21,14 @@ export declare const useStoreStateProxy: <TState extends Record<string, any>>(st
17
21
  * - State must be treated as **immutable**:
18
22
  * - Updates must replace objects rather than mutate them
19
23
  * - Otherwise, changes may not be detected
20
- * - No selector or memoization is needed.
24
+ * - If provided, `initialState` will be applied **once per store instance**
21
25
  *
22
26
  * @example
23
27
  * const state = useStoreState(store);
24
28
  * return <div>{state.user.name}</div>;
25
29
  * // Component will only re-render if `user.name` changes
26
30
  */
27
- export declare const useStoreState: <TState extends Record<string, any>>(storeState: TState, subscribe: StoreApi<TState>["subscribe"]) => TState;
31
+ export declare const useStoreState: <TState extends Record<string, any>>(store: StoreApi<TState>, options?: {
32
+ initialState?: Partial<TState>;
33
+ }) => TState;
28
34
  export {};
package/esm/react.mjs CHANGED
@@ -49,11 +49,24 @@ const useStoreStateProxy = (storeState) => {
49
49
  }, [storeState]);
50
50
  return [trackedState, usedPathsRef];
51
51
  };
52
- const useStoreState = (storeState, subscribe) => {
53
- const [trackedState, usedPathsRef] = useStoreStateProxy(storeState);
52
+ const NO_INITIAL_VALUE = {};
53
+ const useStoreStateWithInitializer = (store, initialState = NO_INITIAL_VALUE) => {
54
+ const initiatedAt = useRef(new WeakMap([[store, 0]]));
55
+ useIsomorphicLayoutEffect(() => {
56
+ if (initialState === NO_INITIAL_VALUE || initiatedAt.current.get(store)) return;
57
+ store.setState(initialState);
58
+ initiatedAt.current.set(store, Date.now());
59
+ }, [store, initialState]);
60
+ const storeState = store.getState();
61
+ const finalState = initialState === NO_INITIAL_VALUE || initiatedAt.current.get(store) ? storeState : { ...storeState, ...initialState };
62
+ return [finalState, initiatedAt];
63
+ };
64
+ const useStoreState = (store, options = {}) => {
65
+ const [state] = useStoreStateWithInitializer(store, options.initialState);
66
+ const [trackedState, usedPathsRef] = useStoreStateProxy(state);
54
67
  const [, reRender] = useState({});
55
68
  useIsomorphicLayoutEffect(() => {
56
- return subscribe((nextState, prevState, changedKeys) => {
69
+ return store.subscribe((nextState, prevState, changedKeys) => {
57
70
  const paths = compressPaths(usedPathsRef.current);
58
71
  for (const path of paths) {
59
72
  const rootKey = path[0];
@@ -63,13 +76,13 @@ const useStoreState = (storeState, subscribe) => {
63
76
  if (!Object.is(prevVal, nextVal)) return reRender({});
64
77
  }
65
78
  });
66
- }, [subscribe]);
79
+ }, [store]);
67
80
  return trackedState;
68
81
  };
69
82
 
70
83
  const createStore = (initialState, options) => {
71
84
  const store = initStore(initialState, options);
72
- const useStore = () => useStoreState(store.getState(), store.subscribe);
85
+ const useStore = (options2) => useStoreState(store, options2);
73
86
  return Object.assign(useStore, store);
74
87
  };
75
88
 
@@ -84,7 +97,7 @@ const createStores = (initialState, options) => {
84
97
  store = initStore(initialState, options);
85
98
  stores.set(keyHash, store);
86
99
  }
87
- const useStore = () => useStoreState(store.getState(), store.subscribe);
100
+ const useStore = (options2) => useStoreState(store, options2);
88
101
  return Object.assign(useStore, {
89
102
  ...store,
90
103
  delete: () => {
@@ -367,8 +380,20 @@ const createQuery = (queryFn, options = {}) => {
367
380
  internals.set(store, configureInternals(store, variable, variableHash));
368
381
  }
369
382
  const useStore = (options2 = {}) => {
370
- const { revalidateOnMount = true, keepPreviousData } = options2;
371
- const storeState = store.getState();
383
+ const {
384
+ initialData = NO_INITIAL_VALUE,
385
+ initialDataIsStale = false,
386
+ revalidateOnMount = true,
387
+ keepPreviousData
388
+ } = options2;
389
+ const [storeState, initialDataInitiatedAt] = useStoreStateWithInitializer(
390
+ store,
391
+ initialData === NO_INITIAL_VALUE ? void 0 : {
392
+ state: "SUCCESS",
393
+ isSuccess: true,
394
+ data: initialData
395
+ }
396
+ );
372
397
  const prevState = useRef({});
373
398
  let storeStateToBeUsed = storeState;
374
399
  if (storeState.state !== "INITIAL") {
@@ -404,8 +429,16 @@ const createQuery = (queryFn, options = {}) => {
404
429
  });
405
430
  }, [store]);
406
431
  useIsomorphicLayoutEffect(() => {
407
- if (revalidateOnMount !== false) revalidate(store, variable, false);
408
- }, [store, revalidateOnMount]);
432
+ if (revalidateOnMount !== false) {
433
+ if (!initialDataIsStale) {
434
+ const dataInitiatedAt = initialDataInitiatedAt.current.get(store);
435
+ if (dataInitiatedAt && dataInitiatedAt + staleTime > Date.now()) {
436
+ return;
437
+ }
438
+ }
439
+ revalidate(store, variable, false);
440
+ }
441
+ }, [store, revalidateOnMount, initialDataIsStale]);
409
442
  if (keepPreviousData) {
410
443
  !!trackedState.error;
411
444
  }
@@ -483,7 +516,7 @@ const createMutation = (mutationFn, options = {}) => {
483
516
  let ongoingPromise;
484
517
  const resolveFns = /* @__PURE__ */ new Set([]);
485
518
  const store = initStore(initialState, options);
486
- const useStore = () => useStoreState(store.getState(), store.subscribe);
519
+ const useStore = () => useStoreState(store);
487
520
  const execute = (variable) => {
488
521
  let currentResolveFn;
489
522
  const stateBeforeExecute = store.getState();
@@ -51,6 +51,12 @@ export type InitStoreOptions<TState extends Record<string, any>> = {
51
51
  onSubscribe?: (state: TState, store: StoreApi<TState>) => void;
52
52
  onUnsubscribe?: (state: TState, store: StoreApi<TState>) => void;
53
53
  onLastUnsubscribe?: (state: TState, store: StoreApi<TState>) => void;
54
+ /**
55
+ * Called whenever the state changes, without counting as a subscriber.
56
+ * Acts like a "spy" on state updates.
57
+ * Useful for devtools, logging, or debugging state changes.
58
+ */
59
+ onStateChange?: (state: TState, prevState: TState, changedKeys: Array<keyof TState>) => void;
54
60
  /**
55
61
  * By default, calling `setState` on the server is disallowed to prevent shared state across requests.
56
62
  * Set this to `true` only if you explicitly intend to mutate state during server execution.
package/esm/vanilla.mjs CHANGED
@@ -35,6 +35,7 @@ const initStore = (initialState, options = {}) => {
35
35
  onSubscribe = noop,
36
36
  onUnsubscribe = noop,
37
37
  onLastUnsubscribe = noop,
38
+ onStateChange = noop,
38
39
  allowSetStateServerSide = false
39
40
  } = options;
40
41
  const subscribers = /* @__PURE__ */ new Set();
@@ -68,6 +69,7 @@ const initStore = (initialState, options = {}) => {
68
69
  }
69
70
  if (changedKeys.length === 0) return;
70
71
  state = { ...prevState, ...newValue };
72
+ onStateChange(state, prevState, changedKeys);
71
73
  [...subscribers].forEach((subscriber) => subscriber(state, prevState, changedKeys));
72
74
  };
73
75
  const storeApi = {
package/package.json CHANGED
@@ -2,7 +2,10 @@
2
2
  "name": "floppy-disk",
3
3
  "description": "Lightweight, simple, and powerful state management library",
4
4
  "private": false,
5
- "version": "3.0.0",
5
+ "version": "3.1.0-beta.1",
6
+ "publishConfig": {
7
+ "tag": "beta"
8
+ },
6
9
  "keywords": [
7
10
  "utilities",
8
11
  "store",
@@ -182,7 +182,33 @@ export declare const createQuery: <TData, TVariable extends Record<string, any>
182
182
  * // While loading userId=2, still show userId=1 data
183
183
  * useQuery({ id: userId }, { keepPreviousData: true });
184
184
  */ keepPreviousData?: boolean;
185
- }) => QueryState<TData, TError>) & {
185
+ } & ({
186
+ /**
187
+ * Initial data used on first render (and will also update the store state right after that)
188
+ *
189
+ * If provided, `initialData` will be applied **once per query-store instance**
190
+ */
191
+ initialData: TData;
192
+ /**
193
+ * Whether the provided `initialData` should be treated as stale.
194
+ *
195
+ * @remarks
196
+ * - If `true`, a revalidation (refetch) will be triggered immediately.
197
+ * - If `false` (default), `initialData` is treated as fresh and will not be revalidated.
198
+ *
199
+ * @default false
200
+ *
201
+ * @example
202
+ * useQuery({
203
+ * initialData: dataFromSSR,
204
+ * initialDataIsStale: true, // force refetch
205
+ * });
206
+ */
207
+ initialDataIsStale?: boolean;
208
+ } | {
209
+ initialData?: never;
210
+ initialDataIsStale?: never;
211
+ })) => QueryState<TData, TError>) & {
186
212
  metadata: {
187
213
  isInvalidated?: boolean;
188
214
  promise?: Promise<QueryState<TData, TError>> | undefined;
@@ -25,4 +25,9 @@ import { type InitStoreOptions } from 'floppy-disk/vanilla';
25
25
  *
26
26
  * useMyStore.setState({ foo: 2 }); // only components using foo will re-render
27
27
  */
28
- export declare const createStore: <TState extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (() => TState) & import("../vanilla.ts").StoreApi<TState>;
28
+ export declare const createStore: <TState extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => ((options?: {
29
+ /**
30
+ * Initial state used on first render (and will also update the store state right after that)
31
+ */
32
+ initialState?: Partial<TState>;
33
+ }) => TState) & import("../vanilla.ts").StoreApi<TState>;
@@ -30,7 +30,14 @@ import { type InitStoreOptions } from 'floppy-disk/vanilla';
30
30
  * return <div>{state.name}</div>;
31
31
  * }
32
32
  */
33
- export declare const createStores: <TState extends Record<string, any>, TKey extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (key?: TKey) => (() => TState) & {
33
+ export declare const createStores: <TState extends Record<string, any>, TKey extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (key?: TKey) => ((options?: {
34
+ /**
35
+ * Initial state used on first render (and will also update the store state right after that)
36
+ *
37
+ * If provided, `initialState` will be applied **once per store instance**
38
+ */
39
+ initialState?: Partial<TState>;
40
+ }) => TState) & {
34
41
  delete: () => boolean;
35
42
  setState: (value: import("../vanilla.ts").SetState<TState>) => void;
36
43
  getState: () => TState;
@@ -4,10 +4,14 @@ export declare const getValueByPath: (obj: any, path: Path) => any;
4
4
  export declare const isPrefixPath: (candidatePrefix: Path, targetPath: Path) => boolean;
5
5
  export declare const compressPaths: (paths: Path[]) => Path[];
6
6
  export declare const useStoreStateProxy: <TState extends Record<string, any>>(storeState: TState) => readonly [TState, import("react").RefObject<Path[]>];
7
+ export declare const NO_INITIAL_VALUE: {};
8
+ export declare const useStoreStateWithInitializer: <TState extends Record<string, any>>(store: StoreApi<TState>, initialState?: Partial<TState>) => readonly [TState, import("react").RefObject<WeakMap<StoreApi<TState>, number>>];
7
9
  /**
8
10
  * React hook for subscribing to a store using automatic dependency tracking.
9
11
  *
10
12
  * @param store - The store instance to subscribe to
13
+ * @param options - Optional configuration
14
+ * @param options.initialState - Initial state used on first render (and will also update the store state right after that)
11
15
  *
12
16
  * @returns A proxied version of the store state
13
17
  *
@@ -17,12 +21,14 @@ export declare const useStoreStateProxy: <TState extends Record<string, any>>(st
17
21
  * - State must be treated as **immutable**:
18
22
  * - Updates must replace objects rather than mutate them
19
23
  * - Otherwise, changes may not be detected
20
- * - No selector or memoization is needed.
24
+ * - If provided, `initialState` will be applied **once per store instance**
21
25
  *
22
26
  * @example
23
27
  * const state = useStoreState(store);
24
28
  * return <div>{state.user.name}</div>;
25
29
  * // Component will only re-render if `user.name` changes
26
30
  */
27
- export declare const useStoreState: <TState extends Record<string, any>>(storeState: TState, subscribe: StoreApi<TState>["subscribe"]) => TState;
31
+ export declare const useStoreState: <TState extends Record<string, any>>(store: StoreApi<TState>, options?: {
32
+ initialState?: Partial<TState>;
33
+ }) => TState;
28
34
  export {};
package/react.js CHANGED
@@ -51,11 +51,24 @@ const useStoreStateProxy = (storeState) => {
51
51
  }, [storeState]);
52
52
  return [trackedState, usedPathsRef];
53
53
  };
54
- const useStoreState = (storeState, subscribe) => {
55
- const [trackedState, usedPathsRef] = useStoreStateProxy(storeState);
54
+ const NO_INITIAL_VALUE = {};
55
+ const useStoreStateWithInitializer = (store, initialState = NO_INITIAL_VALUE) => {
56
+ const initiatedAt = react.useRef(new WeakMap([[store, 0]]));
57
+ useIsomorphicLayoutEffect(() => {
58
+ if (initialState === NO_INITIAL_VALUE || initiatedAt.current.get(store)) return;
59
+ store.setState(initialState);
60
+ initiatedAt.current.set(store, Date.now());
61
+ }, [store, initialState]);
62
+ const storeState = store.getState();
63
+ const finalState = initialState === NO_INITIAL_VALUE || initiatedAt.current.get(store) ? storeState : { ...storeState, ...initialState };
64
+ return [finalState, initiatedAt];
65
+ };
66
+ const useStoreState = (store, options = {}) => {
67
+ const [state] = useStoreStateWithInitializer(store, options.initialState);
68
+ const [trackedState, usedPathsRef] = useStoreStateProxy(state);
56
69
  const [, reRender] = react.useState({});
57
70
  useIsomorphicLayoutEffect(() => {
58
- return subscribe((nextState, prevState, changedKeys) => {
71
+ return store.subscribe((nextState, prevState, changedKeys) => {
59
72
  const paths = compressPaths(usedPathsRef.current);
60
73
  for (const path of paths) {
61
74
  const rootKey = path[0];
@@ -65,13 +78,13 @@ const useStoreState = (storeState, subscribe) => {
65
78
  if (!Object.is(prevVal, nextVal)) return reRender({});
66
79
  }
67
80
  });
68
- }, [subscribe]);
81
+ }, [store]);
69
82
  return trackedState;
70
83
  };
71
84
 
72
85
  const createStore = (initialState, options) => {
73
86
  const store = vanilla.initStore(initialState, options);
74
- const useStore = () => useStoreState(store.getState(), store.subscribe);
87
+ const useStore = (options2) => useStoreState(store, options2);
75
88
  return Object.assign(useStore, store);
76
89
  };
77
90
 
@@ -86,7 +99,7 @@ const createStores = (initialState, options) => {
86
99
  store = vanilla.initStore(initialState, options);
87
100
  stores.set(keyHash, store);
88
101
  }
89
- const useStore = () => useStoreState(store.getState(), store.subscribe);
102
+ const useStore = (options2) => useStoreState(store, options2);
90
103
  return Object.assign(useStore, {
91
104
  ...store,
92
105
  delete: () => {
@@ -369,8 +382,20 @@ const createQuery = (queryFn, options = {}) => {
369
382
  internals.set(store, configureInternals(store, variable, variableHash));
370
383
  }
371
384
  const useStore = (options2 = {}) => {
372
- const { revalidateOnMount = true, keepPreviousData } = options2;
373
- const storeState = store.getState();
385
+ const {
386
+ initialData = NO_INITIAL_VALUE,
387
+ initialDataIsStale = false,
388
+ revalidateOnMount = true,
389
+ keepPreviousData
390
+ } = options2;
391
+ const [storeState, initialDataInitiatedAt] = useStoreStateWithInitializer(
392
+ store,
393
+ initialData === NO_INITIAL_VALUE ? void 0 : {
394
+ state: "SUCCESS",
395
+ isSuccess: true,
396
+ data: initialData
397
+ }
398
+ );
374
399
  const prevState = react.useRef({});
375
400
  let storeStateToBeUsed = storeState;
376
401
  if (storeState.state !== "INITIAL") {
@@ -406,8 +431,16 @@ const createQuery = (queryFn, options = {}) => {
406
431
  });
407
432
  }, [store]);
408
433
  useIsomorphicLayoutEffect(() => {
409
- if (revalidateOnMount !== false) revalidate(store, variable, false);
410
- }, [store, revalidateOnMount]);
434
+ if (revalidateOnMount !== false) {
435
+ if (!initialDataIsStale) {
436
+ const dataInitiatedAt = initialDataInitiatedAt.current.get(store);
437
+ if (dataInitiatedAt && dataInitiatedAt + staleTime > Date.now()) {
438
+ return;
439
+ }
440
+ }
441
+ revalidate(store, variable, false);
442
+ }
443
+ }, [store, revalidateOnMount, initialDataIsStale]);
411
444
  if (keepPreviousData) {
412
445
  !!trackedState.error;
413
446
  }
@@ -485,7 +518,7 @@ const createMutation = (mutationFn, options = {}) => {
485
518
  let ongoingPromise;
486
519
  const resolveFns = /* @__PURE__ */ new Set([]);
487
520
  const store = vanilla.initStore(initialState, options);
488
- const useStore = () => useStoreState(store.getState(), store.subscribe);
521
+ const useStore = () => useStoreState(store);
489
522
  const execute = (variable) => {
490
523
  let currentResolveFn;
491
524
  const stateBeforeExecute = store.getState();
@@ -51,6 +51,12 @@ export type InitStoreOptions<TState extends Record<string, any>> = {
51
51
  onSubscribe?: (state: TState, store: StoreApi<TState>) => void;
52
52
  onUnsubscribe?: (state: TState, store: StoreApi<TState>) => void;
53
53
  onLastUnsubscribe?: (state: TState, store: StoreApi<TState>) => void;
54
+ /**
55
+ * Called whenever the state changes, without counting as a subscriber.
56
+ * Acts like a "spy" on state updates.
57
+ * Useful for devtools, logging, or debugging state changes.
58
+ */
59
+ onStateChange?: (state: TState, prevState: TState, changedKeys: Array<keyof TState>) => void;
54
60
  /**
55
61
  * By default, calling `setState` on the server is disallowed to prevent shared state across requests.
56
62
  * Set this to `true` only if you explicitly intend to mutate state during server execution.
package/vanilla.js CHANGED
@@ -37,6 +37,7 @@ const initStore = (initialState, options = {}) => {
37
37
  onSubscribe = noop,
38
38
  onUnsubscribe = noop,
39
39
  onLastUnsubscribe = noop,
40
+ onStateChange = noop,
40
41
  allowSetStateServerSide = false
41
42
  } = options;
42
43
  const subscribers = /* @__PURE__ */ new Set();
@@ -70,6 +71,7 @@ const initStore = (initialState, options = {}) => {
70
71
  }
71
72
  if (changedKeys.length === 0) return;
72
73
  state = { ...prevState, ...newValue };
74
+ onStateChange(state, prevState, changedKeys);
73
75
  [...subscribers].forEach((subscriber) => subscriber(state, prevState, changedKeys));
74
76
  };
75
77
  const storeApi = {