How to use Data Loaders with React Query

Juan Otálora
4 min readJan 23, 2025

--

Generated using DALL·E

React Query (now Tanstack Query), although this article could also apply to other similar libraries like SWR, helps us manage state easily in our SPAs. However, it has one major drawback: handling many entities simultaneously. Let’s look at an example:

Imagine we have an app like GoodReads, where we want to display books and their ratings.

Our humble interface

Commonly, the rating information is fetched from a different endpoint than the one fetching book information. In a list of books, two different useQuery hooks are executed:

const bookIds = ["1", "2", "3"];

const { data: books = [] } = useQuery(
["books", bookIds],
() => fetchBooksFn(bookIds)
);

const { data: ratings = [] } = useQuery(
["ratings", bookIds],
() => fetchRatingsFn(bookIds)
);

Easy peasy, right? Now imagine the user add a new book from a form on the same page, a book with id=4. Well, you’re fuc**ed. Even though you already have the information for 3 out of the 4 books, all the data will be refetched again because the cache key has changed.

The solution to this (and to many other patterns, like optimistic updates, which I’ll cover in a future post) is to use useQuery to fetch data for a single entity. For example:

const List = () => {

const bookIds = ["1", "2", "3"];

return (
<div>
{bookIds.map(bookId => <Book bookId={bookId} key={bookId} />)}
</div>
);
};


const Book = ({ bookId }) => {

const { data: book } = useQuery(
["books", bookId],
() => fetchBookFn(bookId)
);

const { data: rating } = useQuery(
["ratings", bookId],
() => fetchRatingFn(bookId)
);

return (
<div>
{book?.name}
{rating?.value}
</div>
);
};

A new book in your list? No problem. The books and ratings data is cached individually. A new <Book /> component will render, fetching the data for the new book with id=4.

With this approach, you can implement cool features like lazy loading. Using virtualization, elements that aren’t rendered in the DOM won’t request their data until they appear in the viewport.

Beware of the Server

This approach sounds great in our example of 4 books, but what happens if you need to display information for 100 books? What if 1,000 users are requesting that data? The service calls will multiply, potentially causing a denial of service — something we want to avoid at all costs.

We have two solutions:

  1. Go back to our previous React Query architecture and fetch all the books and ratings in bulk.
  2. Use Data Loaders to group individual requests into a single batch request.

Data Loaders allow us to batch and debounce calls to a function, passing all the parameters from individual calls to a single batch function, which then returns results for all the requests. It might sound odd, but let’s look at a clearer example with our books:

const List = () => {

const bookIds = ["1", "2", "3"];

return (
<div>
{bookIds.map(bookId => <Book bookId={bookId} key={bookId} />)}
</div>
);
};


const Book = ({ bookId }) => {

const { data: book } = useQuery(
["books", bookId],
() => bookLoader.load(bookId)
);

const { data: rating } = useQuery(
["ratings", bookId],
() => ratingLoader.load(bookId)
);

return (
<div>
{book?.name}
{rating?.value}
</div>
);
};


async function batchBookFunction(bookIds) {
const results = await fetchBooksFn(bookIds); // Assume fetch returns a map
return bookIds.map(bookId => results[bookId]); // Maintain the same param order
}

const bookLoader = new DataLoader(batchBookFunction);


async function batchRatingFunction(bookIds) {
const results = await fetchRatingsFn(bookIds);
return bookIds.map(bookId => results[bookId]);
}

const ratingLoader = new DataLoader(batchRatingFunction);

The library ensures that each loader invocation receives the correct response as long as the parameter order is maintained.

A common mistake with Data Loaders is returning results from the batch function in an incorrect order, which could cause books and ratings data to be mismatched in our books list.

In summary, even though 3 loader invocations were made to fetch books and 3 for ratings, the data fetch occurred only once for books and once for ratings.

By default, Data Loader combines all individual load requests made within a single execution frame and calls your batch function with the aggregated set of keys. However, this behavior can be modified using a custom timeout:

const bookLoader = new DataLoader(batchRatingFunction, {
batchScheduleFn: callback => setTimeout(callback, 100),
});

I’m planning to write another article soon about more benefits of this architecture, including how to integrate it with the hexagonal architecture I explained in a previous post. Let me know if this topic interests you!

--

--

Juan Otálora
Juan Otálora

Written by Juan Otálora

Product Frontend Engineer @ Inditex. I write about software, product and design for Frontend Developers, empowering them for what the world needs.

No responses yet