Published on

Create an API Error Boundary Context for React Query

Authors

Creating an API Error Boundary context allows you to catch errors from API calls at a global level in order to perform specific actions based on the error. This is useful for things like logging errors, displaying error messages, or redirecting the user to a different page. In this article, I'll show how to create an API Error Boundary context that is used to catch errors that occur from API calls and any time there is a 401 Unauthorized error, redirect to the login page.

When a user leaves your application open for a long time, the session may expire and the user will be logged out. When this happens, any API calls that are made when the user comes back to it will return a 401 Unauthorized error. At this point we don't want to the page to sit there and show error messages saying it could not retrieve data. Instead, we should actually call a logout function that redirects back to the login page. Better yet, we can get a refresh token to log the user back in automatically.

API Error Boundary Provider

import React, {useState} from 'react';

export type ApiError = {
  error: any; // We only use any type because we don't have control of the error object
  setError: (error: any) => void;
};

// Create the API Error Boundary context
const ApiErrorBoundaryContext = React.createContext<ApiError | undefined>(undefined);

export const ApiErrorBoundaryProvider = ({
  value,
  children,
}: {
  value?: ApiError;
  children: React.ReactNode;
}) => {
  // Store the error in the provider's state
  const [error, setError] = useState(false);
  return (
    <ApiErrorBoundaryContext.Provider value={value ? value : {error, setError}}>
      {children}
    </ApiErrorBoundaryContext.Provider>
  );
};

export const useApiErrorBoundary = () => {
  const context = React.useContext(ApiErrorBoundaryContext);
  if (context === undefined) {
    throw new Error('useApiErrorBoundary must be used inside ApiErrorBoundaryProvider');
  }
  return context;
};

The value of the context is an object that contains the error and a function to set the error, but we are only really including it as a prop so we can properly test it when we create the error watcher. The actual errors will be set by React Query through the useApiErrorBoundary hook. The error is stored in the provider's state.

Error Boundary Test

import React from 'react';
import {render} from 'layout-test-utils';
import {ApiErrorBoundaryProvider, useApiErrorBoundary} from '../api-error-boundary';

let contextHook: any;
function ContextBoundary() {
  contextHook = useApiErrorBoundary();
  return <div>test</div>;
}

test('should be available from the context hook', () => {
  const {getByText} = render(
    <ApiErrorBoundaryProvider>
      <ContextBoundary />
    </ApiErrorBoundaryProvider>
  );
  expect(contextHook).toMatchInlineSnapshot(`
    Object {
      "error": false,
      "setError": [Function],
    }
  `);
  expect(getByText('test')).toBeInTheDocument();
});

test('should throw when useApiErrorBoundary() hook is used not within context provider', () => {
  jest.spyOn(console, 'error').mockImplementation(() => {});
  expect(() => render(<ContextBoundary />)).toThrow(
    /useApiErrorBoundary must be used inside ApiErrorBoundaryProvider/
  );
  expect(console.error).toHaveBeenCalledTimes(2);
  console.error.mockRestore();
});

The tests for the error boundary provider are pretty simple. We are just testing that the context hook returns the correct value and that it throws an error if it is used outside of the provider.

The next step is to create an error watcher that will wrap the private area of the application where all of the protected API calls are made. This is where we will catch the 401 Unauthorized errors and redirect the user to the login page.

API Error Watcher

import React from 'react';
import {useApiErrorBoundary} from 'contexts/api-error-boundary';
import {useAuthServiceContext} from 'ux-auth';

const ApiErrorWatcher = ({children}: {children: React.ReactNode}) => {
  const {error} = useApiErrorBoundary();
  const {logout} = useAuthServiceContext();

  React.useEffect(() => {
    if (error) {
      if (error.response?.status === 401) logout();
    }
  }, [error]);

  return <>{children}</>;
};

export default ApiErrorWatcher;

The error watcher is a simple component that uses the useApiErrorBoundary hook to get the error and the useAuthServiceContext hook to get the logout function. It then uses a React effect to check if there is an error and if the status code is 401, it calls the logout function. The auth service context is a separate context that is used to store the user's authentication information along with auth functions like loginWithRedirect, handleRedirectCallback, getTokenSilently and logout.

API Error Watcher Test

import React from 'react';
import {render} from '@testing-library/react';
import ApiErrorWatcher from '../ApiErrorWatcher';
import * as mockAuthService from 'ux-auth';
import {ApiErrorBoundaryProvider} from 'contexts/api-error-boundary';

test('renders ApiErrorWatcher', () => {
  const logout = jest.fn();
  jest.spyOn(mockAuthService, 'useAuthServiceContext').mockImplementation(() => ({logout}));
  render(
    <ApiErrorBoundaryProvider value={{error: {response: {status: 401}}, setError: jest.fn()}}>
      <ApiErrorWatcher>
        <div>Test</div>
      </ApiErrorWatcher>
    </ApiErrorBoundaryProvider>
  );
  expect(logout).toBeCalledTimes(1);
});

The error watcher test is where you can see why we included the optional value prop in the error boundary provider. We can pass in a mock error object and mock the logout function from the auth service context. This way we can test that the error watcher is calling the logout function when the error status code is 401.

Now, let's implement the error boundary at the top level of the App component. When an error occurs, it will now be accessible from anywhere in the application.

App Component


function App() {
  return (
    <ApiErrorBoundaryProvider>
      <BrowserRouter basename={appConfig.basepath}>
          <AuthServiceProvider>
            <Routes />
          </AuthServiceProvider>
      </BrowserRouter>
    </ApiErrorBoundaryProvider>
  );
}
export default App;

The Routes component includes a PrivateLayout component, which is protected by a PrivateArea that simply checks for authentication and redirects to the login page if the user is not authenticated. The PrivateLayout component is where we will wrap the error watcher.

Routes Component

const Routes = () => {
  const history = useHistory();
  return (
    <Switch>
      <Route exact path="/callback">
        <Callback pendingElement={<PendingElement />} history={history} />
      </Route>
      <Route exact path="/silentrenew">
        <SilentRenew pendingElement={<LoadingSpinner />} />
      </Route>
      <Route exact path="/logoutcallback">
        <LogoutCallback
          pendingElement={<LoadingSpinner />}
          redirectTo={'/home'}
          history={history}
        />
      </Route>
      <Route exact path="/logout">
        <Logout pendingElement={<LoadingSpinner />} />
      </Route>
      <Route path="/svcerror">
        <ServiceError />
      </Route>
      <PrivateArea pendingElement={<PendingElement />}>
        <PrivateLayout />
      </PrivateArea>
    </Switch>
  );
};

PrivateLayout contains the React Query QueryClientProvider, along with a few other context providers. This is where we want to add the ApiErrorWatcher component.

PrivateLayout Component

import React from 'react';
import {QueryClient, QueryClientProvider, QueryCache} from 'react-query';
import {NotificationContextProvider} from 'ux-contexts';
import {ReactQueryDevtools} from 'react-query/devtools';
import Layout from 'navigation/Layout';
import {FeatureFlagsContextProvider} from 'contexts/feature-flags';
import {ApiErrorWatcher} from 'components';
import {useApiErrorBoundary} from 'contexts/api-error-boundary';

export const PrivateLayout: React.FC = () => {
  const {setError} = useApiErrorBoundary();
  const client = new QueryClient({
    queryCache: new QueryCache({
      onError: (error: any) => {
        setError(error);
      },
    }),
  });

  return (
    <QueryClientProvider client={client}>
      <FeatureFlagsContextProvider>
        <NotificationContextProvider>
          <ApiErrorWatcher>
            <Layout />
          </ApiErrorWatcher>
        </NotificationContextProvider>
      </FeatureFlagsContextProvider>
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
};

export default PrivateLayout;

This is also the component where we set the error handler for React Query. This way, if an error occurs in a React Query query or mutation, it will be caught by the error watcher and the user will be redirected to the login page. In order to do this, we use the useApiErrorBoundary hook to get the setError function from the error boundary context. We then pass this function to the onError handler of the React Query QueryCache which we explicitly set in the QueryClient constructor. This way, we can set the error in the error boundary context and the error watcher will be able to handle it.

Creating an API Error Boundary is a great way to handle specific API errors that occur at a global level in your application, like when an auth token expires and the API calls start returning 401 responses. In this example, we used the API Error Boundary to handle errors that occur in React Query queries and mutations, allowing you to handle errors in a centralized location and giving you the ability to handle errors in a way that is specific to your application.