Composable Error Handling with React Query

You shall not pass!

Composition pattern in React is a way to define a group of components that work together within its own context. It allows flexibility of components that share the same logic but can be used in different ways based on the application requirement. This is an example where a button can be composed of a root, icon and text. Another example would be in React Router where Switch, Route and Link work together but can be used in a flexible way by application developers.

My team is working on rewriting our React application into a framework-based front-end application to which other teams can onboard new features. One aspect that we have to take into consideration is how do we handle errors caused by API calls in a universal logic to keep consistent pattern within the entire application.

We use React Query (now called TanStack Query) which allows you to call a Promise and handle the asynchronous states (loading, error, settled) of the Promise call among other useful features (caching, retries, etc).

Typically, this is how a query is defined and how the states are handled:

const useExampleQuery = (id) => {
  return useQuery({
    queryKey: ["example", id],
    queryFn: () => {
      if (Math.floor(Math.random() * 5) > 0) {
        return Promise.reject("Rejected id: " + id);
      }
      return Promise.resolve("Success!");
    },
    onError: (error) => message.error({ content: error }),
  });
};

const MyComponent = ({id}) => {
  const {
    isLoading,
    isError,
    data,
    error
  } = useExampleQuery(id);

  return (
    <>
      {isLoading && <div>Loading...</div>}
      {isError && <div>Error occured: {error}</div>}
      {!!data && <div>{data}</div>}
    </>
  );
}

In this example, the component implemented needs to add logic to display the error message. There is the onError callback which display a toast message, and in MyComponent we display the error message in a persistent way. In a platform-based application, we are trying to find a way to catch and handle the error globally.

To support this scenario, React Query has a default config options for the onError callback. This can be applied at the QueryClient provided by the QueryClientProvider at the root level.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: (error) => {
        message.error({ content: error });
      }
    }
  }
});

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <MyComponent id="my-component"/>
    </QueryClientProvider>
  )
}

While this helps manage the toast message, the persistent error message display still needs to be implemented by the component manually. Could there be a way to have a global way to handle this display? Yes, we can implement a composition pattern with context. Let’s implement this is a rudimentary way first, by storing the error message in a state which can be consumed by an error component called QueryError:

const QueryErrorContext = createContext({ error: null });
const useQueryError = useContext(QueryErrorContext);

const QueryError = () => {
  const context = useQueryError();
  if (context?.error) return null;
  return <div>Error occured: {context.error}</div>
}

const MyComponent = ({id}) => {
  const { isLoading, data } = useExampleQuery(id);
  
  return (
    <>
      <QueryError/>
      {isLoading && <div>Loading...</div>}
      {data && <div>{data}</div>}
    </>
  );
}

const App = () => {
  const [error, setError] = useState(null);

  const queryClient = useMemo(() => new QueryClient({
    defaultOptions: {
      queries: {
        onError: (error) => {
          message.error({ content: error });
          setError(error);
        }
      }
    }
  }), []);
  
  return (
    <QueryErrorContext.Provider value={{error}}>
      <QueryClientProvider client={queryClient}>
        <MyComponent id="my-component"/>
      </QueryClientProvider>
    </QueryErrorContext.Provider>
  )
}

This composition pattern allows the error via QueryError to be displayed at any level of the component tree, in our case, we render it in MyComponent. While this work for a simple application, this would not work where an application has different parts that have different queries. The query error is currently a single global state which means any error that might occur outside of MyComponent would also be rendered by QueryError due to the shared state.

Consider this multi-level App structure with MyContainer that has a query call and multiple children of type MyComponent:

const sections = ['SectionA', 'SectionB', 'SectionC'];

const MyContainer = () => {
  const { isLoading, data } = useExampleQuery("container");

  return (
    <>
      <QueryError/>
      { isLoading && <div>Loading...</div> }
      { data && <div>{data}</div> }
      {
        sections.map(section => (
          <MyComponent
            key={section}
            id={section}/>
        ))
      }
    </>
  );
}

const App = () => {
  return (
    <QueryErrorContext.Provider value={{error}}>
      <QueryClientProvider client={queryClient}>
        <MyContainer/>
      </QueryClientProvider>
    </QueryErrorContext.Provider>
  )
}

In the example above, QueryError will display the same error regardless of level (MyContainer vs MyComponent) or instance (SectionA, SectionB or SectionC) as they all share the same context provider.

How do we compartmentalize error in a way that we can reuse the same logic and pattern? We can provide this by refactoring the query error context provider which in the code sample below we call QueryBoundary. As we can see in the previous code block, QueryClient has to have access to the setError method, this means we have to define the query client at this context provider component. Here is how it would look like:

const QueryErrorContext = createContext({ error: null });
const useQueryError = useContext(QueryErrorContext);

const QueryError = () => {
  const context = useQueryError();
  if (context?.error) return null;
  return <div>Error occured: {context.error}</div>
}

const QueryBoundary = ({ children }) => {
  const [error, setError] = useState(null);

  const queryClient = useMemo(() => new QueryClient({
    defaultOptions: {
      queries: {
        onError: (error) => {
          message.error({ content: error });
          setError(error);
        }
      }
    }
  }), []);
  
  return (
    <QueryErrorContext.Provider value={{error}}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </QueryErrorContext.Provider>
  );
}
QueryBoundary.Error = QueryError;

const MyContainer = () => {
  const { isLoading, data } = useExampleQuery("container");

  return (
    <>
      <QueryBoundary.Error/>
      { isLoading && <div>Loading...</div> }
      { data && <div>{data}</div> }
      { 
        sections.map(section => (
          <QueryBoundary>
            <MyComponent key={section} id={section}/>
          </QueryBoundary>
        ))
      }
    </>
  );
}

const App = () => {
  return (
    <QueryBoundary>
      <MyContainer/>
    </QueryBoundary>
  )
}

With this new pattern, the QueryError will only render the errors that is caught by the nearest QueryBoundary ancestor. Component owners can use this flexible pattern to define where their error boundary is and where to display their error message without having to get the error/isError values from any query.

A good practice is to attach the components together by assigining it as a member to let developers know that they work together. In our case, we can do QueryBoundary.Errors = QueryErrorComponent.

There is a final issue with the code if there needs to be a shared states between the components due to where QueryClient is defined. For example, if the session state is at the App level, this is no longer shared across the QueryClients as the contexts are now split by QueryBoundary. This means caching and cache invalidation would not work across different boundaries. To tackle this issue, React Query has a way to have a shared state between query clients, via the QueryCache. We can define a single cache for multiple query clients. Here is how it can be implemented:

const queryCache = new QueryCache({
  onError: (error, query) => {
    console.log("Error occured on query " + query.queryKey + ": " + error);
  },
});

const QueryBoundary = ({ children }) => {
  const [error, setError] = useState(null);

  const queryClient = useMemo(() => new QueryClient({
    queryCache,
    defaultOptions: {
      queries: {
        onError: (error) => {
          message.error({ content: error });
          setError(error);
        }
      }
    }
  }), []);
  
  return (
    <QueryErrorContext.Provider value={{error}}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </QueryErrorContext.Provider>
  );
}

That is it! Now the queries have a single cache source with different query clients allowing a composable error message handling at different levels of the application. Here is a demo on CodeSandbox of how it behaves (with some variance). Refresh the Preview to see the random query error messages showing in different boundaries. Source.

Previous
Previous

Create CSS Components with Variables and Nesting