floppy-disk 3.0.0-beta.2 → 3.0.0-beta.4

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
@@ -1,3 +1,370 @@
1
- # Floppy Disk 💾
1
+ # FloppyDisk.ts 💾
2
2
 
3
3
  A lightweight, simple, and powerful state management library.
4
+
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
+ FloppyDisk provides a very similar developer experience (DX), while introducing additional features and a smaller bundle size.
7
+
8
+ Comparison: https://github.com/afiiif/floppy-disk/tree/beta/comparison
9
+
10
+ Demo: https://afiiif.github.io/floppy-disk/
11
+
12
+ **Installation:**
13
+
14
+ ```
15
+ npm install floppy-disk
16
+ ```
17
+
18
+ ## Global Store
19
+
20
+ Here's how to create and use a store:
21
+
22
+ ```tsx
23
+ import { createStore } from 'floppy-disk/react';
24
+
25
+ const useDigimon = createStore({
26
+ age: 3,
27
+ level: 'Rookie',
28
+ });
29
+ ```
30
+
31
+ You can use the store both inside and outside of React components.
32
+
33
+ ```tsx
34
+ function MyDigimon() {
35
+ const { age } = useDigimon();
36
+ return <div>Digimon age: {age}</div>;
37
+ // This component will only re-render when `age` changes.
38
+ // Changes to `level` will NOT trigger a re-render.
39
+ }
40
+
41
+ function Control() {
42
+ return (
43
+ <>
44
+ <button onClick={() => {
45
+ // You can setState directly
46
+ useDigimon.setState(prev => ({ age: prev.age + 1 }));
47
+ }}>
48
+ Increase digimon's age
49
+ </button>
50
+
51
+ <button onClick={evolve}>Evolve</button>
52
+ </>
53
+ );
54
+ }
55
+
56
+ // You can create a custom actions
57
+ const evolve = () => {
58
+ const { level } = useDigimon.getState();
59
+
60
+ const order = ['In-Training', 'Rookie', 'Champion', 'Ultimate'];
61
+ const nextLevel = order[order.indexOf(level) + 1];
62
+
63
+ if (!nextLevel) return console.warn('Already at ultimate level');
64
+
65
+ useDigimon.setState({ level: nextLevel });
66
+ };
67
+ ```
68
+
69
+ ### Differences from Zustand
70
+
71
+ If you're coming from Zustand, this should feel very familiar.\
72
+ Key differences:
73
+
74
+ 1. **No Selectors Needed**\
75
+ You don't need selectors when using hooks.
76
+ FloppyDisk automatically tracks which parts of the state are used and optimizes re-renders accordingly.
77
+ 2. **Object-Only Store Initialization**\
78
+ In FloppyDisk, stores **must** be initialized with an object. Primitive values or function initializers are not allowed.
79
+
80
+ Zustand examples:
81
+
82
+ ```tsx
83
+ const useExample1 = create(123);
84
+
85
+ const useExample2 = create(set => ({
86
+ value: 1,
87
+ inc: () => set(prev => ({ value: prev.value + 1 })),
88
+ }));
89
+ ```
90
+
91
+ FloppyDisk equivalents:
92
+
93
+ ```tsx
94
+ const useExample1 = createStore({ value: 123 });
95
+
96
+ // Unlike Zustand, defining actions inside the store is **discouraged** in FloppyDisk.
97
+ // 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
+
101
+ // However, it's still possible if you understand how closures work:
102
+ const useExample2Alt = createStore({
103
+ value: 1,
104
+ inc: () => useExample2Alt.setState(prev => ({ value: prev.value + 1 })),
105
+ });
106
+ ```
107
+
108
+ ## Async State (Query & Mutation)
109
+
110
+ FloppyDisk also provides a powerful async state layer, inspired by [TanStack-Query](https://tanstack.com/query) but with a simpler API.
111
+
112
+ It is agnostic to the type of async operation,
113
+ it works with any Promise-based operation—whether it's a network request, local computation, storage access, or something else.
114
+
115
+ Because of that, we intentionally avoid terms like "fetch" or "refetch".\
116
+ Instead, we use:
117
+
118
+ - **execute** → run the async operation (same as "fetch" in TanStack-Query)
119
+ - **revalidate** → re-run while keeping existing data (same as "refetch" in TanStack-Query)
120
+
121
+ ### Query vs Mutation
122
+
123
+ <details>
124
+
125
+ <summary>Query → Read Operations</summary>
126
+
127
+ Queries are designed for reading data.\
128
+ They assume:
129
+
130
+ - no side effects
131
+ - no data mutation
132
+ - safe to run multiple times
133
+
134
+ Because of this, queries come with helpful defaults:
135
+
136
+ - ✅ Retry mechanism (for transient failures)
137
+ - ✅ Revalidation (keep data fresh automatically)
138
+ - ✅ Caching & staleness control
139
+
140
+ Use queries when:
141
+
142
+ - fetching data
143
+ - reading from storage
144
+ - running idempotent async logic
145
+
146
+ </details>
147
+
148
+ <details>
149
+
150
+ <summary>Mutation → Write Operations</summary>
151
+
152
+ Mutations are designed for changing data.\
153
+ Examples:
154
+
155
+ - insert
156
+ - update
157
+ - delete
158
+ - triggering side effects
159
+
160
+ Because mutations are **not safe to repeat blindly**, FloppyDisk does **not** include:
161
+
162
+ - ❌ automatic retry
163
+ - ❌ automatic revalidation
164
+ - ❌ implicit re-execution
165
+
166
+ This is intentional.\
167
+ Mutations should be explicit and controlled, not automatic.
168
+
169
+ If you need retry mechanism, then you can always add it manually.
170
+
171
+ </details>
172
+
173
+ ### Single Query
174
+
175
+ Create a query using `createQuery`:
176
+
177
+ ```tsx
178
+ import { createQuery } from 'floppy-disk/react';
179
+
180
+ const myCoolQuery = createQuery(
181
+ myAsyncFn,
182
+ // { staleTime: 5000, revalidateOnFocus: false } <-- optional options
183
+ );
184
+
185
+ const useMyCoolQuery = myCoolQuery();
186
+
187
+ // Use it inside your component:
188
+
189
+ function MyComponent() {
190
+ const query = useMyCoolQuery();
191
+ if (query.state === 'INITIAL') return <div>Loading...</div>;
192
+ if (query.error) return <div>Error: {query.error.message}</div>;
193
+ return <div>{JSON.stringify(query.data)}</div>;
194
+ }
195
+ ```
196
+
197
+ ### Query State: Two Independent Dimensions
198
+
199
+ FloppyDisk tracks two things separately:
200
+
201
+ - Is it running? → `isPending`\
202
+ (value: `boolean`)
203
+ - What's the result? → `state`\
204
+ (value: `INITIAL | 'SUCCESS' | 'ERROR' | 'SUCCESS_BUT_REVALIDATION_ERROR'`)
205
+
206
+ They are **independent**.
207
+
208
+ ### Automatic Re-render Optimization
209
+
210
+ Just like the global store, FloppyDisk tracks usage automatically:
211
+
212
+ ```tsx
213
+ const { data } = useMyQuery();
214
+ // ^Only data changes will trigger a re-render
215
+
216
+ const value = useMyQuery().data?.foo.bar.baz;
217
+ // ^Only data.foo.bar.baz changes will trigger a re-render
218
+ ```
219
+
220
+ ### Keyed Query (Dynamic Params)
221
+
222
+ You can create parameterized queries:
223
+
224
+ ```tsx
225
+ import { getUserById, type GetUserByIdResponse } from '../utils';
226
+
227
+ type MyQueryParam = { id: string };
228
+
229
+ const userQuery = createQuery<GetUserByIdResponse, MyQueryParam>(
230
+ getUserById,
231
+ // { staleTime: 5000, revalidateOnFocus: false } <-- optional options
232
+ );
233
+ ```
234
+
235
+ Use it with parameters:
236
+
237
+ ```tsx
238
+ function UserDetail({ id }) {
239
+ const useUserQuery = userQuery({ id: 1 });
240
+ const query = useUserQuery();
241
+ if (query.state === 'INITIAL') return <div>Loading...</div>;
242
+ if (query.error) return <div>Error: {query.error.message}</div>;
243
+ return <div>{JSON.stringify(query.data)}</div>;
244
+ }
245
+ ```
246
+
247
+ Each unique parameter creates its own cache entry.
248
+
249
+ ### Infinite Query
250
+
251
+ FloppyDisk does **not provide** a dedicated "infinite query" API.\
252
+ Instead, it embraces a simpler and more flexible approach:
253
+
254
+ > Infinite queries are just **composition** + **recursion**.
255
+
256
+ Why? Because async state is already powerful enough:
257
+
258
+ - keyed queries handle parameters
259
+ - components handle composition
260
+ - recursion handles pagination
261
+
262
+ No special abstraction needed.
263
+
264
+ Here is the example on how to implement infinite query properly:
265
+
266
+ ```tsx
267
+ type GetPostParams = {
268
+ cursor?: string; // For pagination
269
+ };
270
+ type GetPostsResponse = {
271
+ posts: Post[];
272
+ meta: { nextCursor: string };
273
+ };
274
+
275
+ const postsQuery = createQuery<GetPostsResponse, GetPostParams>(
276
+ getPosts,
277
+ {
278
+ staleTime: Infinity,
279
+ revalidateOnFocus: false,
280
+ revalidateOnReconnect: false,
281
+ },
282
+ );
283
+
284
+ function Main() {
285
+ return <Page cursor={undefined} />;
286
+ }
287
+
288
+ function Page({ cursor }: { cursor?: string }) {
289
+ const usePostsQuery = postsQuery({ cursor });
290
+ const { state, data, error } = usePostsQuery();
291
+
292
+ if (state === 'INITIAL') return <div>Loading...</div>;
293
+ if (error) return <div>Error</div>;
294
+
295
+ return (
296
+ <>
297
+ {data.posts.map(post => (
298
+ <PostCard key={post.id} post={post} />
299
+ ))}
300
+ {data.meta.nextCursor && (
301
+ <LoadMore nextCursor={data.meta.nextCursor} />
302
+ )}
303
+ </>
304
+ );
305
+ }
306
+
307
+ function LoadMore({ nextCursor }: { nextCursor?: string }) {
308
+ const [isNextPageRequested, setIsNextPageRequested] = useState(() => {
309
+ const stateOfNextPageQuery = postsQuery({ cursor: nextCursor }).getState();
310
+ return stateOfNextPageQuery.isPending || stateOfNextPageQuery.isSuccess;
311
+ });
312
+
313
+ if (isNextPageRequested) {
314
+ return <Page cursor={nextCursor} />;
315
+ }
316
+
317
+ return (
318
+ <ReachingBottomObserver
319
+ onReachBottom={() => setIsNextPageRequested(true)}
320
+ />
321
+ );
322
+ }
323
+ ```
324
+
325
+ When implementing infinite queries, it is **highly recommended to disable automatic revalidation**.
326
+
327
+ Why?\
328
+ In an infinite list, users may scroll through many pages ("_doom-scrolling_").\
329
+ If revalidation is triggered:
330
+
331
+ - All previously loaded pages may re-execute
332
+ - Content at the top may change without the user noticing
333
+ - Layout shifts can occur unexpectedly
334
+
335
+ This leads to a **confusing and unstable user experience**.\
336
+ Revalidating dozens of previously viewed pages rarely provides value to the user.
337
+
338
+ ## SSR Guidance
339
+
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:
343
+
344
+ > **You most likely don't need this library.**
345
+
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:
354
+
355
+ - fetch on the server
356
+ - hydrate it into a client-side cache
357
+ - then revalidate using a query library
358
+
359
+ While this works, it introduces additional complexity.
360
+
361
+ Instead, we encourage a simpler approach:
362
+
363
+ > If your data is fetched on the server, revalidate it using **your framework's built-in mechanism** (e.g. Next.js route revalidation).
364
+
365
+ Because of this philosophy, FloppyDisk **does not support** hydrating server-fetched data into the client store.
366
+
367
+ This keeps the mental model clean:
368
+
369
+ - server data → handled by the framework
370
+ - client async state → handled by FloppyDisk
@@ -44,6 +44,17 @@ export type MutationState<TData, TVariable, TError> = {
44
44
  error: TError;
45
45
  errorUpdatedAt: number;
46
46
  });
47
+ export declare const INITIAL_STATE: {
48
+ state: string;
49
+ isPending: boolean;
50
+ isSuccess: boolean;
51
+ isError: boolean;
52
+ variable: undefined;
53
+ data: undefined;
54
+ dataUpdatedAt: undefined;
55
+ error: undefined;
56
+ errorUpdatedAt: undefined;
57
+ };
47
58
  /**
48
59
  * Configuration options for a mutation.
49
60
  *
@@ -52,15 +63,18 @@ export type MutationState<TData, TVariable, TError> = {
52
63
  */
53
64
  export type MutationOptions<TData, TVariable, TError = Error> = InitStoreOptions<MutationState<TData, TVariable, TError>> & {
54
65
  /**
55
- * Called when the mutation succeeds.
66
+ * Called when the mutation succeeds.\
67
+ * If multiple concurrent executions happened, only the latest execution triggers this callback.
56
68
  */
57
69
  onSuccess?: (data: TData, variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable, TError>) => void;
58
70
  /**
59
- * Called when the mutation fails.
71
+ * Called when the mutation fails.\
72
+ * If multiple concurrent executions happened, only the latest execution triggers this callback.
60
73
  */
61
74
  onError?: (error: TError, variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable, TError>) => void;
62
75
  /**
63
- * Called after the mutation settles (either success or error).
76
+ * Called after the mutation settles (either success or error).\
77
+ * If multiple concurrent executions happened, only the latest execution triggers this callback.
64
78
  */
65
79
  onSettled?: (variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable, TError>) => void;
66
80
  };
@@ -78,8 +92,10 @@ export type MutationOptions<TData, TVariable, TError = Error> = InitStoreOptions
78
92
  * - Mutations are **not cached** and only track the latest execution.
79
93
  * - Designed for operations that change data (e.g. create, update, delete).
80
94
  * - No retry mechanism is provided by default.
81
- * - Each execution overwrites the previous state.
82
95
  * - The mutation always resolves (never throws): the result contains either `data` or `error`.
96
+ * - If multiple executions triggered at the same time:
97
+ * - Only the latest execution is allowed to update the state.
98
+ * - Results from previous executions are ignored if a newer one exists.
83
99
  *
84
100
  * @example
85
101
  * const useCreateUser = createMutation(async (input) => {
@@ -111,9 +127,9 @@ export declare const createMutation: <TData, TVariable = undefined, TError = Err
111
127
  * - `{ error, variable }` on failure
112
128
  *
113
129
  * @remarks
114
- * - If a mutation is already in progress, a warning is logged.
115
- * - Concurrent executions are allowed but may lead to race conditions.
116
130
  * - The promise never rejects to simplify async handling.
131
+ * - If a mutation is already in progress, a warning is logged.
132
+ * - When a new execution starts, all previous pending executions will resolve with the result of the latest execution.
117
133
  */
118
134
  execute: TVariable extends undefined ? () => Promise<{
119
135
  variable: undefined;
@@ -128,8 +144,8 @@ export declare const createMutation: <TData, TVariable = undefined, TError = Err
128
144
  * Resets the mutation state back to its initial state.
129
145
  *
130
146
  * @remarks
131
- * - Does not cancel any ongoing request.
132
- * - If a request is still pending, its result may override the reset state.
147
+ * - Does not cancel any ongoing execution.
148
+ * - If an execution is still pending, its result may override the reset state.
133
149
  */
134
150
  reset: () => void;
135
151
  };
@@ -159,11 +159,13 @@ export type QueryOptions<TData, TVariable extends Record<string, any>, TError =
159
159
  */
160
160
  export declare const createQuery: <TData, TVariable extends Record<string, any> = never, TError = Error>(queryFn: (variable: TVariable, currentState: QueryState<TData, TError>) => Promise<TData>, options?: QueryOptions<TData, TVariable, TError>) => ((variable?: TVariable) => ((options?: {
161
161
  /**
162
- * Whether the query should execute automatically on mount.
162
+ * Whether the query should be ravalidated automatically on mount.
163
+ *
164
+ * Revalidate means execute the queryFn **if stale/invalidated**.
163
165
  *
164
166
  * @default true
165
167
  */
166
- enabled?: boolean;
168
+ revalidateOnMount?: boolean;
167
169
  /**
168
170
  * Whether to keep previous successful data while a new variable is loading.
169
171
  *
@@ -0,0 +1,82 @@
1
+ import { type MutationOptions, type MutationState } from './create-mutation.mjs';
2
+ /**
3
+ * A hook for managing async mutation state.
4
+ *
5
+ * @param mutationFn - Async function that performs the mutation.
6
+ * Receives the input variable and the state snapshot before execution.
7
+ *
8
+ * @param options - Optional lifecycle callbacks:
9
+ * - `onSuccess(data, variable, stateBeforeExecute)`
10
+ * - `onError(error, variable, stateBeforeExecute)`
11
+ * - `onSettled(variable, stateBeforeExecute)`
12
+ *
13
+ * @returns A tuple containing:
14
+ * - state: The current mutation state (render snapshot)
15
+ * - controls: An object with mutation actions and helpers
16
+ *
17
+ * @remarks
18
+ * - No retry mechanism is provided by default.
19
+ * - The mutation always resolves (never throws): the result contains either `data` or `error`.
20
+ * - If multiple executions triggered at the same time:
21
+ * - Only the latest execution is allowed to update the state.
22
+ * - Results from previous executions are ignored if a newer one exists.
23
+ */
24
+ export declare const useMutation: <TData, TVariable = undefined, TError = Error>(
25
+ /**
26
+ * Async function that performs the mutation.
27
+ *
28
+ * @remarks
29
+ * - Does NOT need to be memoized (e.g. `useCallback`).
30
+ * - The latest function reference is always used internally.
31
+ */
32
+ mutationFn: (variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable, TError>) => Promise<TData>,
33
+ /**
34
+ * Optional lifecycle callbacks.
35
+ *
36
+ * @remarks
37
+ * - Callbacks do NOT need to be memoized.
38
+ * - The latest callbacks are always used internally.
39
+ */
40
+ options?: MutationOptions<TData, TVariable, TError>) => [MutationState<TData, TVariable, TError>, {
41
+ /**
42
+ * Executes the mutation.
43
+ *
44
+ * @param variable - Input passed to the mutation function
45
+ *
46
+ * @returns A promise that always resolves with:
47
+ * - `{ data, variable }` on success
48
+ * - `{ error, variable }` on failure
49
+ *
50
+ * @remarks
51
+ * - The promise never rejects to simplify async handling.
52
+ * - If a mutation is already in progress, a warning is logged.
53
+ * - When a new execution starts, all previous pending executions will resolve with the result of the latest execution.
54
+ */
55
+ execute: TVariable extends undefined ? () => Promise<{
56
+ variable: undefined;
57
+ data?: TData;
58
+ error?: TError;
59
+ }> : (variable: TVariable) => Promise<{
60
+ variable: TVariable;
61
+ data?: TData;
62
+ error?: TError;
63
+ }>;
64
+ /**
65
+ * Resets the mutation state back to its initial state.
66
+ *
67
+ * @remarks
68
+ * - Does not cancel any ongoing execution.
69
+ * - If an execution is still pending, its result may override the reset state.
70
+ */
71
+ reset: () => void;
72
+ /**
73
+ * Returns the latest mutation state directly from the internal ref.
74
+ *
75
+ * @returns The most up-to-date mutation state.
76
+ *
77
+ * @remarks
78
+ * - Unlike the `state` returned by the hook, this value is not tied to React render cycles.
79
+ * - Use this inside async flows or event handlers to avoid stale reads.
80
+ */
81
+ getLatestState: () => MutationState<TData, TVariable, TError>;
82
+ }];
package/esm/react.d.mts CHANGED
@@ -3,4 +3,5 @@ export { useStoreState } from './react/use-store.mjs';
3
3
  export * from './react/create-store.mjs';
4
4
  export * from './react/create-stores.mjs';
5
5
  export * from './react/create-query.mjs';
6
- export * from './react/create-mutation.mjs';
6
+ export { createMutation, type MutationOptions, type MutationState, } from './react/create-mutation.mjs';
7
+ export * from './react/use-mutation.mjs';
package/esm/react.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useEffect, useState, useRef, useMemo } from 'react';
1
+ import { useLayoutEffect, useEffect, useState, useRef, useMemo, useCallback } from 'react';
2
2
  import { isClient, initStore, getHash, noop } from 'floppy-disk/vanilla';
3
3
 
4
4
  const useIsomorphicLayoutEffect = isClient ? useLayoutEffect : useEffect;
@@ -128,7 +128,7 @@ const createQuery = (queryFn, options = {}) => {
128
128
  onSettled = noop,
129
129
  shouldRetry: shouldRetryFn = (_, s) => s.retryCount === 0 ? [true, 1500] : [false]
130
130
  } = options;
131
- const initialState = INITIAL_STATE$1;
131
+ const initialState = { ...INITIAL_STATE$1 };
132
132
  const stores = /* @__PURE__ */ new Map();
133
133
  const configureStoreEvents = () => ({
134
134
  ...options,
@@ -367,7 +367,7 @@ const createQuery = (queryFn, options = {}) => {
367
367
  internals.set(store, configureInternals(store, variable, variableHash));
368
368
  }
369
369
  const useStore = (options2 = {}) => {
370
- const { enabled = true, keepPreviousData } = options2;
370
+ const { revalidateOnMount = true, keepPreviousData } = options2;
371
371
  const storeState = store.getState();
372
372
  const prevState = useRef({});
373
373
  let storeStateToBeUsed = storeState;
@@ -380,7 +380,7 @@ const createQuery = (queryFn, options = {}) => {
380
380
  storeStateToBeUsed = { ...storeState, ...prevState.current };
381
381
  }
382
382
  const [trackedState, usedPathsRef] = useStoreStateProxy(
383
- enabled && storeState.state === "INITIAL" ? (
383
+ revalidateOnMount && storeState.state === "INITIAL" ? (
384
384
  // Optimize rendering on initial state
385
385
  // Do { isPending: true } → result
386
386
  // instead of { isPending: false } → { isPending: true } → result
@@ -404,8 +404,8 @@ const createQuery = (queryFn, options = {}) => {
404
404
  });
405
405
  }, [store]);
406
406
  useIsomorphicLayoutEffect(() => {
407
- if (enabled !== false) revalidate(store, variable, false);
408
- }, [store, enabled]);
407
+ if (revalidateOnMount !== false) revalidate(store, variable, false);
408
+ }, [store, revalidateOnMount]);
409
409
  if (keepPreviousData) {
410
410
  !!trackedState.error;
411
411
  }
@@ -479,19 +479,26 @@ const INITIAL_STATE = {
479
479
  };
480
480
  const createMutation = (mutationFn, options = {}) => {
481
481
  const { onSuccess = noop, onError, onSettled = noop } = options;
482
- const initialState = INITIAL_STATE;
482
+ const initialState = { ...INITIAL_STATE };
483
+ let ongoingPromise;
484
+ const resolveFns = /* @__PURE__ */ new Set([]);
483
485
  const store = initStore(initialState, options);
484
486
  const useStore = () => useStoreState(store.getState(), store.subscribe);
485
487
  const execute = (variable) => {
488
+ let currentResolveFn;
486
489
  const stateBeforeExecute = store.getState();
487
490
  if (stateBeforeExecute.isPending) {
488
491
  console.warn(
489
- "Mutation executed while a previous execution is still pending. This may cause race conditions or unexpected state updates."
492
+ "A mutation was executed while a previous execution is still pending. The previous execution will be ignored (latest execution wins)."
490
493
  );
491
494
  }
492
495
  store.setState({ isPending: true });
493
- return new Promise((resolve) => {
496
+ const promise = new Promise((resolve) => {
497
+ currentResolveFn = resolve;
494
498
  mutationFn(variable, stateBeforeExecute).then((data) => {
499
+ if (promise !== ongoingPromise) {
500
+ return resolve({ data, variable });
501
+ }
495
502
  store.setState({
496
503
  state: "SUCCESS",
497
504
  isPending: false,
@@ -504,8 +511,12 @@ const createMutation = (mutationFn, options = {}) => {
504
511
  errorUpdatedAt: void 0
505
512
  });
506
513
  resolve({ data, variable });
514
+ resolveFns.clear();
507
515
  onSuccess(data, variable, stateBeforeExecute);
508
516
  }).catch((error) => {
517
+ if (promise !== ongoingPromise) {
518
+ return resolve({ error, variable });
519
+ }
509
520
  store.setState({
510
521
  state: "ERROR",
511
522
  isPending: false,
@@ -518,12 +529,19 @@ const createMutation = (mutationFn, options = {}) => {
518
529
  errorUpdatedAt: Date.now()
519
530
  });
520
531
  resolve({ error, variable });
532
+ resolveFns.clear();
521
533
  if (onError) onError(error, variable, stateBeforeExecute);
522
534
  else console.error(store.getState());
523
535
  }).finally(() => {
536
+ if (promise !== ongoingPromise) return;
524
537
  onSettled(variable, stateBeforeExecute);
538
+ ongoingPromise = void 0;
525
539
  });
526
540
  });
541
+ if (ongoingPromise) resolveFns.forEach((resolveFn) => resolveFn(promise));
542
+ resolveFns.add(currentResolveFn);
543
+ ongoingPromise = promise;
544
+ return promise;
527
545
  };
528
546
  return Object.assign(useStore, {
529
547
  subscribe: store.subscribe,
@@ -550,17 +568,17 @@ const createMutation = (mutationFn, options = {}) => {
550
568
  * - `{ error, variable }` on failure
551
569
  *
552
570
  * @remarks
553
- * - If a mutation is already in progress, a warning is logged.
554
- * - Concurrent executions are allowed but may lead to race conditions.
555
571
  * - The promise never rejects to simplify async handling.
572
+ * - If a mutation is already in progress, a warning is logged.
573
+ * - When a new execution starts, all previous pending executions will resolve with the result of the latest execution.
556
574
  */
557
575
  execute,
558
576
  /**
559
577
  * Resets the mutation state back to its initial state.
560
578
  *
561
579
  * @remarks
562
- * - Does not cancel any ongoing request.
563
- * - If a request is still pending, its result may override the reset state.
580
+ * - Does not cancel any ongoing execution.
581
+ * - If an execution is still pending, its result may override the reset state.
564
582
  */
565
583
  reset: () => {
566
584
  if (store.getState().isPending) {
@@ -573,4 +591,107 @@ const createMutation = (mutationFn, options = {}) => {
573
591
  });
574
592
  };
575
593
 
576
- export { createMutation, createQuery, createStore, createStores, useIsomorphicLayoutEffect, useStoreState };
594
+ const useMutation = (mutationFn, options = {}) => {
595
+ const { onSuccess = noop, onError, onSettled = noop } = options;
596
+ const callbackRef = useRef({ onSuccess, onError, onSettled });
597
+ callbackRef.current.onSuccess = onSuccess;
598
+ callbackRef.current.onError = onError;
599
+ callbackRef.current.onSettled = onSettled;
600
+ const stateRef = useRef({ ...INITIAL_STATE });
601
+ const [, reRender] = useState({});
602
+ const refs = useRef({
603
+ mutationFn,
604
+ ongoingPromise: void 0,
605
+ resolveFns: /* @__PURE__ */ new Set()
606
+ });
607
+ refs.current.mutationFn = mutationFn;
608
+ const execute = useCallback((variable) => {
609
+ let currentResolveFn;
610
+ const stateBeforeExecute = stateRef.current;
611
+ if (stateBeforeExecute.isPending) {
612
+ console.warn(
613
+ "A mutation was executed while a previous execution is still pending. The previous execution will be ignored (latest execution wins)."
614
+ );
615
+ }
616
+ stateRef.current.isPending = true;
617
+ reRender({});
618
+ const promise = new Promise(
619
+ (resolve) => {
620
+ currentResolveFn = resolve;
621
+ refs.current.mutationFn(variable, stateBeforeExecute).then((data) => {
622
+ if (promise !== refs.current.ongoingPromise) {
623
+ return resolve({ data, variable });
624
+ }
625
+ stateRef.current = {
626
+ state: "SUCCESS",
627
+ isPending: false,
628
+ isSuccess: true,
629
+ isError: false,
630
+ variable,
631
+ data,
632
+ dataUpdatedAt: Date.now(),
633
+ error: void 0,
634
+ errorUpdatedAt: void 0
635
+ };
636
+ reRender({});
637
+ resolve({ data, variable });
638
+ refs.current.resolveFns.clear();
639
+ callbackRef.current.onSuccess(data, variable, stateBeforeExecute);
640
+ }).catch((error) => {
641
+ if (promise !== refs.current.ongoingPromise) {
642
+ return resolve({ error, variable });
643
+ }
644
+ stateRef.current = {
645
+ state: "ERROR",
646
+ isPending: false,
647
+ isSuccess: false,
648
+ isError: true,
649
+ variable,
650
+ data: void 0,
651
+ dataUpdatedAt: void 0,
652
+ error,
653
+ errorUpdatedAt: Date.now()
654
+ };
655
+ reRender({});
656
+ resolve({ error, variable });
657
+ refs.current.resolveFns.clear();
658
+ if (callbackRef.current.onError) {
659
+ callbackRef.current.onError(error, variable, stateBeforeExecute);
660
+ } else {
661
+ console.error(stateRef.current);
662
+ }
663
+ }).finally(() => {
664
+ if (promise !== refs.current.ongoingPromise) return;
665
+ callbackRef.current.onSettled(variable, stateBeforeExecute);
666
+ refs.current.ongoingPromise = void 0;
667
+ });
668
+ }
669
+ );
670
+ if (refs.current.ongoingPromise) {
671
+ refs.current.resolveFns.forEach((resolveFn) => resolveFn(promise));
672
+ }
673
+ refs.current.resolveFns.add(currentResolveFn);
674
+ refs.current.ongoingPromise = promise;
675
+ return promise;
676
+ }, []);
677
+ const reset = useCallback(() => {
678
+ if (stateRef.current.isPending) {
679
+ console.warn(
680
+ "Mutation state was reset while a request is still pending. The request will continue, but its result may override the reset state."
681
+ );
682
+ }
683
+ stateRef.current = { ...INITIAL_STATE };
684
+ reRender({});
685
+ }, []);
686
+ const r = [
687
+ stateRef.current,
688
+ {
689
+ execute,
690
+ reset,
691
+ getLatestState: () => stateRef.current
692
+ }
693
+ ];
694
+ return r;
695
+ };
696
+
697
+ export { createMutation, createQuery, createStore, createStores, useIsomorphicLayoutEffect, useMutation, useStoreState };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "floppy-disk",
3
3
  "description": "Lightweight, simple, and powerful state management library",
4
4
  "private": false,
5
- "version": "3.0.0-beta.2",
5
+ "version": "3.0.0-beta.4",
6
6
  "publishConfig": {
7
7
  "tag": "beta"
8
8
  },
@@ -44,6 +44,17 @@ export type MutationState<TData, TVariable, TError> = {
44
44
  error: TError;
45
45
  errorUpdatedAt: number;
46
46
  });
47
+ export declare const INITIAL_STATE: {
48
+ state: string;
49
+ isPending: boolean;
50
+ isSuccess: boolean;
51
+ isError: boolean;
52
+ variable: undefined;
53
+ data: undefined;
54
+ dataUpdatedAt: undefined;
55
+ error: undefined;
56
+ errorUpdatedAt: undefined;
57
+ };
47
58
  /**
48
59
  * Configuration options for a mutation.
49
60
  *
@@ -52,15 +63,18 @@ export type MutationState<TData, TVariable, TError> = {
52
63
  */
53
64
  export type MutationOptions<TData, TVariable, TError = Error> = InitStoreOptions<MutationState<TData, TVariable, TError>> & {
54
65
  /**
55
- * Called when the mutation succeeds.
66
+ * Called when the mutation succeeds.\
67
+ * If multiple concurrent executions happened, only the latest execution triggers this callback.
56
68
  */
57
69
  onSuccess?: (data: TData, variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable, TError>) => void;
58
70
  /**
59
- * Called when the mutation fails.
71
+ * Called when the mutation fails.\
72
+ * If multiple concurrent executions happened, only the latest execution triggers this callback.
60
73
  */
61
74
  onError?: (error: TError, variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable, TError>) => void;
62
75
  /**
63
- * Called after the mutation settles (either success or error).
76
+ * Called after the mutation settles (either success or error).\
77
+ * If multiple concurrent executions happened, only the latest execution triggers this callback.
64
78
  */
65
79
  onSettled?: (variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable, TError>) => void;
66
80
  };
@@ -78,8 +92,10 @@ export type MutationOptions<TData, TVariable, TError = Error> = InitStoreOptions
78
92
  * - Mutations are **not cached** and only track the latest execution.
79
93
  * - Designed for operations that change data (e.g. create, update, delete).
80
94
  * - No retry mechanism is provided by default.
81
- * - Each execution overwrites the previous state.
82
95
  * - The mutation always resolves (never throws): the result contains either `data` or `error`.
96
+ * - If multiple executions triggered at the same time:
97
+ * - Only the latest execution is allowed to update the state.
98
+ * - Results from previous executions are ignored if a newer one exists.
83
99
  *
84
100
  * @example
85
101
  * const useCreateUser = createMutation(async (input) => {
@@ -111,9 +127,9 @@ export declare const createMutation: <TData, TVariable = undefined, TError = Err
111
127
  * - `{ error, variable }` on failure
112
128
  *
113
129
  * @remarks
114
- * - If a mutation is already in progress, a warning is logged.
115
- * - Concurrent executions are allowed but may lead to race conditions.
116
130
  * - The promise never rejects to simplify async handling.
131
+ * - If a mutation is already in progress, a warning is logged.
132
+ * - When a new execution starts, all previous pending executions will resolve with the result of the latest execution.
117
133
  */
118
134
  execute: TVariable extends undefined ? () => Promise<{
119
135
  variable: undefined;
@@ -128,8 +144,8 @@ export declare const createMutation: <TData, TVariable = undefined, TError = Err
128
144
  * Resets the mutation state back to its initial state.
129
145
  *
130
146
  * @remarks
131
- * - Does not cancel any ongoing request.
132
- * - If a request is still pending, its result may override the reset state.
147
+ * - Does not cancel any ongoing execution.
148
+ * - If an execution is still pending, its result may override the reset state.
133
149
  */
134
150
  reset: () => void;
135
151
  };
@@ -159,11 +159,13 @@ export type QueryOptions<TData, TVariable extends Record<string, any>, TError =
159
159
  */
160
160
  export declare const createQuery: <TData, TVariable extends Record<string, any> = never, TError = Error>(queryFn: (variable: TVariable, currentState: QueryState<TData, TError>) => Promise<TData>, options?: QueryOptions<TData, TVariable, TError>) => ((variable?: TVariable) => ((options?: {
161
161
  /**
162
- * Whether the query should execute automatically on mount.
162
+ * Whether the query should be ravalidated automatically on mount.
163
+ *
164
+ * Revalidate means execute the queryFn **if stale/invalidated**.
163
165
  *
164
166
  * @default true
165
167
  */
166
- enabled?: boolean;
168
+ revalidateOnMount?: boolean;
167
169
  /**
168
170
  * Whether to keep previous successful data while a new variable is loading.
169
171
  *
@@ -0,0 +1,82 @@
1
+ import { type MutationOptions, type MutationState } from './create-mutation';
2
+ /**
3
+ * A hook for managing async mutation state.
4
+ *
5
+ * @param mutationFn - Async function that performs the mutation.
6
+ * Receives the input variable and the state snapshot before execution.
7
+ *
8
+ * @param options - Optional lifecycle callbacks:
9
+ * - `onSuccess(data, variable, stateBeforeExecute)`
10
+ * - `onError(error, variable, stateBeforeExecute)`
11
+ * - `onSettled(variable, stateBeforeExecute)`
12
+ *
13
+ * @returns A tuple containing:
14
+ * - state: The current mutation state (render snapshot)
15
+ * - controls: An object with mutation actions and helpers
16
+ *
17
+ * @remarks
18
+ * - No retry mechanism is provided by default.
19
+ * - The mutation always resolves (never throws): the result contains either `data` or `error`.
20
+ * - If multiple executions triggered at the same time:
21
+ * - Only the latest execution is allowed to update the state.
22
+ * - Results from previous executions are ignored if a newer one exists.
23
+ */
24
+ export declare const useMutation: <TData, TVariable = undefined, TError = Error>(
25
+ /**
26
+ * Async function that performs the mutation.
27
+ *
28
+ * @remarks
29
+ * - Does NOT need to be memoized (e.g. `useCallback`).
30
+ * - The latest function reference is always used internally.
31
+ */
32
+ mutationFn: (variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable, TError>) => Promise<TData>,
33
+ /**
34
+ * Optional lifecycle callbacks.
35
+ *
36
+ * @remarks
37
+ * - Callbacks do NOT need to be memoized.
38
+ * - The latest callbacks are always used internally.
39
+ */
40
+ options?: MutationOptions<TData, TVariable, TError>) => [MutationState<TData, TVariable, TError>, {
41
+ /**
42
+ * Executes the mutation.
43
+ *
44
+ * @param variable - Input passed to the mutation function
45
+ *
46
+ * @returns A promise that always resolves with:
47
+ * - `{ data, variable }` on success
48
+ * - `{ error, variable }` on failure
49
+ *
50
+ * @remarks
51
+ * - The promise never rejects to simplify async handling.
52
+ * - If a mutation is already in progress, a warning is logged.
53
+ * - When a new execution starts, all previous pending executions will resolve with the result of the latest execution.
54
+ */
55
+ execute: TVariable extends undefined ? () => Promise<{
56
+ variable: undefined;
57
+ data?: TData;
58
+ error?: TError;
59
+ }> : (variable: TVariable) => Promise<{
60
+ variable: TVariable;
61
+ data?: TData;
62
+ error?: TError;
63
+ }>;
64
+ /**
65
+ * Resets the mutation state back to its initial state.
66
+ *
67
+ * @remarks
68
+ * - Does not cancel any ongoing execution.
69
+ * - If an execution is still pending, its result may override the reset state.
70
+ */
71
+ reset: () => void;
72
+ /**
73
+ * Returns the latest mutation state directly from the internal ref.
74
+ *
75
+ * @returns The most up-to-date mutation state.
76
+ *
77
+ * @remarks
78
+ * - Unlike the `state` returned by the hook, this value is not tied to React render cycles.
79
+ * - Use this inside async flows or event handlers to avoid stale reads.
80
+ */
81
+ getLatestState: () => MutationState<TData, TVariable, TError>;
82
+ }];
package/react.d.ts CHANGED
@@ -3,4 +3,5 @@ export { useStoreState } from './react/use-store';
3
3
  export * from './react/create-store';
4
4
  export * from './react/create-stores';
5
5
  export * from './react/create-query';
6
- export * from './react/create-mutation';
6
+ export { createMutation, type MutationOptions, type MutationState, } from './react/create-mutation';
7
+ export * from './react/use-mutation';
package/react.js CHANGED
@@ -130,7 +130,7 @@ const createQuery = (queryFn, options = {}) => {
130
130
  onSettled = vanilla.noop,
131
131
  shouldRetry: shouldRetryFn = (_, s) => s.retryCount === 0 ? [true, 1500] : [false]
132
132
  } = options;
133
- const initialState = INITIAL_STATE$1;
133
+ const initialState = { ...INITIAL_STATE$1 };
134
134
  const stores = /* @__PURE__ */ new Map();
135
135
  const configureStoreEvents = () => ({
136
136
  ...options,
@@ -369,7 +369,7 @@ const createQuery = (queryFn, options = {}) => {
369
369
  internals.set(store, configureInternals(store, variable, variableHash));
370
370
  }
371
371
  const useStore = (options2 = {}) => {
372
- const { enabled = true, keepPreviousData } = options2;
372
+ const { revalidateOnMount = true, keepPreviousData } = options2;
373
373
  const storeState = store.getState();
374
374
  const prevState = react.useRef({});
375
375
  let storeStateToBeUsed = storeState;
@@ -382,7 +382,7 @@ const createQuery = (queryFn, options = {}) => {
382
382
  storeStateToBeUsed = { ...storeState, ...prevState.current };
383
383
  }
384
384
  const [trackedState, usedPathsRef] = useStoreStateProxy(
385
- enabled && storeState.state === "INITIAL" ? (
385
+ revalidateOnMount && storeState.state === "INITIAL" ? (
386
386
  // Optimize rendering on initial state
387
387
  // Do { isPending: true } → result
388
388
  // instead of { isPending: false } → { isPending: true } → result
@@ -406,8 +406,8 @@ const createQuery = (queryFn, options = {}) => {
406
406
  });
407
407
  }, [store]);
408
408
  useIsomorphicLayoutEffect(() => {
409
- if (enabled !== false) revalidate(store, variable, false);
410
- }, [store, enabled]);
409
+ if (revalidateOnMount !== false) revalidate(store, variable, false);
410
+ }, [store, revalidateOnMount]);
411
411
  if (keepPreviousData) {
412
412
  !!trackedState.error;
413
413
  }
@@ -481,19 +481,26 @@ const INITIAL_STATE = {
481
481
  };
482
482
  const createMutation = (mutationFn, options = {}) => {
483
483
  const { onSuccess = vanilla.noop, onError, onSettled = vanilla.noop } = options;
484
- const initialState = INITIAL_STATE;
484
+ const initialState = { ...INITIAL_STATE };
485
+ let ongoingPromise;
486
+ const resolveFns = /* @__PURE__ */ new Set([]);
485
487
  const store = vanilla.initStore(initialState, options);
486
488
  const useStore = () => useStoreState(store.getState(), store.subscribe);
487
489
  const execute = (variable) => {
490
+ let currentResolveFn;
488
491
  const stateBeforeExecute = store.getState();
489
492
  if (stateBeforeExecute.isPending) {
490
493
  console.warn(
491
- "Mutation executed while a previous execution is still pending. This may cause race conditions or unexpected state updates."
494
+ "A mutation was executed while a previous execution is still pending. The previous execution will be ignored (latest execution wins)."
492
495
  );
493
496
  }
494
497
  store.setState({ isPending: true });
495
- return new Promise((resolve) => {
498
+ const promise = new Promise((resolve) => {
499
+ currentResolveFn = resolve;
496
500
  mutationFn(variable, stateBeforeExecute).then((data) => {
501
+ if (promise !== ongoingPromise) {
502
+ return resolve({ data, variable });
503
+ }
497
504
  store.setState({
498
505
  state: "SUCCESS",
499
506
  isPending: false,
@@ -506,8 +513,12 @@ const createMutation = (mutationFn, options = {}) => {
506
513
  errorUpdatedAt: void 0
507
514
  });
508
515
  resolve({ data, variable });
516
+ resolveFns.clear();
509
517
  onSuccess(data, variable, stateBeforeExecute);
510
518
  }).catch((error) => {
519
+ if (promise !== ongoingPromise) {
520
+ return resolve({ error, variable });
521
+ }
511
522
  store.setState({
512
523
  state: "ERROR",
513
524
  isPending: false,
@@ -520,12 +531,19 @@ const createMutation = (mutationFn, options = {}) => {
520
531
  errorUpdatedAt: Date.now()
521
532
  });
522
533
  resolve({ error, variable });
534
+ resolveFns.clear();
523
535
  if (onError) onError(error, variable, stateBeforeExecute);
524
536
  else console.error(store.getState());
525
537
  }).finally(() => {
538
+ if (promise !== ongoingPromise) return;
526
539
  onSettled(variable, stateBeforeExecute);
540
+ ongoingPromise = void 0;
527
541
  });
528
542
  });
543
+ if (ongoingPromise) resolveFns.forEach((resolveFn) => resolveFn(promise));
544
+ resolveFns.add(currentResolveFn);
545
+ ongoingPromise = promise;
546
+ return promise;
529
547
  };
530
548
  return Object.assign(useStore, {
531
549
  subscribe: store.subscribe,
@@ -552,17 +570,17 @@ const createMutation = (mutationFn, options = {}) => {
552
570
  * - `{ error, variable }` on failure
553
571
  *
554
572
  * @remarks
555
- * - If a mutation is already in progress, a warning is logged.
556
- * - Concurrent executions are allowed but may lead to race conditions.
557
573
  * - The promise never rejects to simplify async handling.
574
+ * - If a mutation is already in progress, a warning is logged.
575
+ * - When a new execution starts, all previous pending executions will resolve with the result of the latest execution.
558
576
  */
559
577
  execute,
560
578
  /**
561
579
  * Resets the mutation state back to its initial state.
562
580
  *
563
581
  * @remarks
564
- * - Does not cancel any ongoing request.
565
- * - If a request is still pending, its result may override the reset state.
582
+ * - Does not cancel any ongoing execution.
583
+ * - If an execution is still pending, its result may override the reset state.
566
584
  */
567
585
  reset: () => {
568
586
  if (store.getState().isPending) {
@@ -575,9 +593,113 @@ const createMutation = (mutationFn, options = {}) => {
575
593
  });
576
594
  };
577
595
 
596
+ const useMutation = (mutationFn, options = {}) => {
597
+ const { onSuccess = vanilla.noop, onError, onSettled = vanilla.noop } = options;
598
+ const callbackRef = react.useRef({ onSuccess, onError, onSettled });
599
+ callbackRef.current.onSuccess = onSuccess;
600
+ callbackRef.current.onError = onError;
601
+ callbackRef.current.onSettled = onSettled;
602
+ const stateRef = react.useRef({ ...INITIAL_STATE });
603
+ const [, reRender] = react.useState({});
604
+ const refs = react.useRef({
605
+ mutationFn,
606
+ ongoingPromise: void 0,
607
+ resolveFns: /* @__PURE__ */ new Set()
608
+ });
609
+ refs.current.mutationFn = mutationFn;
610
+ const execute = react.useCallback((variable) => {
611
+ let currentResolveFn;
612
+ const stateBeforeExecute = stateRef.current;
613
+ if (stateBeforeExecute.isPending) {
614
+ console.warn(
615
+ "A mutation was executed while a previous execution is still pending. The previous execution will be ignored (latest execution wins)."
616
+ );
617
+ }
618
+ stateRef.current.isPending = true;
619
+ reRender({});
620
+ const promise = new Promise(
621
+ (resolve) => {
622
+ currentResolveFn = resolve;
623
+ refs.current.mutationFn(variable, stateBeforeExecute).then((data) => {
624
+ if (promise !== refs.current.ongoingPromise) {
625
+ return resolve({ data, variable });
626
+ }
627
+ stateRef.current = {
628
+ state: "SUCCESS",
629
+ isPending: false,
630
+ isSuccess: true,
631
+ isError: false,
632
+ variable,
633
+ data,
634
+ dataUpdatedAt: Date.now(),
635
+ error: void 0,
636
+ errorUpdatedAt: void 0
637
+ };
638
+ reRender({});
639
+ resolve({ data, variable });
640
+ refs.current.resolveFns.clear();
641
+ callbackRef.current.onSuccess(data, variable, stateBeforeExecute);
642
+ }).catch((error) => {
643
+ if (promise !== refs.current.ongoingPromise) {
644
+ return resolve({ error, variable });
645
+ }
646
+ stateRef.current = {
647
+ state: "ERROR",
648
+ isPending: false,
649
+ isSuccess: false,
650
+ isError: true,
651
+ variable,
652
+ data: void 0,
653
+ dataUpdatedAt: void 0,
654
+ error,
655
+ errorUpdatedAt: Date.now()
656
+ };
657
+ reRender({});
658
+ resolve({ error, variable });
659
+ refs.current.resolveFns.clear();
660
+ if (callbackRef.current.onError) {
661
+ callbackRef.current.onError(error, variable, stateBeforeExecute);
662
+ } else {
663
+ console.error(stateRef.current);
664
+ }
665
+ }).finally(() => {
666
+ if (promise !== refs.current.ongoingPromise) return;
667
+ callbackRef.current.onSettled(variable, stateBeforeExecute);
668
+ refs.current.ongoingPromise = void 0;
669
+ });
670
+ }
671
+ );
672
+ if (refs.current.ongoingPromise) {
673
+ refs.current.resolveFns.forEach((resolveFn) => resolveFn(promise));
674
+ }
675
+ refs.current.resolveFns.add(currentResolveFn);
676
+ refs.current.ongoingPromise = promise;
677
+ return promise;
678
+ }, []);
679
+ const reset = react.useCallback(() => {
680
+ if (stateRef.current.isPending) {
681
+ console.warn(
682
+ "Mutation state was reset while a request is still pending. The request will continue, but its result may override the reset state."
683
+ );
684
+ }
685
+ stateRef.current = { ...INITIAL_STATE };
686
+ reRender({});
687
+ }, []);
688
+ const r = [
689
+ stateRef.current,
690
+ {
691
+ execute,
692
+ reset,
693
+ getLatestState: () => stateRef.current
694
+ }
695
+ ];
696
+ return r;
697
+ };
698
+
578
699
  exports.createMutation = createMutation;
579
700
  exports.createQuery = createQuery;
580
701
  exports.createStore = createStore;
581
702
  exports.createStores = createStores;
582
703
  exports.useIsomorphicLayoutEffect = useIsomorphicLayoutEffect;
704
+ exports.useMutation = useMutation;
583
705
  exports.useStoreState = useStoreState;