Blogs

Building a Flexible MatchQueryStatus Component for Handling Async Data in React

The Problem: Managing Async Data States in React Components

When working with APIs in React, managing the various states of asynchronous data—loading, success, error, and empty—can become repetitive and cumbersome. This is especially true when using React Query's useQuery hook in multiple components.

Introduction

In this post, we'll create a flexible and reusable MatchQueryStatus component to handle asynchronous data states in React using React Query. This component streamlines data-loading states and provides flexibility for UI customization.

The Basics of React Query

React Query simplifies server-side data management with features like caching, synchronization, and stale data handling. When using useQuery, you receive a query result object with:

  • isLoading: true while the data is being fetched.
  • isError: true if an error occurs.
  • data: the fetched data once it’s successfully retrieved.
  • refetch: a function to retry the fetch.

Building the MatchQueryStatus Component

Our MatchQueryStatus component:

  • Accepts a React Query result and various rendering components or functions
  • Renders different UI components based on the query's state
  • Provides flexibility by allowing custom components for loading, error, empty, and success states

The Loader Component

This helper component displays a loading spinner.

tsx

import React from 'react'; import { Loader2 } from 'lucide-react'; import { cn } from '../lib/utils'; function Loader({ className, loaderClassName, }: { className?: string; loaderClassName?: string; }) { return ( <div className={cn('flex h-8 w-full items-start justify-center', className)}> <Loader2 className={cn('size animate-spin', loaderClassName)} /> </div> ); }

Defining the MatchQueryStatusProps Type

This type accepts properties based on different cases:

  • Case 1: We use the render prop to display data if it’s available and handle errors and loading states with custom components.
  • Case 2: We define custom components for each state, including Loading, Errored, Empty, and Success.

tsx

import { type UseQueryResult } from '@tanstack/react-query'; type MatchQueryStatusProps<T> = | { query: UseQueryResult<T>; render: (data: T, isLoading: boolean) => JSX.Element; Errored?: | JSX.Element | (( error: unknown, refetch: () => void, isLoading: boolean ) => JSX.Element); Loading?: never; Empty?: never; Success?: never; } | { query: UseQueryResult<T>; Loading?: | JSX.Element | ((className?: string, loaderClassName?: string) => JSX.Element); Errored?: | JSX.Element | (( error: unknown, refetch: () => void, isLoading: boolean ) => JSX.Element); Empty?: JSX.Element; Success: (data: NonNullable<T>) => JSX.Element; render?: never; loaderClassName?: string; };

Type Guard for render Prop

The hasRender function checks if the props include a render prop. This type guard helps us determine how to handle the MatchQueryStatus component’s behavior.

tsx

function hasRender<T>(props: MatchQueryStatusProps<T>): props is { query: UseQueryResult<T>; render: (data: T, isLoading: boolean) => JSX.Element; Errored?: | JSX.Element | ((error: unknown, refetch: () => void, isLoading: boolean) => JSX.Element); } { return 'render' in props && typeof props.render === 'function'; }

The MatchQueryStatus Component

This component’s main function is to check the query’s state (isLoading, isError, isSuccess, or empty data) and render the appropriate UI based on the props passed. Here’s the full implementation:

tsx

// import ErrorCard and EmptyCard or you can create inside of this file import ErrorCard from '@/components/error-card'; import EmptyCard from '@/components/empty-card'; function MatchQueryStatus<T>(props: MatchQueryStatusProps<T>): JSX.Element { const { query } = props; if (hasRender(props)) { // Handle custom error UI if Errored is provided in the render case if (query.isError && props.Errored) { return typeof props.Errored === 'function' ? props.Errored(query.error, query.refetch, query.isLoading) : props.Errored; } // If no error, render the main content return props.render(query.data as T, query.isLoading); } const { loaderClassName, Loading = (c, l = loaderClassName) => ( <Loader className={c} loaderClassName={l} /> ), Errored = (error, refetch, isLoading) => ( <ErrorCard error={error} isLoading={isLoading} onRetry={refetch} /> ), Empty = <EmptyCard />, Success, } = props; if (query.isLoading) return typeof Loading === 'function' ? Loading(undefined, loaderClassName) : Loading; if (query.isError) { return typeof Errored === 'function' ? Errored(query.error, query.refetch, query.isLoading) : Errored; } const isEmptyData = (data: unknown): boolean => data === undefined || data === null || (Array.isArray(data) && data.length === 0); if (isEmptyData(query.data) && Empty) return Empty; return Success(query.data as NonNullable<T>); } export default MatchQueryStatus;

Explanation

  • Loading State: If the query is loading, it checks if a custom Loading component is provided. If not, it defaults to using the Loader component.
  • Error State: If there’s an error, it uses the Errored component, which can be customized.
  • Empty State: Checks if the data is empty (i.e., undefined, null, or an empty array) and displays an EmptyCard if so.
  • Success State: When data is successfully fetched, it renders the Success component with the data.

Usage Example

Here’s how you might use MatchQueryStatus in a React component.

Without render prop

tsx

import { useQuery } from '@tanstack/react-query'; function MyComponent() { const query = useQuery({ queryKey: ['todos'], queryFn:()=>{}, }); return ( <MatchQueryStatus query={query} Loading={<Loader />} Errored={(error, refetch, isLoading) => ( <ErrorCard error={error} onRetry={refetch} isLoading={isLoading} /> )} Empty={<EmptyCard />} Success={(data) => <div>Data Loaded Successfully: {data}</div>} /> ) }

With render prop

tsx

import { DataTable } from '@/components/table/react-table'; import React from 'react'; import useAdminDocColumns from './doctor-columns'; import useAdminDoctors from '@/services/useAdminDoctors'; import MatchQueryStatus from '@/components/make-status-query'; const DoctorAdminTable = () => { const columns = useAdminDocColumns(); const query = useAdminDoctors('approved'); return ( <> <MatchQueryStatus query={query} render={(response, isLoading) => ( <DataTable columns={columns} data={response || []} isLoading={isLoading} /> )} /> </> ); }; export default DoctorAdminTable;

Conclusion

The MatchQueryStatus component is a flexible and reusable way to handle the various states of asynchronous data in a React application. By leveraging React Query and customizable props, you can maintain a consistent structure for your API calls and keep your components clean and readable. This approach can significantly enhance the user experience, especially in complex applications where multiple asynchronous calls are made.