Repository: tannerlinsley/react-query
Pull request #980 (2020-09-07 16:26:41)

feat: add reset error boundary component
Because error boundaries are only applicable to a certain a tree, I think it would be good if the error reset would also only be applicable to that specific tree.

The `ReactQueryResetErrorBoundary` component enables this functionality, while allowing users to keep their own error boundary component. This does mean users will need to wrap their queries in two boundaries. We could streamline this by providing a `ReactQueryErrorBoundary` component similar to `react-error-boundary`. But maybe there are arguments against this?

This change should be backwards compatible and would allow us to remove `queryCache.resetErrorBoundaries()` and `query.state.throwInErrorBoundary` in v3 to make the core less knowledgable about React.

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/tannerlinsley/react-query/cmgp1yvrq](https://vercel.com/tannerlinsley/react-query/cmgp1yvrq)  
✅ Preview: *In Progress*


tannerlinsley: Conflicts ;)
boschni: Ah yes will resolve those. What is your thought on a ‘ReactQueryErrorBoundary’ vs ‘ReactQueryResetErrorBoundary’?
tannerlinsley: So I think my main argument against what's happening here is that React Query isn't an error boundary component and by providing one would be diverging from the ecosystem (most people just use `react-error-boundary` or have their own. I 100% agree that people want to capture/reset errors for specific parts of their app, but doesn't that just mean that they will use multiple instances of their chosen error boundary component? 

That said... we would need a reliable way to *know* which queries to reset for a specific tree... so thus, we need a wrapper component 😂

All that said, I think we should totally ship a component, but do as much as we can to show people how to reliably integrate it with a real error boundary component. 

And that brings me to the name. It's not really an error boundary. It's a query resetter. So maybe something like `ReactQueryResetBoundary`?
boschni: Fair enough! Users can always create their own error boundary wrapper which also includes a react query error reset boundary :)

Patch:
diff --git a/docs/src/pages/docs/guides/suspense.md b/docs/src/pages/docs/guides/suspense.md
index 3e4308684..11f0d2442 100644
--- a/docs/src/pages/docs/guides/suspense.md
+++ b/docs/src/pages/docs/guides/suspense.md
@@ -43,23 +43,57 @@ In addition to queries behaving differently in suspense mode, mutations also beh
 
 ## Resetting Error Boundaries
 
-Whether you are using **suspense** or **useErrorBoundaries** in your queries, you will need to know how to use the `queryCache.resetErrorBoundaries` function to let queries know that you want them to try again when you render them again.
+Whether you are using **suspense** or **useErrorBoundaries** in your queries, you will need a way to let queries know that you want to try again when re-rendering after some error occured.
 
-How you trigger this function is up to you, but the most common use case is to do it in something like `react-error-boundary`'s `onReset` callback:
+Query errors can be reset with the `ReactQueryErrorResetBoundary` component or with the `useErrorResetBoundary` hook.
+
+When using the component it will reset any query errors within the boundaries of the component:
+
+```js
+import { ReactQueryErrorResetBoundary } from 'react-query'
+import { ErrorBoundary } from 'react-error-boundary'
+
+const App: React.FC = () => (
+  <ReactQueryErrorResetBoundary>
+    {({ reset }) => (
+      <ErrorBoundary
+        onReset={reset}
+        fallbackRender={({ resetErrorBoundary }) => (
+          <div>
+            There was an error!
+            <Button onClick={() => resetErrorBoundary()}>Try again</Button>
+          </div>
+        )}
+      >
+        <Page />
+      </ErrorBoundary>
+    )}
+  </ReactQueryErrorResetBoundary>
+)
+```
+
+When using the hook it will reset any query errors within the closest `ReactQueryErrorResetBoundary`. If there is no boundary defined it will reset them globally:
 
 ```js
-import { queryCache } from "react-query";
-import { ErrorBoundary } from "react-error-boundary";
-
-<ErrorBoundary
-  onReset={() => queryCache.resetErrorBoundaries()}
-  fallbackRender={({ error, resetErrorBoundary }) => (
-    <div>
-      There was an error!
-      <Button onClick={() => resetErrorBoundary()}>Try again</Button>
-    </div>
-  )}
->
+import { useErrorResetBoundary } from 'react-query'
+import { ErrorBoundary } from 'react-error-boundary'
+
+const App: React.FC = () => {
+  const { reset } = useErrorResetBoundary()
+  return (
+    <ErrorBoundary
+      onReset={reset}
+      fallbackRender={({ resetErrorBoundary }) => (
+        <div>
+          There was an error!
+          <Button onClick={() => resetErrorBoundary()}>Try again</Button>
+        </div>
+      )}
+    >
+      <Page />
+    </ErrorBoundary>
+  )
+}
 ```
 
 ## Fetch-on-render vs Render-as-you-fetch
diff --git a/src/react/ReactQueryErrorResetBoundary.tsx b/src/react/ReactQueryErrorResetBoundary.tsx
new file mode 100644
index 000000000..ac6a8adf9
--- /dev/null
+++ b/src/react/ReactQueryErrorResetBoundary.tsx
@@ -0,0 +1,51 @@
+import React from 'react'
+
+// CONTEXT
+
+interface ReactQueryErrorResetBoundaryValue {
+  clearReset: () => void
+  isReset: () => boolean
+  reset: () => void
+}
+
+function createValue(): ReactQueryErrorResetBoundaryValue {
+  let isReset = true
+  return {
+    clearReset: () => {
+      isReset = false
+    },
+    reset: () => {
+      isReset = true
+    },
+    isReset: () => {
+      return isReset
+    },
+  }
+}
+
+const context = React.createContext(createValue())
+
+// HOOK
+
+export const useErrorResetBoundary = () => React.useContext(context)
+
+// COMPONENT
+
+export interface ReactQueryErrorResetBoundaryProps {
+  children:
+    | ((value: ReactQueryErrorResetBoundaryValue) => React.ReactNode)
+    | React.ReactNode
+}
+
+export const ReactQueryErrorResetBoundary: React.FC<ReactQueryErrorResetBoundaryProps> = ({
+  children,
+}) => {
+  const value = React.useMemo(() => createValue(), [])
+  return (
+    <context.Provider value={value}>
+      {typeof children === 'function'
+        ? (children as Function)(value)
+        : children}
+    </context.Provider>
+  )
+}
diff --git a/src/react/index.ts b/src/react/index.ts
index 4a645f15b..93a54f7e4 100644
--- a/src/react/index.ts
+++ b/src/react/index.ts
@@ -3,6 +3,10 @@ export {
   useQueryCache,
 } from './ReactQueryCacheProvider'
 export { ReactQueryConfigProvider } from './ReactQueryConfigProvider'
+export {
+  ReactQueryErrorResetBoundary,
+  useErrorResetBoundary,
+} from './ReactQueryErrorResetBoundary'
 export { useIsFetching } from './useIsFetching'
 export { useMutation } from './useMutation'
 export { useQuery } from './useQuery'
@@ -15,3 +19,4 @@ export type { UseInfiniteQueryObjectConfig } from './useInfiniteQuery'
 export type { UsePaginatedQueryObjectConfig } from './usePaginatedQuery'
 export type { ReactQueryCacheProviderProps } from './ReactQueryCacheProvider'
 export type { ReactQueryConfigProviderProps } from './ReactQueryConfigProvider'
+export type { ReactQueryErrorResetBoundaryProps } from './ReactQueryErrorResetBoundary'
diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts
index 6509f8efd..cce86a709 100644
--- a/src/react/useBaseQuery.ts
+++ b/src/react/useBaseQuery.ts
@@ -3,17 +3,19 @@ import React from 'react'
 import { useRerenderer } from './utils'
 import { getResolvedQueryConfig } from '../core/config'
 import { QueryObserver } from '../core/queryObserver'
-import { QueryResultBase, QueryConfig, QueryKey } from '../core/types'
-import { useQueryCache } from './ReactQueryCacheProvider'
+import { QueryResultBase, QueryKey, QueryConfig } from '../core/types'
+import { useErrorResetBoundary } from './ReactQueryErrorResetBoundary'
+import { useQueryCache } from '.'
 import { useContextConfig } from './ReactQueryConfigProvider'
 
 export function useBaseQuery<TResult, TError>(
   queryKey: QueryKey,
   config?: QueryConfig<TResult, TError>
 ): QueryResultBase<TResult, TError> {
-  const rerender = useRerenderer()
   const cache = useQueryCache()
+  const rerender = useRerenderer()
   const contextConfig = useContextConfig()
+  const errorResetBoundary = useErrorResetBoundary()
 
   // Get resolved config
   const resolvedConfig = getResolvedQueryConfig(
@@ -49,7 +51,11 @@ export function useBaseQuery<TResult, TError>(
   if (resolvedConfig.suspense || resolvedConfig.useErrorBoundary) {
     const query = observer.getCurrentQuery()
 
-    if (result.isError && query.state.throwInErrorBoundary) {
+    if (
+      result.isError &&
+      !errorResetBoundary.isReset() &&
+      query.state.throwInErrorBoundary
+    ) {
       throw result.error
     }
 
@@ -58,6 +64,7 @@ export function useBaseQuery<TResult, TError>(
       resolvedConfig.suspense &&
       !result.isSuccess
     ) {
+      errorResetBoundary.clearReset()
       const unsubscribe = observer.subscribe()
       throw observer.fetch().finally(unsubscribe)
     }


Test patch:
diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx
index 0dd3cd084..387fce907 100644
--- a/src/react/tests/suspense.test.tsx
+++ b/src/react/tests/suspense.test.tsx
@@ -5,6 +5,10 @@ import * as React from 'react'
 import { sleep, queryKey, mockConsoleError } from './utils'
 import { useQuery } from '..'
 import { queryCache } from '../../core'
+import {
+  ReactQueryErrorResetBoundary,
+  useErrorResetBoundary,
+} from '../ReactQueryErrorResetBoundary'
 
 describe("useQuery's in Suspense mode", () => {
   it('should not call the queryFn twice when used in Suspense mode', async () => {
@@ -192,6 +196,135 @@ describe("useQuery's in Suspense mode", () => {
     consoleMock.mockRestore()
   })
 
+  it('should retry fetch if the reset error boundary has been reset', async () => {
+    const key = queryKey()
+
+    let succeed = false
+    const consoleMock = mockConsoleError()
+
+    function Page() {
+      useQuery(
+        key,
+        async () => {
+          await sleep(10)
+          if (!succeed) {
+            throw new Error('Suspense Error Bingo')
+          } else {
+            return 'data'
+          }
+        },
+        {
+          retry: false,
+          suspense: true,
+        }
+      )
+      return <div>rendered</div>
+    }
+
+    const rendered = render(
+      <ReactQueryErrorResetBoundary>
+        {({ reset }) => (
+          <ErrorBoundary
+            onReset={reset}
+            fallbackRender={({ resetErrorBoundary }) => (
+              <div>
+                <div>error boundary</div>
+                <button
+                  onClick={() => {
+                    resetErrorBoundary()
+                  }}
+                >
+                  retry
+                </button>
+              </div>
+            )}
+          >
+            <React.Suspense fallback="Loading...">
+              <Page />
+            </React.Suspense>
+          </ErrorBoundary>
+        )}
+      </ReactQueryErrorResetBoundary>
+    )
+
+    await waitFor(() => rendered.getByText('Loading...'))
+    await waitFor(() => rendered.getByText('error boundary'))
+    await waitFor(() => rendered.getByText('retry'))
+    fireEvent.click(rendered.getByText('retry'))
+    await waitFor(() => rendered.getByText('error boundary'))
+    await waitFor(() => rendered.getByText('retry'))
+    succeed = true
+    fireEvent.click(rendered.getByText('retry'))
+    await waitFor(() => rendered.getByText('rendered'))
+
+    consoleMock.mockRestore()
+  })
+
+  it('should retry fetch if the reset error boundary has been reset with global hook', async () => {
+    const key = queryKey()
+
+    let succeed = false
+    const consoleMock = mockConsoleError()
+
+    function Page() {
+      useQuery(
+        key,
+        async () => {
+          await sleep(10)
+          if (!succeed) {
+            throw new Error('Suspense Error Bingo')
+          } else {
+            return 'data'
+          }
+        },
+        {
+          retry: false,
+          suspense: true,
+        }
+      )
+      return <div>rendered</div>
+    }
+
+    function App() {
+      const { reset } = useErrorResetBoundary()
+      return (
+        <ErrorBoundary
+          onReset={reset}
+          fallbackRender={({ resetErrorBoundary }) => (
+            <div>
+              <div>error boundary</div>
+              <button
+                onClick={() => {
+                  resetErrorBoundary()
+                }}
+              >
+                retry
+              </button>
+            </div>
+          )}
+        >
+          <React.Suspense fallback="Loading...">
+            <Page />
+          </React.Suspense>
+        </ErrorBoundary>
+      )
+    }
+
+    const rendered = render(<App />)
+
+    await waitFor(() => rendered.getByText('Loading...'))
+    await waitFor(() => rendered.getByText('error boundary'))
+    await waitFor(() => rendered.getByText('retry'))
+    fireEvent.click(rendered.getByText('retry'))
+    await waitFor(() => rendered.getByText('error boundary'))
+    await waitFor(() => rendered.getByText('retry'))
+    succeed = true
+    fireEvent.click(rendered.getByText('retry'))
+    await waitFor(() => rendered.getByText('rendered'))
+
+    consoleMock.mockRestore()
+  })
+
   it('should not call the queryFn when not enabled', async () => {
     const key = queryKey()