Reducing React Query Boilerplate

  • tidbit
  • React Query
  • async frontend state

At Hub3, React Query has been a game-changer for how we handle frontend state. This post describes a helpful React Query pattern we developed that reduces boilerplate and improves our productivity.

Wait, React Query?

A quick primer for the unfamiliar: React Query describes itself as a “data-fetching library for React.” It encourages you to reflect on your frontend state’s “ownership” and separate it into client state and server state.

  • Client state is created by the app’s frontend and doesn’t live anywhere else.
    • e.g. selectedBookIds , isModalOpen (aka UI state)
  • Server state is the opposite—state that your client fetches asynchronously from some backend.
    • e.g. books , userProfile

React Query makes working with server state easier by providing hooks to help you fetch it, manage asynchronous loading/error states, and cache it via the stale-while-revalidate pattern. At Hub3, React Query dramatically reduced our reliance on Redux Toolkit, which removed a lot of Redux state management code and sped up development.

Check out React Query’s overview and motivation for the full scoop.

The Boilerplate

Consider the example below that uses React Query’s excellent useQuery hook via custom hooks. Try to focus on how asynchronous loading and error states are handled (and not on the contrived page structure 🙃):

const BookShowPage = props => {
  const {
    data: book,
    isLoading: isLoadingBook,
    error: bookLoadingError,
  } = useBookQuery(props.bookId);

  const {
    data: author,
    isLoading: isLoadingAuthor,
    error: authorLoadingError,
  } = useAuthorQuery(book.authorId, { isEnabled: !!book });

  const {
    data: similarBooks,
    isLoading: isLoadingSimilarBooks,
    error: similarBooksLoadingError,
  } = useSimilarBooksQuery(props.bookId);

  // Show a loader if necessary.
  if (isLoadingBook ||
      isLoadingAuthor ||
      isLoadingSimilarBooks) {
    return <Loader />;
  }

  // Show an error message if necessary.
  const loadingError =
    bookLoadingError ||
    authorLoadingError ||
    similarBooksLoadingError;
  if (loadingError) {
    return <Error error={loadingError} />;
  }

  return (
    <PageContainer>
      <BookSummary book={book} />
      <AuthorSummary author={author} />
      <SimilarBooksCarousel books={similarBooks} />
    </PageContainer>
  );
};

Not bad! But we can do a bit better. Here are my two issues:

  1. For each query, you need two new variables to store the loading and error state (e.g. isLoadingBook and bookLoadingError). You also need to update the loading and error conditionals.
    • This pattern becomes cumbersome with a few useQuery statements.
  2. For each component that fetches data asynchronously, you need to remember to show loading and error states.
    • In my experience, the latter is often forgotten.

The Boilerplate Killer

To address the pain points from above, we created reactQueryRenderHelper to simplify components with multiple asynchronous queries and standardize our loading and error handling pattern.

To understand how it works, let’s refactor the above example:

const BookShowPage = props => {
  const { data: book, ...bookQueryAttrs } =
    useBookQuery(props.bookId);

  const { data: author, ...authorQueryAttrs } =
    useAuthorQuery(book.authorId, { isEnabled: !!book });

  const { data: similarBooks, ...similarBooksQueryAttrs } =
    useSimilarBooksQuery(props.bookId);

  const asyncStatusComponent = reactQueryRenderHelper([
    bookQueryAttrs,
    authorQueryAttrs,
    similarBooksQueryAttrs,
  ]);

  if (asyncStatusComponent) {
    return asyncStatusComponent;
  }

  return (
    <PageContainer>
      <BookSummary book={book} />
      <AuthorSummary author={author} />
      <SimilarBooksCarousel books={similarBooks} />
    </PageContainer>
  );
};

Kinda neat!

What is asyncStatusComponent? In this case, it’s either undefined, <Loader /> or <Error />. reactQueryRenderHelper decides which one based on its arguments. The component doesn’t know or care—it just renders asyncStatusComponent if it’s present and otherwise trusts that the query data is ready to use.

A few benefits to this pattern:

  • ⬇️ Boilerplate: Fewer variables and conditionals
  • ⬆️ Standardization: Loading and error state is handled identically across components.
  • ⬆️ Robustness: You can’t forget to handle and display asynchronous errors (which I see happen quite a bit).

reactQueryRenderHelper implementation

Below is our implementation of reactQueryRenderHelper. To use it in your project, be sure to bring your own <Loader /> and <Error /> components:

// Vendor
import _ from "lodash";
// Components
import Loader from "~/components/shared/loader";
import Error from "~/components/shared/error";

// This helper inspects the results from one or more react-query useQuery calls.
// It decides what component should be rendered (Loader, Error, etc.) and
// returns it to the caller. This helps reduce tedious boilerplate code.
//
// opts:
// - loaderProps: props for shared/loader
// - errorProps: props for shared/error
// - loaderComponent: override loader component
// - errorComponent: override error component
const reactQueryRenderHelper = (reactQueryAttrs, opts = {}) => {
  const isLoading = _.some(reactQueryAttrs, { isLoading: true });
  if (isLoading) {
    return opts.loaderComponent || <Loader {...opts.loaderProps} />;
  }

  const isError = _.some(reactQueryAttrs, { isError: true });
  if (isError) {
    return opts.errorComponent || <Error {...opts.errorProps} />;
  }

  return undefined;
};

export default reactQueryRenderHelper;