javascriptreactjserror-handlingreact-queryreact-error-boundary

React query error boundaries: how to give users different options depending on whether they are seeing the component for the second time


I'm trying to improve error handling in my app that uses react-query.

I have an <ErrorBoundary> component for my authenticated user router:

<ErrorBoundaryRoot errorMessage="auth error">
  <PracticeTypeContextProvider>
    <IonReactRouter>
       ... myRoutesAndStuff

The component wrapper looks like this:

import { ErrorBoundary } from 'react-error-boundary';

const ErrorBoundaryRoot: React.VFC<MyProps> = ({ children, errorMessage }: MyProps) => {
  const { reset } = useQueryErrorResetBoundary();

  return (
    <ErrorBoundary
      onReset={reset}
      fallbackRender={({ resetErrorBoundary }) => (
        <PageError errorMessage={errorMessage} resetErrorBoundary={resetErrorBoundary} />
      )}
    >
      <Suspense fallback={<LoadingApp />}>
        { children }
      </Suspense>
    </ErrorBoundary>
  );
};

This correctly catches the errors, great!

Now, I wanted to show users one of two buttons:

I set up my <PageError> component like this:

const PageError: React.VFC<MyProps> = ({ errorMessage, resetErrorBoundary }: MyProps) => {
  const [alreadyTriedItOnce, setAlreadyTriedItOnce] = useState(false);
  return (
    <IonPage>
      <IonContent>
        <>
          { alreadyTriedItOnce ? (
            <ButtonResetApp platform={platform} resetErrorBoundary={resetErrorBoundary} />
          ) : (
            <ButtonInvalidateAuthQueries
              setAlreadyTriedItOnce={setAlreadyTriedItOnce}
              resetErrorBoundary={resetErrorBoundary}
            />
          )}
        </>
        )}
      </IonContent>
    </IonPage>
  );
};

And the invalidate button:

const ButtonInvalidateAuthQueries: React.VFC<MyProps> = (
  { setAlreadyTriedItOnce, resetErrorBoundary }: MyProps,
) => {
  const queryClient = useQueryClient();
  return (
    <IonButton
      onClick={() => {
        queryClient.invalidateQueries(queryKeyInvalidateAllAuthQueries);
        resetErrorBoundary();
        setAlreadyTriedItOnce(true);
      }}
    >
      Reset auth queries
    </IonButton>
  );
};

However, this doesn't work, because when the user clicks <ButtonInvalidateAuthQueries>, then all the auth components get re-rendered, including the error boundary, so if the error boundary is shown again, the alreadyTriedItOnce state is initialized to false.

So my question is: is there some clever way to tell if <ButtonInvalidateAuthQueries> has failed, and if so, show <ButtonResetApp> instead?

As a workaround, I can show both buttons, and give users the instruction "click this one first, and if that doesn't work, click the other one," but I feel like as a programmer it's my job to give users the best option at any given time.


Solution

  • I managed to fix this based on the comments from Bergi by using Context.

    First, I created a simple context to store a boolean useState:

    import React from 'react';
    import useStateBoolean from '../utils/hooks/useStateBoolean';
    
    const ErrorTriedAlreadyContext = React.createContext(false);
    const ErrorTriedAlreadySetContext = React.createContext(
      {} as React.Dispatch<React.SetStateAction<boolean>>,
    );
    
    interface MyProps {
      children: JSX.Element,
    }
    
    export const ErrorTriedAlreadyContextProvider: React.VFC<MyProps> = ({ children }: MyProps) => {
      const [triedAlready, setTriedAlready] = useStateBoolean();
    
      return (
        <ErrorTriedAlreadyContext.Provider value={triedAlready}>
          <ErrorTriedAlreadySetContext.Provider value={setTriedAlready}>
            {children}
          </ErrorTriedAlreadySetContext.Provider>
        </ErrorTriedAlreadyContext.Provider>
      );
    };
    
    export const useErrorAlreadyTriedContext = (): [
      boolean, React.Dispatch<React.SetStateAction<boolean>>,
    ] => {
      const triedAlready = React.useContext(ErrorTriedAlreadyContext);
      const setTriedAlready = React.useContext(ErrorTriedAlreadySetContext);
    
      if (!setTriedAlready) {
        throw new Error('The ErrorTriedAlreadyProvider is missing.');
      }
    
      return [triedAlready, setTriedAlready];
    };
    

    Then, I created a new ErrorBoundary:

    const ErrorBoundaryAuth: React.VFC<MyProps> = ({ children }: MyProps) => {
      const { reset } = useQueryErrorResetBoundary();
    
      return (
        <ErrorTriedAlreadyContextProvider>
          <ErrorBoundary
            onReset={reset}
            fallbackRender={({ resetErrorBoundary }) => (
              <PageError
                errorMessage={t({ id: 'error.auth_router_query_error', message: 'Temporary app error' })}
                allowAuthReset
                resetErrorBoundary={resetErrorBoundary}
              />
            )}
          >
            <Suspense fallback={<LoadingApp />}>
              { children }
            </Suspense>
          </ErrorBoundary>
        </ErrorTriedAlreadyContextProvider>
      );
    };
    

    I added it to my router:

      return (
        <ErrorBoundaryAuth>
          <PracticeTypeContextProvider>
            <IonReactRouter>
    

    And my button:

    const ButtonInvalidateAuthQueries: React.VFC<MyProps> = (
      { resetErrorBoundary }: MyProps,
    ) => {
      const queryClient = useQueryClient();
      const [, setAlreadyTried] = useErrorAlreadyTriedContext();
      return (
        <>
          <IonButton
            onClick={() => {
              queryClient.invalidateQueries(queryKeyInvalidateAllNonCookieQueries);
              if (resetErrorBoundary) {
                resetErrorBoundary();
              }
              // The context may not always be available.
              if (setAlreadyTried) {
                setAlreadyTried(true);
              }
            }}
          >
            <IonIcon icon={sync} slot="start" />
            <Trans id="button.reset_queries">Try again</Trans>
          </IonButton>
        </>
      );
    };
    

    And now it works!