Repository: tannerlinsley/react-query
Pull request #3320 (2022-02-20 12:15:43)

feat(tkdodos-blog): add Offline React Query post

vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: [https://vercel.com/tanstack/react-query/2j6jFWe33ysXCnCyVtcFpnCurWkj](https://vercel.com/tanstack/react-query/2j6jFWe33ysXCnCyVtcFpnCurWkj)  
✅ Preview: *In Progress*


codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/tannerlinsley/react-query/pr/3320/builds/220676) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit e55d5fcd24cbe27f235f9de9b93d403819bf60dd:

|Sandbox| Source |
|--|--|
|[tannerlinsley/react-query: basic](https://codesandbox.io/s/tannerlinsley-react-query-basic-hdiyhx)| Configuration |
|[tannerlinsley/react-query: basic-typescript](https://codesandbox.io/s/tannerlinsley-react-query-basic-typescript-xin8l4)| Configuration |

TkDodo: Thank you. I wanted to wait with this until v4 was released - but maybe you can make this go to the `alpha` branch instead? Then we would have it on https://react-query-alpha.tanstack.com :)

Patch:
diff --git a/docs/src/pages/community/tkdodos-blog.md b/docs/src/pages/community/tkdodos-blog.md
index 044125d21..c214463e3 100644
--- a/docs/src/pages/community/tkdodos-blog.md
+++ b/docs/src/pages/community/tkdodos-blog.md
@@ -53,3 +53,8 @@ React Query maintainer [TkDodo](https://twitter.com/tkdodo) has a series of blog
 ## [#12: Mastering Mutations in React Query](https://tkdodo.eu/blog/mastering-mutations-in-react-query)
 
 > Mutations are the important, second part necessary to work with server data - for situations where you need to update it. This blog post covers what mutations are and how they are different from queries. You'll learn the difference between `mutate` and `mutateAsync` as well as how you can tie queries and mutations together. [Read more...](https://tkdodo.eu/blog/mastering-mutations-in-react-query)
+
+
+## [#13: Offline React Query](https://tkdodo.eu/blog/offline-react-query)
+
+> There are many ways to produce promises - which is everything React Query needs - but by far the biggest use-case is data fetching. Very often, that requires an active network connection. But sometimes, especially on mobile devices where, the network connection can be unreliable, you need your app to also work without it. In this article, you'll learn about the different offline strategies React Query offers. [Read more...](https://tkdodo.eu/blog/offline-react-query)
diff --git a/docs/src/pages/comparison.md b/docs/src/pages/comparison.md
index dfa06c741..439d3b13c 100644
--- a/docs/src/pages/comparison.md
+++ b/docs/src/pages/comparison.md
@@ -23,7 +23,7 @@ Feature/Capability Key:
 | Query Key Change Detection                         | Deep Compare (Stable Serialization)      | Shallow Compare             | Deep Compare (Unstable Serialization) | Referential Equality (===)           |
 | Query Data Change Detection                        | Deep Comparison + Structural Sharing     | Deep Compare (via `dequal`) | Deep Compare (Unstable Serialization) | Referential Equality (===)           |
 | Query Data Memoization Level                       | Query + Structural Sharing               | Query                       | Query + Entity + Structural Sharing   | Query                                |
-| Bundle Size                                        | [![][bp-react-query]][bpl-react-query]   | [![][bp-swr]][bpl-swr]      | [![][bp-apollo]][bpl-apollo]          | [![][bp-rtk-query]][bpl-rtk-query]   |
+| Bundle Size<sup>8</sup>                            | [![][bp-react-query]][bpl-react-query]   | [![][bp-swr]][bpl-swr]      | [![][bp-apollo]][bpl-apollo]          | [![][bp-rtk-query]][bpl-rtk-query]   |
 | API Definition                                     | On-Use, Declarative                      | On-Use                      | GraphQL Schema                        | Declarative                          |
 | Queries                                            | ✅                                       | ✅                          | ✅                                    | ✅                                   |
 | Caching                                            | ✅                                       | ✅                          | ✅                                    | ✅                                   |
@@ -74,7 +74,9 @@ Feature/Capability Key:
 
 > **<sup>6</sup> Normalized Caching** - React Query, SWR and RTK-Query do not currently support automatic-normalized caching which describes storing entities in a flat architecture to avoid some high-level data duplication.
 
-> **<sup>6</sup> SWR's Immutable Mode** - SWR ships with an "immutable" mode that does allow you to only fetch a query once for the life of the cache, but it still does not have the concept of stale-time or conditional auto-revalidation
+> **<sup>7</sup> SWR's Immutable Mode** - SWR ships with an "immutable" mode that does allow you to only fetch a query once for the life of the cache, but it still does not have the concept of stale-time or conditional auto-revalidation
+
+> **<sup>8</sup> RTK-Query's bundle size** [can vary][rtk-query-bundle-size] depending on whether you already use redux-toolkit or not
 
 <!-- -->
 
@@ -103,9 +105,10 @@ Feature/Capability Key:
 
 [rtk-query]: https://redux-toolkit.js.org/rtk-query/overview
 [rtk-query-comparison]: https://redux-toolkit.js.org/rtk-query/comparison
+[rtk-query-bundle-size]: https://redux-toolkit.js.org/rtk-query/comparison#bundle-size
 [bp-rtk]: https://badgen.net/bundlephobia/minzip/@reduxjs/toolkit?label=💾
-[bp-rtk-query]: https://badgen.net/bundlephobia/minzip/@rtk-incubator/rtk-query?label=💾
-[gh-rtk-query]: https://github.com/rtk-incubator/rtk-query
-[stars-rtk-query]: https://img.shields.io/github/stars/rtk-incubator/rtk-query?label=%F0%9F%8C%9F
+[bp-rtk-query]: https://badgen.net/bundlephobia/minzip/@reduxjs/toolkit?label=💾
+[gh-rtk-query]: https://github.com/reduxjs/redux-toolkit
+[stars-rtk-query]: https://img.shields.io/github/stars/reduxjs/redux-toolkit?label=🌟
 [bpl-rtk]: https://bundlephobia.com/result?p=@reduxjs/toolkit
-[bpl-rtk-query]: https://bundlephobia.com/result?p=@rtk-incubator/rtk-query
+[bpl-rtk-query]: https://bundlephobia.com/package/@reduxjs/toolkit
diff --git a/docs/src/pages/plugins/persistQueryClient.md b/docs/src/pages/plugins/persistQueryClient.md
index 75aaac23a..aa31b064d 100644
--- a/docs/src/pages/plugins/persistQueryClient.md
+++ b/docs/src/pages/plugins/persistQueryClient.md
@@ -3,25 +3,25 @@ id: persistQueryClient
 title: persistQueryClient
 ---
 
-`persistQueryClient` is a utility for persisting the state of your queryClient and its caches for later use. Different **persisters** can be used to store your client and cache to many different storage layers.
+This is set of utilities for interacting with "persisters" which save your queryClient for later use. Different **persisters** can be used to store your client and cache to many different storage layers.
 
-## Officially Supported Persisters
+## Build Persisters
 
 - [createWebStoragePersister](/plugins/createWebStoragePersister)
 - [createAsyncStoragePersister](/plugins/createAsyncStoragePersister)
+- [create a custom persister](#persisters)
 
-## Installation
+## How It Works
 
-This utility comes packaged with `react-query` and is available under the `react-query/persistQueryClient` import.
+**IMPORTANT** - for persist to work properly, you probably want to pass `QueryClient` a `cacheTime` value to override the default during hydration (as shown above).
 
-## Usage
+If it is not set when creating the `QueryClient` instance, it will default to `300000` (5 minutes) for hydration, and the stored cache will be discarded after 5 minutes of inactivity. This is the default garbage collection behavior.
 
-Import the `persistQueryClient` function, and pass it your `QueryClient` instance (with a `cacheTime` set), and a Persister interface (there are multiple persister types you can use):
+It should be set as the same value or higher than persistQueryClient's `maxAge` option. E.g. if `maxAge` is 24 hours (the default) then `cacheTime` should be 24 hours or higher. If lower than `maxAge`, garbage collection will kick in and discard the stored cache earlier than expected.
 
-```ts
-import { persistQueryClient } from 'react-query/persistQueryClient'
-import { createWebStoragePersister } from 'react-query/createWebStoragePersister'
+You can also pass it `Infinity` to disable garbage collection behavior entirely.
 
+```ts
 const queryClient = new QueryClient({
   defaultOptions: {
     queries: {
@@ -29,76 +29,59 @@ const queryClient = new QueryClient({
     },
   },
 })
-
-const localStoragePersister = createWebStoragePersister({
-  storage: window.localStorage,
-})
-
-persistQueryClient({
-  queryClient,
-  persister: localStoragePersister,
-})
 ```
 
-**IMPORTANT** - for persist to work properly, you need to pass `QueryClient` a `cacheTime` value to override the default during hydration (as shown above).
-
-If it is not set when creating the `QueryClient` instance, it will default to `300000` (5 minutes) for hydration, and the stored cache will be discarded after 5 minutes of inactivity. This is the default garbage collection behavior.
-
-It should be set as the same value or higher than persistQueryClient's `maxAge` option. E.g. if `maxAge` is 24 hours (the default) then `cacheTime` should be 24 hours or higher. If lower than `maxAge`, garbage collection will kick in and discard the stored cache earlier than expected.
-
-You can also pass it `Infinity` to disable garbage collection behavior entirely.
+### Environment Checking
+A check for window `undefined` is performed prior to saving/restoring/removing your data (avoids build errors).
 
-## How does it work?
+### Cache Busting
 
-- A check for window `undefined` is performed prior to saving/restoring/removing your data (avoids build errors).
-
-### Storing
-
-As you use your application:
-
-- When your query/mutation cache is updated, it will be [`dehydrated`](../reference/hydration#dehydrate) and stored by the persister you provided. The officially supported persisters throttle this action to happen at most every 1 second to save on potentially expensive writes, but can be customized as you see fit.
-
-#### Cache Busting
-
-Sometimes you may make changes to your application or data that immediately invalidate any and all cached data. If and when this happens, you can pass a `buster` string option to `persistQueryClient`, and if the cache that is found does not also have that buster string, it will be discarded.
+Sometimes you may make changes to your application or data that immediately invalidate any and all cached data. If and when this happens, you can pass a `buster` string option. If the cache that is found does not also have that buster string, it will be discarded.  The following several functions accept this option:
 
 ```ts
 persistQueryClient({ queryClient, persister, buster: buildHash })
+persistQueryClientSave({ queryClient, persister, buster: buildHash })
+persistQueryClientRestore({ queryClient, persister, buster: buildHash })
 ```
 
-### Restoring
-
-When you reload/bootstrap your app:
+### Removal
 
-- Attempts to [`hydrate`](../reference/hydration#hydrate) a previously persisted dehydrated query/mutation cache from the persister back into the query cache of the passed query client.
-- If a cache is found that is older than the `maxAge` (which by default is 24 hours), it will be discarded. This can be customized as you see fit.
+If data is found to be any of the following:
 
-### Removal
+1. expired (see `maxAge`)
+2. busted (see `buster`)
+3. error (ex: `throws ...`)
+4. empty (ex: `undefined`)
 
-- If data is found to be expired (see `maxAge`), busted (see `buster`), error (ex: `throws ...`), or empty (ex: `undefined`), the persister `removeClient()` is called and the cache is immediately discarded.
+the persister `removeClient()` is called and the cache is immediately discarded.
 
 ## API
 
-### `persistQueryClientRestore`
+### `persistQueryClientSave`
+
+- Your query/mutation are [`dehydrated`](../reference/hydration#dehydrate) and stored by the persister you provided. 
+- `createWebStoragePersister` and `createAsyncStoragePersister` throttle this action to happen at most every 1 second to save on potentially expensive writes.  Review their documentation to see how to customize their throttle timing. 
 
-This will attempt to restore a persister's stored cached to the query cache of the passed queryClient.
+You can use this to explicitly persist the cache at the moment(s) you choose.
 
 ```ts
-persistQueryClientRestore({
+persistQueryClientSave({
   queryClient,
   persister,
-  maxAge = 1000 * 60 * 60 * 24, // 24 hours
   buster = '',
-  hydrateOptions = undefined,
+  dehydrateOptions = undefined,
 })
 ```
 
-### `persistQueryClientSave`
+### `persistQueryClientSubscribe`
+
+Runs `persistQueryClientSave` whenever the cache changes for your `queryClient`. For example: you might initiate the `subscribe` when a user logs-in and checks "Remember me".
 
-This will attempt to save the current query cache with the persister. You can use this to explicitly persist the cache at the moments you choose.
+- It returns an `unsubscribe` function which you can use to discontinue the monitor; ending the updates to the persisted cache.
+- If you want to erase the persisted cache after the `unsubscribe`, you can send a new `buster` to `persistQueryClientRestore` which will trigger the persister's `removeClient` function and discard the persisted cache.
 
 ```ts
-persistQueryClientSave({
+persistQueryClientSubscribe({
   queryClient,
   persister,
   buster = '',
@@ -106,25 +89,31 @@ persistQueryClientSave({
 })
 ```
 
-### `persistQueryClientSubscribe`
+### `persistQueryClientRestore`
 
-This will subscribe to query cache updates which will run `persistQueryClientSave`. For example: you might initiate the `subscribe` when a user logs-in and checks "Remember me".
+- Attempts to [`hydrate`](../reference/hydration#hydrate) a previously persisted dehydrated query/mutation cache from the persister back into the query cache of the passed query client.
+- If a cache is found that is older than the `maxAge` (which by default is 24 hours), it will be discarded. This timing can be customized as you see fit.
 
-- It returns an `unsubscribe` function which you can use to discontinue the monitor; ending the updates to the persisted cache.
-- If you want to erase the persisted cache after the `unsubscribe`, you can send a new `buster` to `persistQueryClientRestore` which will trigger the persister's `removeClient` function and discard the persisted cache.
+You can use this to restore the cache at moment(s) you choose.
 
 ```ts
-persistQueryClientSubscribe({
+persistQueryClientRestore({
   queryClient,
   persister,
+  maxAge = 1000 * 60 * 60 * 24, // 24 hours
   buster = '',
-  dehydrateOptions = undefined,
+  hydrateOptions = undefined,
 })
 ```
 
 ### `persistQueryClient`
 
-This will automatically restore any persisted cache and subscribes to the query cache to persist any changes from the query cache to the persister. It returns an `unsubscribe` function which you can use to discontinue the monitor; ending the updates to the persisted cache.
+Takes the following actions:
+
+1. Immediately restores any persisted cache ([see `persistQueryClientRestore`](#persistqueryclientrestore))
+2. Subscribes to the query cache and returns the `unsubscribe` function ([see `persistQueryClientSubscribe`](#persistqueryclientsubscribe)).
+
+This functionality is preserved from version 3.x.
 
 ```ts
 persistQueryClient({
@@ -139,7 +128,7 @@ persistQueryClient({
 
 ### `Options`
 
-An object of options:
+All options available are as follows:
 
 ```ts
 interface PersistQueryClientOptions {
@@ -156,16 +145,25 @@ interface PersistQueryClientOptions {
   /** A unique string that can be used to forcefully
    * invalidate existing caches if they do not share the same buster string */
   buster?: string
-  /** The options passed to the hydrate function */
+  /** The options passed to the hydrate function
+   * Not used on `persistQueryClientSave` or `persistQueryClientSubscribe` */
   hydrateOptions?: HydrateOptions
-  /** The options passed to the dehydrate function */
+  /** The options passed to the dehydrate function 
+  * Not used on `persistQueryClientRestore` */
   dehydrateOptions?: DehydrateOptions
 }
 ```
 
-## Building a Persister
+There are actually three interfaces available: 
+- `PersistedQueryClientSaveOptions` is used for `persistQueryClientSave` and `persistQueryClientSubscribe` (doesn't use `hydrateOptions`).
+- `PersistedQueryClientRestoreOptions` is used for `persistQueryClientRestore` (doesn't use `dehydrateOptions`).
+- `PersistQueryClientOptions` is used for `persistQueryClient`
 
-Persisters have the following interface:
+## Persisters
+
+### Persisters Interface
+
+Persisters have the following interfaces:
 
 ```ts
 export interface Persister {
@@ -185,4 +183,33 @@ export interface PersistedClient {
 }
 ```
 
-Satisfy all of these interfaces and you've got yourself a persister!
+You can import these (to build a persister):
+```ts
+import { PersistedClient, Persister } from "react-query/persistQueryClient";
+```
+
+### Building A Persister
+You can persist however you like.  Here is an example of how to build an [Indexed DB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) persister. Compared to `Web Storage API`, Indexed DB is faster, stores more than 5MB, and doesn't require serialization.  That means it can readily store Javascript native types, such as `Date` and `File`.
+
+```ts
+import { get, set, del } from "idb-keyval";
+import { PersistedClient, Persister } from "react-query/persistQueryClient";
+
+/**
+ * Creates an Indexed DB persister
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
+ */
+export function createIDBPersister(idbValidKey: IDBValidKey = "reactQuery") {
+  return {
+    persistClient: async (client: PersistedClient) => {
+      set(idbValidKey, client);
+    },
+    restoreClient: async () => {
+      return await get<PersistedClient>(idbValidKey);
+    },
+    removeClient: async () => {
+      await del(idbValidKey);
+    },
+  } as Persister;
+}
+```
diff --git a/examples/simple/src/index.js b/examples/simple/src/index.js
index 1d63f87ae..7f0e64c0f 100644
--- a/examples/simple/src/index.js
+++ b/examples/simple/src/index.js
@@ -3,6 +3,7 @@ import React from "react";
 import ReactDOM from "react-dom";
 import { QueryClient, QueryClientProvider, useQuery } from "react-query";
 import { ReactQueryDevtools } from "react-query/devtools";
+import axios from "axios";
 
 const queryClient = new QueryClient();
 
@@ -16,9 +17,9 @@ export default function App() {
 
 function Example() {
   const { isLoading, error, data, isFetching } = useQuery(["repoData"], () =>
-    fetch(
+    axios.get(
       "https://api.github.com/repos/tannerlinsley/react-query"
-    ).then((res) => res.json())
+    ).then((res) => res.data)
   );
 
   if (isLoading) return "Loading...";
diff --git a/src/core/infiniteQueryObserver.ts b/src/core/infiniteQueryObserver.ts
index 063b01ae8..1622ed077 100644
--- a/src/core/infiniteQueryObserver.ts
+++ b/src/core/infiniteQueryObserver.ts
@@ -1,4 +1,5 @@
 import type {
+  DefaultedInfiniteQueryObserverOptions,
   FetchNextPageOptions,
   FetchPreviousPageOptions,
   InfiniteData,
@@ -84,7 +85,7 @@ export class InfiniteQueryObserver<
   }
 
   getOptimisticResult(
-    options: InfiniteQueryObserverOptions<
+    options: DefaultedInfiniteQueryObserverOptions<
       TQueryFnData,
       TError,
       TData,
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index f22860667..95b0d080b 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -1,4 +1,4 @@
-import { RefetchPageFilters } from './types'
+import { DefaultedQueryObserverOptions, RefetchPageFilters } from './types'
 import {
   isServer,
   isValidTimeout,
@@ -205,7 +205,7 @@ export class QueryObserver<
   }
 
   getOptimisticResult(
-    options: QueryObserverOptions<
+    options: DefaultedQueryObserverOptions<
       TQueryFnData,
       TError,
       TData,
@@ -213,13 +213,9 @@ export class QueryObserver<
       TQueryKey
     >
   ): QueryObserverResult<TData, TError> {
-    const defaultedOptions = this.client.defaultQueryOptions(options)
-
-    const query = this.client
-      .getQueryCache()
-      .build(this.client, defaultedOptions)
+    const query = this.client.getQueryCache().build(this.client, options)
 
-    return this.createResult(query, defaultedOptions)
+    return this.createResult(query, options)
   }
 
   getCurrentResult(): QueryObserverResult<TData, TError> {
diff --git a/src/core/types.ts b/src/core/types.ts
index 0dab1232c..8ec2927b9 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -246,6 +246,23 @@ export interface InfiniteQueryObserverOptions<
     TQueryKey
   > {}
 
+export type DefaultedInfiniteQueryObserverOptions<
+  TQueryFnData = unknown,
+  TError = unknown,
+  TData = TQueryFnData,
+  TQueryData = TQueryFnData,
+  TQueryKey extends QueryKey = QueryKey
+> = WithRequired<
+  InfiniteQueryObserverOptions<
+    TQueryFnData,
+    TError,
+    TData,
+    TQueryData,
+    TQueryKey
+  >,
+  'useErrorBoundary' | 'refetchOnReconnect'
+>
+
 export interface FetchQueryOptions<
   TQueryFnData = unknown,
   TError = unknown,
diff --git a/src/createAsyncStoragePersister/asyncThrottle.ts b/src/createAsyncStoragePersister/asyncThrottle.ts
new file mode 100644
index 000000000..940f03df6
--- /dev/null
+++ b/src/createAsyncStoragePersister/asyncThrottle.ts
@@ -0,0 +1,58 @@
+export interface AsyncThrottleOptions {
+  interval?: number
+  onError?: (error: unknown) => void
+}
+
+const noop = () => {
+  /* do nothing */
+}
+
+export function asyncThrottle<Args extends readonly unknown[]>(
+  func: (...args: Args) => Promise<void>,
+  { interval = 1000, onError = noop }: AsyncThrottleOptions = {}
+) {
+  if (typeof func !== 'function') throw new Error('argument is not function.')
+
+  let running = false
+  let lastTime = 0
+  let timeout: ReturnType<typeof setTimeout>
+  let currentArgs: Args | null = null
+
+  const execFunc = async () => {
+    if (currentArgs) {
+      const args = currentArgs
+      currentArgs = null
+      try {
+        running = true
+        await func(...args)
+      } catch (error) {
+        onError(error)
+      } finally {
+        lastTime = Date.now() // this line must after 'func' executed to avoid two 'func' running in concurrent.
+        running = false
+      }
+    }
+  }
+
+  const delayFunc = async () => {
+    clearTimeout(timeout)
+    timeout = setTimeout(() => {
+      if (running) {
+        delayFunc() // Will come here when 'func' execution time is greater than the interval.
+      } else {
+        execFunc()
+      }
+    }, interval)
+  }
+
+  return (...args: Args) => {
+    currentArgs = args
+
+    const tooSoon = Date.now() - lastTime < interval
+    if (running || tooSoon) {
+      delayFunc()
+    } else {
+      execFunc()
+    }
+  }
+}
diff --git a/src/createAsyncStoragePersister/index.ts b/src/createAsyncStoragePersister/index.ts
index 9a1b7b667..57122af0b 100644
--- a/src/createAsyncStoragePersister/index.ts
+++ b/src/createAsyncStoragePersister/index.ts
@@ -1,4 +1,5 @@
 import { PersistedClient, Persister } from '../persistQueryClient'
+import { asyncThrottle } from './asyncThrottle'
 
 interface AsyncStorage {
   getItem: (key: string) => Promise<string | null>
@@ -50,43 +51,3 @@ export const createAsyncStoragePersister = ({
     removeClient: () => storage.removeItem(key),
   }
 }
-
-function asyncThrottle<Args extends readonly unknown[], Result>(
-  func: (...args: Args) => Promise<Result>,
-  { interval = 1000, limit = 1 }: { interval?: number; limit?: number } = {}
-) {
-  if (typeof func !== 'function') throw new Error('argument is not function.')
-  const running = { current: false }
-  let lastTime = 0
-  let timeout: ReturnType<typeof setTimeout>
-  const queue: Array<Args> = []
-  return (...args: Args) =>
-    (async () => {
-      if (running.current) {
-        lastTime = Date.now()
-        if (queue.length > limit) {
-          queue.shift()
-        }
-
-        queue.push(args)
-        clearTimeout(timeout)
-      }
-      if (Date.now() - lastTime > interval) {
-        running.current = true
-        await func(...args)
-        lastTime = Date.now()
-        running.current = false
-      } else {
-        if (queue.length > 0) {
-          const lastArgs = queue[queue.length - 1]!
-          timeout = setTimeout(async () => {
-            if (!running.current) {
-              running.current = true
-              await func(...lastArgs)
-              running.current = false
-            }
-          }, interval)
-        }
-      }
-    })()
-}


Test patch:
diff --git a/src/createAsyncStoragePersister/tests/asyncThrottle.test.ts b/src/createAsyncStoragePersister/tests/asyncThrottle.test.ts
new file mode 100644
index 000000000..54f7ee3dd
--- /dev/null
+++ b/src/createAsyncStoragePersister/tests/asyncThrottle.test.ts
@@ -0,0 +1,127 @@
+import { asyncThrottle } from '../asyncThrottle'
+import { sleep as delay } from '../../reactjs/tests/utils'
+
+describe('asyncThrottle', () => {
+  test('basic', async () => {
+    const interval = 10
+    const execTimeStamps: number[] = []
+    const mockFunc = jest.fn(
+      async (id: number, complete?: (value?: unknown) => void) => {
+        await delay(1)
+        execTimeStamps.push(Date.now())
+        if (complete) {
+          complete(id)
+        }
+      }
+    )
+    const testFunc = asyncThrottle(mockFunc, { interval })
+
+    testFunc(1)
+    await delay(1)
+    testFunc(2)
+    await delay(1)
+    await new Promise(resolve => testFunc(3, resolve))
+
+    expect(mockFunc).toBeCalledTimes(2)
+    expect(mockFunc.mock.calls[1]?.[0]).toBe(3)
+    expect(execTimeStamps.length).toBe(2)
+    expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual(
+      interval
+    )
+  })
+
+  test('Bug #3331 case 1: Special timing', async () => {
+    const interval = 1000
+    const execTimeStamps: number[] = []
+    const mockFunc = jest.fn(
+      async (id: number, complete?: (value?: unknown) => void) => {
+        await delay(30)
+        execTimeStamps.push(Date.now())
+        if (complete) {
+          complete(id)
+        }
+      }
+    )
+    const testFunc = asyncThrottle(mockFunc, { interval })
+
+    testFunc(1)
+    testFunc(2)
+    await delay(35)
+    testFunc(3)
+    await delay(35)
+    await new Promise(resolve => testFunc(4, resolve))
+
+    expect(mockFunc).toBeCalledTimes(2)
+    expect(mockFunc.mock.calls[1]?.[0]).toBe(4)
+    expect(execTimeStamps.length).toBe(2)
+    expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual(
+      interval
+    )
+  })
+
+  test('Bug #3331 case 2: "func" execution time is greater than the interval.', async () => {
+    const interval = 1000
+    const execTimeStamps: number[] = []
+    const mockFunc = jest.fn(
+      async (id: number, complete?: (value?: unknown) => void) => {
+        await delay(interval + 10)
+        execTimeStamps.push(Date.now())
+        if (complete) {
+          complete(id)
+        }
+      }
+    )
+    const testFunc = asyncThrottle(mockFunc, { interval })
+
+    testFunc(1)
+    testFunc(2)
+    await new Promise(resolve => testFunc(3, resolve))
+
+    expect(mockFunc).toBeCalledTimes(2)
+    expect(mockFunc.mock.calls[1]?.[0]).toBe(3)
+    expect(execTimeStamps.length).toBe(2)
+    expect(execTimeStamps[1]! - execTimeStamps[0]!).toBeGreaterThanOrEqual(
+      interval
+    )
+  })
+
+  test('"func" throw error not break next invoke', async () => {
+    const mockFunc = jest.fn(
+      async (id: number, complete?: (value?: unknown) => void) => {
+        if (id === 1) throw new Error('error')
+        await delay(1)
+        if (complete) {
+          complete(id)
+        }
+      }
+    )
+    const testFunc = asyncThrottle(mockFunc, { interval: 10 })
+
+    testFunc(1)
+    await delay(1)
+    await new Promise(resolve => testFunc(2, resolve))
+
+    expect(mockFunc).toBeCalledTimes(2)
+    expect(mockFunc.mock.calls[1]?.[0]).toBe(2)
+  })
+
+  test('"onError" should be called when "func" throw error', done => {
+    const err = new Error('error')
+    const handleError = (e: unknown) => {
+      expect(e).toBe(err)
+      done()
+    }
+
+    const testFunc = asyncThrottle(
+      () => {
+        throw err
+      },
+      { onError: handleError }
+    )
+    testFunc()
+  })
+
+  test('should throw error when "func" is not a function', () => {
+    expect(() => asyncThrottle(1 as any)).toThrowError()
+  })
+})