diff --git a/apps/docs/docs/getting-started/mutation.md b/apps/docs/docs/getting-started/mutation.md
index 09caa0ff..33be0907 100644
--- a/apps/docs/docs/getting-started/mutation.md
+++ b/apps/docs/docs/getting-started/mutation.md
@@ -20,7 +20,7 @@ There are multiple way to select the account used for signing.
import { ReDotSignerProvider } from "@reactive-dot/react";
const App = () => (
- {/** ... */}
+ {/* ... */}
);
```
diff --git a/apps/docs/docs/getting-started/query.md b/apps/docs/docs/getting-started/query.md
index a646fe28..0447c3cb 100644
--- a/apps/docs/docs/getting-started/query.md
+++ b/apps/docs/docs/getting-started/query.md
@@ -116,3 +116,37 @@ const QueryWithRefresh = () => {
);
};
```
+
+## Retry failed query
+
+Error from queries can be caught and reset using `ErrorBoundary` & [`useResetQueryError`](/api/react/function/useResetQueryError) hook.
+
+```tsx
+import { useResetQueryError } from "@reactive-dot/react";
+import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
+
+const ErrorFallback = (props: FallbackProps) => (
+
+ Oops, something went wrong!
+
+
+);
+
+const AppErrorBoundary = () => {
+ const resetQueryError = useResetQueryError();
+
+ return (
+ {
+ if (details.reason === "imperative-api") {
+ const [error] = details.args;
+ resetQueryError(error);
+ }
+ }}
+ >
+ {/* ... */}
+
+ );
+};
+```
diff --git a/apps/docs/docs/getting-started/setup.mdx b/apps/docs/docs/getting-started/setup.mdx
index af4e9d87..b9e16afb 100644
--- a/apps/docs/docs/getting-started/setup.mdx
+++ b/apps/docs/docs/getting-started/setup.mdx
@@ -101,8 +101,8 @@ import { Suspense } from "react";
const App = () => (
- {/** Make sure there is at least one Suspense boundary wrapping the app */}
- {/** ... */}
+ {/* Make sure there is at least one Suspense boundary wrapping the app */}
+ {/* ... */}
);
diff --git a/apps/example/package.json b/apps/example/package.json
index f30976e8..d3e7d138 100644
--- a/apps/example/package.json
+++ b/apps/example/package.json
@@ -15,7 +15,8 @@
"date-fns": "^3.6.0",
"polkadot-api": "^0.8.0",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "react-error-boundary": "^4.0.13"
},
"devDependencies": {
"@reactive-dot/eslint-config": "workspace:^",
diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx
index df831df8..b73b8c97 100644
--- a/apps/example/src/App.tsx
+++ b/apps/example/src/App.tsx
@@ -3,6 +3,7 @@ import { IDLE, MutationError, PENDING } from "@reactive-dot/core";
import {
ReDotChainProvider,
ReDotProvider,
+ useResetQueryError,
useAccounts,
useBlock,
useConnectedWallets,
@@ -14,6 +15,7 @@ import {
import { formatDistance } from "date-fns";
import { Binary } from "polkadot-api";
import { Suspense, useState, useTransition } from "react";
+import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
const PendingPoolRewards = () => {
const accounts = useAccounts();
@@ -236,34 +238,57 @@ const Mutation = () => {
);
};
-const Example = () => (
-
-
-
-
+const ErrorFallback = (props: FallbackProps) => (
+
+
+ Oops, something went wrong!
+
+
+
);
+type ExampleProps = { chainName: string };
+
+const Example = (props: ExampleProps) => {
+ const resetQueryError = useResetQueryError();
+
+ return (
+
+ {
+ if (details.reason === "imperative-api") {
+ const [error] = details.args;
+ resetQueryError(error);
+ }
+ }}
+ >
+ Loading {props.chainName}...}>
+ {props.chainName}
+
+
+
+
+
+ );
+};
+
const App = () => (
- Loading Polkadot...}>
- Polkadot
-
-
+
Loading Kusama...}>
- Kusama
-
+
Loading Westend...}>
- Westend
-
+
diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts
index d3ee560e..b11ca179 100644
--- a/packages/react/src/hooks/useQuery.ts
+++ b/packages/react/src/hooks/useQuery.ts
@@ -4,7 +4,7 @@ import {
queryPayloadAtomFamily,
} from "../stores/query.js";
import type { Falsy, FalsyGuard, FlatHead } from "../types.js";
-import { flatHead, stringify } from "../utils.js";
+import { flatHead, stringify } from "../utils/vanilla.js";
import type { ChainHookOptions } from "./types.js";
import {
Query,
diff --git a/packages/react/src/hooks/useResetQueryError.ts b/packages/react/src/hooks/useResetQueryError.ts
new file mode 100644
index 00000000..80256231
--- /dev/null
+++ b/packages/react/src/hooks/useResetQueryError.ts
@@ -0,0 +1,8 @@
+import { resetQueryError } from "../utils/jotai.js";
+
+/**
+ * Hook for getting function to reset query error caught by error boundary
+ *
+ * @returns Function to reset caught query error
+ */
+export const useResetQueryError = () => resetQueryError;
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 791296fa..5b4019b8 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -14,3 +14,4 @@ export { useMutation } from "./hooks/useMutation.js";
export { useQuery, useQueryWithRefresh } from "./hooks/useQuery.js";
export { useTypedApi } from "./hooks/useTypedApi.js";
export { useConnectedWallets, useWallets } from "./hooks/useWallets.js";
+export { useResetQueryError } from "./hooks/useResetQueryError.js";
diff --git a/packages/react/src/stores/query.ts b/packages/react/src/stores/query.ts
index 8271dcb8..c2b86a6d 100644
--- a/packages/react/src/stores/query.ts
+++ b/packages/react/src/stores/query.ts
@@ -1,4 +1,5 @@
-import { stringify } from "../utils.js";
+import { withAtomFamilyErrorCatcher } from "../utils/jotai.js";
+import { stringify } from "../utils/vanilla.js";
import { typedApiAtomFamily } from "./client.js";
import {
QueryInstruction,
@@ -24,13 +25,21 @@ const instructionPayloadAtomFamily = atomFamily(
}): Atom | WritableAtom, [], void> => {
switch (preflight(param.instruction)) {
case "promise":
- return atomWithRefresh(async (get, { signal }) => {
+ return withAtomFamilyErrorCatcher(
+ instructionPayloadAtomFamily,
+ param,
+ atomWithRefresh,
+ )(async (get, { signal }) => {
const api = await get(typedApiAtomFamily(param.chainId));
return query(api, param.instruction, { signal });
});
case "observable":
- return atomWithObservable((get) =>
+ return withAtomFamilyErrorCatcher(
+ instructionPayloadAtomFamily,
+ param,
+ atomWithObservable,
+ )((get) =>
from(get(typedApiAtomFamily(param.chainId))).pipe(
switchMap(
(api) => query(api, param.instruction) as Observable,
@@ -67,8 +76,12 @@ export const getQueryInstructionPayloadAtoms = (
// TODO: should be memoized within render function instead
// https://github.com/pmndrs/jotai/discussions/1553
export const queryPayloadAtomFamily = atomFamily(
- (param: { chainId: ChainId; query: Query }) =>
- atom((get) => {
+ (param: { chainId: ChainId; query: Query }): Atom =>
+ withAtomFamilyErrorCatcher(
+ queryPayloadAtomFamily,
+ param,
+ atom,
+ )((get) => {
const atoms = getQueryInstructionPayloadAtoms(param.chainId, param.query);
return Promise.all(
diff --git a/packages/react/src/utils/jotai.ts b/packages/react/src/utils/jotai.ts
new file mode 100644
index 00000000..4dfa12aa
--- /dev/null
+++ b/packages/react/src/utils/jotai.ts
@@ -0,0 +1,92 @@
+import { QueryError } from "@reactive-dot/core";
+import type { Atom, Getter } from "jotai";
+import type { AtomFamily } from "jotai/vanilla/utils/atomFamily";
+import { Observable } from "rxjs";
+import { catchError } from "rxjs/operators";
+
+export class AtomFamilyError extends QueryError {
+ constructor(
+ readonly atomFamily: AtomFamily,
+ readonly param: unknown,
+ message: string | undefined,
+ options?: ErrorOptions,
+ ) {
+ super(message, options);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ static fromAtomFamilyError>(
+ error: TError,
+ atomFamily: TAtomFamily,
+ param: TAtomFamily extends AtomFamily
+ ? Param
+ : unknown,
+ message?: string,
+ ): AtomFamilyError {
+ return new AtomFamilyError(atomFamily, param, message, {
+ cause: error,
+ });
+ }
+}
+
+export const withAtomFamilyErrorCatcher = <
+ TParam,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ TRead extends (get: Getter, ...args: unknown[]) => any,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ TAtomCreator extends (read: TRead, ...args: any[]) => Atom,
+>(
+ atomFamily: AtomFamily,
+ param: TParam,
+ atomCreator: TAtomCreator,
+): TAtomCreator => {
+ // @ts-expect-error complex sub-type
+ const atomCatching: TAtomCreator = (read, ...args) => {
+ // @ts-expect-error complex sub-type
+ const readCatching: TRead = (...readArgs) => {
+ try {
+ const value = read(...readArgs);
+
+ if (value instanceof Promise) {
+ return value.catch((error) => {
+ throw AtomFamilyError.fromAtomFamilyError(error, atomFamily, param);
+ });
+ }
+
+ if (value instanceof Observable) {
+ return value.pipe(
+ catchError((error) => {
+ throw AtomFamilyError.fromAtomFamilyError(
+ error,
+ atomFamily,
+ param,
+ );
+ }),
+ );
+ }
+
+ return value;
+ } catch (error) {
+ throw AtomFamilyError.fromAtomFamilyError(error, atomFamily, param);
+ }
+ };
+
+ return atomCreator(readCatching, ...args);
+ };
+
+ return atomCatching;
+};
+
+export const resetQueryError = (error: unknown) => {
+ if (!(error instanceof Error)) {
+ return;
+ }
+
+ if (error instanceof AtomFamilyError) {
+ error.atomFamily.remove(error.param);
+ }
+
+ if (error.cause instanceof Error) {
+ resetQueryError(error.cause);
+ }
+};
diff --git a/packages/react/src/utils.ts b/packages/react/src/utils/vanilla.ts
similarity index 100%
rename from packages/react/src/utils.ts
rename to packages/react/src/utils/vanilla.ts
diff --git a/yarn.lock b/yarn.lock
index b3671d51..9ab10f1d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3411,6 +3411,7 @@ __metadata:
polkadot-api: "npm:^0.8.0"
react: "npm:^18.3.1"
react-dom: "npm:^18.3.1"
+ react-error-boundary: "npm:^4.0.13"
typescript: "npm:^5.4.5"
vite: "npm:^5.2.13"
languageName: unknown
@@ -12791,6 +12792,17 @@ __metadata:
languageName: node
linkType: hard
+"react-error-boundary@npm:^4.0.13":
+ version: 4.0.13
+ resolution: "react-error-boundary@npm:4.0.13"
+ dependencies:
+ "@babel/runtime": "npm:^7.12.5"
+ peerDependencies:
+ react: ">=16.13.1"
+ checksum: 10c0/6f3e0e4d7669f680ccf49c08c9571519c6e31f04dcfc30a765a7136c7e6fbbbe93423dd5a9fce12107f8166e54133e9dd5c2079a00c7a38201ac811f7a28b8e7
+ languageName: node
+ linkType: hard
+
"react-error-overlay@npm:^6.0.11":
version: 6.0.11
resolution: "react-error-overlay@npm:6.0.11"