Skip to content
October 10, 2021

When not to use React Query

Or when component level fetching becomes problematic

ReactQuery announced the removal of the callbacks (onSuccess, etc) in the next major version.. In this article I was expanding on how these callbacks can cause issues, so I consider the post now irrelevant. I will leave it here for historical reasons.

I feel that this validates my (controversial) point, that the callbacks are not the best way to handle side effects.

React query is a fantastic library. I have written about it in "Separating server cache & application state with React Query". It's so good, that for the majority of the cases, in combination with a light state management solution (Zustand, Jotai, Recoil) that's all you need.

Some great cases for using React query

  1. Fetching and caching a single resource by id. Something like an article for example. The user selected this resource and we display it in isolation.
TypeScript
useGetArticleInfoQuery.ts
function useGetArticleInfoQuery(articleId: string) {
  return useQuery({
    queryFn: getArticleInfoQuery,
    queryKey: ['article', {articleId}],
    staleTime: Infinity,
  });
}
  1. Fetching a list of options and caching them for the remainder of the user journey. For example the article categories to display in a dropdown component. Can be reused across the application, and doesn't belong to a specific store. Can also use mutations and create a new category, updating the cache.
TypeScript
useGetArticleCategoriesQuery.ts
function useGetArticleCategoriesQuery() {
  return useQuery({
    queryFn: getArticleCategoriesQuery,
    queryKey: ['article-categories'],
    staleTime: Infinity,
  });
}
  1. Fetching a finite list of items, with some filters. Could be the last 5 articles.
TypeScript
useGetArticlesQuery.ts
function useGetLatestArticlesQuery() {
  const [limit, category] = useStore(
    (state) => [state.latestArticles.limit, state.latestArticles.category],
    shallow
  );
  return useQuery({
    queryFn: getArticles,
    queryKey: ['articles', {limit, category}],
  });
}

When things get weird

Here are some cases where moving the data fetching away from the component level helps:

  1. When we need fine-grained control over the sequence of the network requests.
  2. When the response of any network request, is needed for the next one.

Imagine you have a dashboard with a lot of components that start fetching the moment we load the page. What happens when we want to reduce the number of calls done simultaneously? Using dependent queries might help, but we have to pull more business logic in the hooks.

Here's another one, say we bring a list of 50 articles. One day, a Product Owner, asks us to add more metadata for each list item. These metadata are unrelated to the original call, probably coming from a 3rd party service. By moving the responsibility of fetching to the component level, we're looking for a good 50 simultaneous calls. We have to completely change our component (view) structure to make it work in a single one, just because we bundled business logic there. This comes at the cost of flexibility.

Again, nothing wrong with React query, just not the ideal tool for that fine-grained control needed. Something like RxJS is more like it.

Other code smells

Here's an interesting example of having paginated results with cursors. If you don't know about cursor-based pagination, we can't just go to page 2. We have to request the initial batch of data first and have the backend let us know about the cursor string that unlocks the following page.

So we we make a request and the api responds with:

TypeScript
response
{
  data: [...],
  cursor: 'some-cursor-string'
}

and we trigger the onSuccess callback to let our store know about the latest cursors, and we cache the data.

TypeScript
useGetArticlesQuery.ts
function useGetArticlesQuery() {
  const [limit, category, pageIndex, cursors, setCursors] = useStore(
    (state) => [
      state.latestArticles.limit,
      state.latestArticles.category,
      state.latestArticles.pageIndex,
      state.latestArticles.cursors,
      state.latestArticles.setCursors,
    ],
    shallow
  );
 
  // {0: null, 1: aqwWRQ}
  const targetCursor = cursors[pageIndex];
 
  return useQuery({
    queryFn: getArticles,
    queryKey: ['articles', {limit, category, cursor: targetCursor}],
    keepPreviousData: true,
    staleTime: Infinity,
    onSuccess: function (data) {
      if (data.nextCursor) {
        setCursors({
          ...cursors,
          [pageIndex + 1]: data.nextCursor,
        });
      }
    },
  });
}

Now here's the catch. The onSuccess handler won't fire again while the data are not stale.

If we change for example "category" and override the cursor object, when reverting back to the original one, we will be served from cache. The onSuccess handler won't fire, and we'll keep sending undefined to the backend, responding to us with the same first batch of data.

The solution is to use useEffect and manually check the payload each time, but again, this shouldn't be happening here.

Since the journey doesn't end with simply showing the results, and we need to have full control, the data fetching should live far away from the component.

You might say it's trivial, but what if we want a more complex orchestration?


React hasn't got some commonly accepted standards for how to build your application. And this is fantastic, we have so many choices to tackle our problems, but they come with tradeoffs.

React query is a fantastic tool. A tool that has saved my bacon a hundred times, and lets me just fetch some data, without handling the status or caching myself. But it's a tool that won't solve everything by itself.

Weird, complex, and unexpected requirements will eventually come your way, and the ability to be flexible is what will save your sanity. Making the network requests on the component level isn't always the best approach. So be vigilant and ready to make the appropriate adjustments, instead of trying to make a tool do something it's not responsible for. I've learned this the hard way.