Simplify TanStack React Query State Handling with Pattern Matching

How pattern matching can help elegantly handle different states (loading, error, success, empty) of TanStack React Query, making your components cleaner and more type-safe.

It’s interesting how simple solutions can often have a significant impact. Back at React Paris, I met a React developer who told me, “I remember one of your posts on LinkedIn; it was a snippet about React Query, and now all my coworkers are using it.” It took me some time to find it, but here is the post. It seemed like a good time to write a blog post about it.

Anyway, let’s get back to the main topic: Handling asynchronous data fetching is a core part of many web applications. TanStack React Query is a fantastic library for managing server state. However, rendering different UI based on the query’s status (loading, error, success, potentially empty) can sometimes lead to verbose and tedious conditional logic within components.

This post is inspired by Dominik Dorfmeister’s article Composition is great by the way.

The Challenge: Handling Multiple Query States

Typically, when using useQuery, you might end up with code like this:

import { usePostsListQuery } from "./posts/queries";
export default function PostLists() {
const postsQuery = usePostsListQuery();
if (postsQuery.isLoading) {
return <LoadingSpinner />;
}
if (postsQuery.isError) {
return (
<ErrorMessage error={postsQuery.error} refetch={postsQuery.refetch} />
);
}
if (!postsQuery.data || postsQuery.data.length === 0) {
return <EmptyMessage message="No posts found." />;
}
// Success state
return (
<ul>
{postsQuery.data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

While this works, the nested if statements or multiple ternaries can become cumbersome, especially as the component grows or if you need to handle these states repeatedly across your application.

Handling multiple queries within the same component makes writing these if statements even less enjoyable.

As mentioned in Dominik’s post, this can become tedious when you have a layout component wrapping this logic. For instance, let’s say the boundary of your component is represented by the red area below:

My websiteMy websiteMy websiteMy websitePostsPostsPostsPostsLoadingErroredEmptySuccessSomethingbad happened.Please enable youwifi!There is no postfor now in thiscategory. Post 1 Post 2 Post 3

You will need to repeat the layout component for each conditional branch. Ultimately, you might create a custom layout component like PostsListLayout and wrap the return statement of each condition with it.

Here’s how that might look, repeating the PostsListLayout component for each state:

import PostsListLayout from "./components/PostsListLayout";
import { usePostsListquery } from "./posts/queries";
export default function PostList() {
const postsQuery = usePostsListQuery();
if (postsQuery.isLoading) {
return (
<PostsListLayout>
<LoadingSpinner />
</PostsListLayout>
);
}
if (postsQuery.isError) {
return (
<PostsListLayout>
<ErrorMessage error={postsQuery.error} refetch={postsQuery.refetch} />
</PostsListLayout>
);
}
if (!postsQuery.data || postsQuery.data.length === 0) {
return (
<PostsListLayout>
<EmptyMessage message="No posts found." />
</PostsListLayout>
);
}
return (
<PostsListLayout>
<ul>
{postsQuery.data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</PostsListLayout>
);
}

This repetition makes the component harder to read and maintain.

Can we do better?

A Cleaner Approach: Pattern Matching Utility

To streamline this process, we can introduce a pattern matching utility function. I will describe the function later, but first, let’s look at the result.

Why? Because showing 15 lines of TypeScript upfront might scare some readers away. So, let’s focus on the benefits first before diving into the implementation details.

An example

Our component can look like this:

import { usePostsListQuery } from "./posts/queries";
import { matchQueryStatus } from "./utils/matchQueryStatus";
import PostsListLayout from "./components/PostsListLayout";
export default function PostsList() {
const postsQuery = usePostsListQuery();
return (
<PostsListLayout>
{matchQueryStatus(postsQuery, {
Loading: <LoadingSpinner />
Errored: (error) => <ErrorMessage error={error.message} />,
Empty: <EmptyMessage message="No posts found." />,
Success: ({ data }) => (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
})}
</PostsListLayout>
);
}

Pretty nice, isn’t it? All the complex conditional logic is gone!

When problems disappear magically

Each query state is mapped to a corresponding property (Errored, Loading, Empty, Success) in the options object, and each property holds the component or render function for that state.

This is strictly typed. For example, if you provide the Empty property, TypeScript ensures that the data passed to the Success function is non-nullable. Conversely, if you omit Empty, data will be typed as potentially null or undefined within Success.

export default function
function PostsList(): React.JSX.Element
PostsList
() {
const
const postsQuery: UseQueryResult<{
id: string;
title: string;
}[], Error>
postsQuery
=
function usePostsListQuery(): UseQueryResult<{
id: string;
title: string;
}[], Error>
usePostsListQuery
();
return (
<
const PostsListLayout: ({ children }: {
children: React.ReactNode;
}) => React.JSX.Element
PostsListLayout
>
{
matchQueryStatus<{
id: string;
title: string;
}[]>(query: UseQueryResult<{
id: string;
title: string;
}[]>, options: {
Loading: JSX.Element;
Errored: JSX.Element | ((error: unknown) => JSX.Element);
Success: (data: UseQueryResult<...>) => JSX.Element;
}): JSX.Element (+2 overloads)
matchQueryStatus
(
const postsQuery: UseQueryResult<{
id: string;
title: string;
}[], Error>
postsQuery
, {
type Loading: JSX.Element
Loading
: <
const LoadingSpinner: () => React.JSX.Element
LoadingSpinner
/>,
type Errored: any
Errored
: <
const ErrorMessage: () => React.JSX.Element
ErrorMessage
/>,
type Success: (data: UseQueryResult<{
id: string;
title: string;
}[]>) => JSX.Element
Success
: ({
data: {
id: string;
title: string;
}[] | undefined

The last successfully resolved data for the query.

data
}) => (
<
React.JSX.IntrinsicElements.ul: React.DetailedHTMLProps<React.HTMLAttributes<HTMLUListElement>, HTMLUListElement>
ul
>
{data.
Array<{ id: string; title: string; }>.map<React.JSX.Element>(callbackfn: (value: {
id: string;
title: string;
}, index: number, array: {
id: string;
title: string;
}[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
((
post: {
id: string;
title: string;
}
post
) => (
Error ts(18048) ― 'data' is possibly 'undefined'.
<
React.JSX.IntrinsicElements.li: React.DetailedHTMLProps<React.LiHTMLAttributes<HTMLLIElement>, HTMLLIElement>
li
React.Attributes.key?: React.Key | null | undefined
key
={
post: {
id: string;
title: string;
}
post
.
id: string
id
}>{
post: {
id: string;
title: string;
}
post
.
title: string
title
}</
React.JSX.IntrinsicElements.li: React.DetailedHTMLProps<React.LiHTMLAttributes<HTMLLIElement>, HTMLLIElement>
li
>
))}
</
React.JSX.IntrinsicElements.ul: React.DetailedHTMLProps<React.HTMLAttributes<HTMLUListElement>, HTMLUListElement>
ul
>
),
})}
</
const PostsListLayout: ({ children }: {
children: React.ReactNode;
}) => React.JSX.Element
PostsListLayout
>
);
}

How It Works: The “Hourglass Pattern”

💡
Info

This section delves into how I approach this kind of problem, offering a more theoretical perspective on reducing complexity and simplifying codebases. Feel free to skip to the next section if you are primarily interested in the implementation details.

A common issue is repeating complex logic that essentially maps the query state to the UI. This process can often be broken down into two distinct steps.

We can transform the single problem of “Mapping query to UI components” into two simpler, distinct problems:

  • Mapping the query result to a defined State: A generic, reusable step.
  • Mapping that State to specific UI Components: A simpler, component-specific step.
QueryStatusLoadingErrorSuccessDataNullUndefinedEmpty ArrayNon empty dataUILoadingErroredEmptySuccessMappingLogic????ComplexityEasinessMapping=Problem Reduction

Why an hourglass?

I like to visualize this as an hourglass. We funnel the complex query result object down into a smaller, well-defined set of states (the narrow part of the hourglass). And then, we expand back out from these simple states to the desired UI components (the bottom part of the hourglass).

ComplexityEasierLogicReductionSimplerMappingComplexityComplexityComplexMappingLogic

The Implementation

This utility function takes the query result object and an options object mapping query states to corresponding JSX elements or render functions.

Here’s the implementation of the matchQueryStatus utility:

import { type UseQueryResult } from "@tanstack/react-query";
/**
* Match the state of a query to a set of components.
*
* Useful for rendering different UI based on the state of a query.
*
* **Note:** if you don't provide an `Empty` component and the query is empty,
* the data in the Success component will be also typed as undefined.
* @example ```jsx
* const query = useQuery({... });
* return matchQueryStatus(query, {
* Loading: <Loading />,
* Errored: <Errored />,
* Success: ({ data }) => <Data data={data} />
* // ^ type of T | null
* })
* ```
* If you provide an `Empty` component, the data will be typed as non-nullable.
* @example ```jsx
* const query = useQuery({... });
*
* return matchQueryStatus(query, {
* Loading: <Loading />,
* Errored: <Error />,
* Empty: <Empty />,
* Success: ({ data }) => <Data data={data} />,
* // ^ type of data is T
* );
* ```
*/
export function matchQueryStatus<T>(
query: UseQueryResult<T>,
options: {
Loading: JSX.Element;
Errored: JSX.Element | ((error: unknown) => JSX.Element);
Empty: JSX.Element;
Success: (
data: UseQueryResult<T> & {
data: NonNullable<UseQueryResult<T>["data"]>;
},
) => JSX.Element;
},
): JSX.Element;
export function matchQueryStatus<T>(
query: UseQueryResult<T>,
options: {
Loading: JSX.Element;
Errored: JSX.Element | ((error: unknown) => JSX.Element);
Success: (data: UseQueryResult<T>) => JSX.Element;
},
): JSX.Element;
export function matchQueryStatus<T>(
query: UseQueryResult<T>,
{
Loading,
Errored,
Empty,
Success,
}: {
Loading: JSX.Element;
Errored: JSX.Element | ((error: unknown) => JSX.Element);
Empty?: JSX.Element;
Success: (data: UseQueryResult<T>) => JSX.Element;
},
): JSX.Element {
if (query.isLoading) {
return Loading;
}
if (query.isError) {
if (typeof Errored === "function") {
return Errored(query.error);
}
return Errored;
}
const isEmpty =
query.data === undefined ||
query.data === null ||
(Array.isArray(query.data) && query.data.length === 0);
if (isEmpty && Empty) {
return Empty;
}
return Success(query);
}

This function checks the query status (isLoading, isError) and data presence (isEmpty) and returns the corresponding component provided in the options object.

💡
Info

Notice the TypeScript overloads. If you provide an Empty component in the options, the Success handler receives the query object where data is guaranteed to be non-nullable (NonNullable<T>). This improves type safety and reduces the need for non-null assertions (!) in your success component.

The code is available as a Gist here.

Conclusion

Using a pattern matching utility like matchQueryStatus for TanStack React Query results offers several benefits:

  1. Readability: Consolidates state handling logic into a single, declarative structure.
  2. Maintainability: Easier to modify or add handling for specific states.
  3. Reusability: The utility function can be reused across different components.
  4. Type Safety: Leverages TypeScript for better guarantees, especially around non-nullable data in the success state when handling empty states explicitly.

While simple if/else checks are fine for basic cases, adopting a pattern matching approach can significantly improve the clarity and robustness of your React components when dealing with the various states of asynchronous operations managed by TanStack React Query.

Feel free to use this pattern and adapt the utility function for your own needs.

Note: This article intentionally omits discussion of Suspense and newer APIs like the use hook. While feedback on those is welcome in the comments, they represent a topic for a separate article.

Any thoughts about this? Or questions? Feel free to comment below :)