Common data fetching patterns for real apps with react-query

JB
Nerd For Tech
Published in
9 min readJun 7, 2021

--

React-query is a gorgeous data fetching library that has received well-deserved praise. This article will detail patterns for a CRUD app, plus some bonus patterns.

The documentation for react-query is excellent, but I found myself wishing for more practical examples while learning it. These examples will assume some familiarity with react-query, such as with the useQuery hook, but not beyond the basics. This example app will be a note-writing tool like Evernote, and I’ll just outline data fetching cases.

Fetching data:

  1. Fetch data and handle results: useQueries.
  2. Fetch a subset of data using query strings or query params: useQuery with parameters.
  3. Fetch data conditionally: useQuery with enabled.
  4. Act when query finishes: useQuery with onSuccess, onError, or onSettled .
  5. Paginate fetched data:useInfiniteQuery.

Read before starting:

I use the library axios to make all API calls, and will use it here, but it is not necessary. Axios and react-query both return responses wrapped in an envelope called data , so it will result in needing to drill into objects like data.data to get results. This is kind of ugly syntax. For readability, I write all of my API calls as separate functions and call them within the useQuery hook. Again, this is just a personal preference for clarity and is not necessary.

For a larger app, I would recommend storing all API calls in custom hooks, in the pattern outlined in this example from the docs here, and this Medium article.

This article will describe working with APIs that fetch data from authenticated users. The specifics of building this API is outside the scope of this article.

  1. Fetch data and handle results: useQueries

We want to fetch all of our notes to see a broad overview, but we will also be also to stores notes in different folders, and only show notes in a particular folder. For the home screen, we want to fetch all posts and all folders. All of the posts will be shown on the home screen, and all of the folders will be shown as a side nav menu.

We will use two queries: one to fetch the posts, and one to fetch the folders.

Here, all is an array that stores the query results fetchFolders and fetchPosts.

const fetchFolders = async () => {const res = await axios.get('/folders')return res.data}const fetchPosts = async () => {const res = await axios.get('/posts')return res.data}const all = useQueries([{ queryKey: 'fetchFolders', queryFn: () => fetchFolders() },{ queryKey: 'fetchPosts', queryFn: () => fetchPosts() },]);if(all.some(e => e.isLoading)) return <Loading/>if(all.some(e => e.error)) return <Error/>return (   <Home data={all}/>)

Using the some method for useQueries is a personal preference that I find useful when you want to return a component unless all queries have returned from a useQueries call.

some is a vanilla JS operator that returns a boolean based on if any element in an array meets a condition. MDN docs.

2a. Fetch a subset of data: useQuery with query params

All of the posts are stored at the /posts endpoint, but there are times we don’t want to return every single post. If a user navigates to a folder, we only want to return the posts that are stored in that folder. Note that all of the posts must be saved with their folder to accurately search posts by folder.

Say the folder “Book notes” has the slug book-notes, which we are taken to when we click the folder name.

This is an opportunity to show two different queries with additional parameters. First, we look up the folder by its slug book-notes and get its id.

To look up the folder by slug, we are identifying a resource so we used a query param to build an endpoint that looks up folders by slug (note that folder slugs must be unique for this to work correctly).

const slug = props.match.params.folder;const fetchFolder = async (slug) => { 
const res = await axios.get(`/groups/${slug}`)
return res.data
}
const { data, isLoading, error } = useQuery(
['fetchFolder', slug],
() => fetchFolder()
);

From the react-query docs:

When a query needs more information to uniquely describe its data, you can use an array with a string and any number of serializable objects to describe it. This is useful for:

- Hierarchical or nested resources — It’s common to pass an ID, index, or other primitive to uniquely identify the item

- Queries with additional parameters— It’s common to pass an object of additional options

By “more information to uniquely describe its data”, it means query params or strings that specify specific resources or conditions to return documents from a data collection. Therefore the query key for this query is [fetchFolder, slug], where slug is a variable that corresponds to the value of the slug in the URL param. So if our full URL was www.notesapp.com/book-notes, the query key is [fetchFolder, book-notes] . This creates a unique query for this room alone. Query behavior can be unpredictable if different queries are using the same key.

With the folder returned in the data object, we can get the folder id from the data._id property. Then, we look up all posts whose folder value is that id.

2b. Fetch a subset of data: useQuery with query strings.

This is a good opportunity to show useQuery with query strings. It’s very similar to query params.

const fetchFolderPosts = async (id) => { 
const res = await axios.get(`/posts?folder=${id}`)
return res.data
}
const { data, isLoading, error } = useQuery(
['fetchFolderPosts', {folder: id}],
() => fetchFolderPosts()
});

As we read in the docs, it’s common to pass a unique identifier as a string in the query key array, and an object of additional options. Normally, query params, like unique ids and slugs, will be passed in the query key as a string, while parts of query strings, like filtering options, will be passed as keys to an object. If we had many options, they would be passed like, {folder: id, sort: date, tag: category } , etc.

Note that passing values as objects vs. strings should not impact the functionality as long as the query has a unique key. It is just a convention.

3. Fetch data conditionally:

useQuery with enabled.

This query is actually dependent on the one before it to finish before it may execute. Without being passed a folder id, it shouldn’t run. Therefore, this query should be idle unless the previous query fetching the folder has successfully returned an id. This is done using the enabled boolean that’s part of the useQuery hook.

const fetchFolderPosts = async (id) => { 
const res = await axios.get(`/posts?folder=${id}`)
return res.data
}
const { data, isLoading, isIdle, error } = useQuery(
['fetchFolderPosts', {folder: id}],
() => fetchFolderPosts(),
{
enabled: !!id
}
});

Note: Using the !! converts a non-boolean value to boolean. See more here.

This query will now be in an idle state unless the id value we pass it exists, aka it isn’t null , undefined , “”, etc.

Because we’re introducing a new possible state to this query, the idle state, we need to handle this along with the isLoading and isError states. The idle state is when the query has not begun fetching.

const fetchFolderPosts = async (id) => { 
const res = await axios.get(`/posts?folder=${id}`)
return res.data
}
const { data, isLoading, isIdle, error } = useQuery(
['fetchFolderPosts', {folder: id}],
() => fetchFolderPosts(),
{
enabled: !!id
}
});
if(isLoading || isIdle) return <Loading/>if(error) return <Error/>

If the isIdle state isn’t handled here, the function will try to render the component before the data has been fetched and error out.

4. Act when query finishes: useQuery with onSuccess, onError, or onSettled

react-query provides the onSuccess , onError , and onSettled functions to act after the query has finished. Each of these functions has different built-in arguments:

onSuccess (data, payload)

onError(error, payload)

onSettled(data, error, payload)

You can access the payload , or the data passed into the query function, as the final argument of each function. I could not find this in the react-query docs, but it works and the PR for it is on GH here.

We will use onSuccess to get the folder returned from our search to global state, so we can use it for other things, like creating a list of “recently accessed” folders that the user can use for shortcuts.

In a parent component, store a state list of recently accessed folder names. Pass the children the setRecents method using props or a state library.

const [recents, setRecents] = useState([]);return (
/// return children
)

In the original Folder component:

const fetchFolderPosts = async (id) => { 
const res = await axios.get(`/posts?folder=${id}`)
return res.data
}
const { data, isLoading, error } = useQuery(
['fetchFolderPosts', {folder: id}],
() => fetchFolderPosts(), {
onSuccess: (res) => {
const newList = recents.concat(res.data);
setRecents(newList); }
}
});

5. Paginate fetched data: useInfiniteQuery

It’s a fact of life that paginating data is a huge pain. The useInfiniteQuery hook from react-query makes things significantly easier, but I find that pagination is necessarily a bit hairy.

I use the Mongoose Paginate library with mongoose to help pagination from the backend.

Let’s refactor the query to fetch posts by folder to include pagination. Say we only want 20 posts to appear in a single view in a folder.

const fetchPosts = async (pg, id, limit) => {
const offset = (pg - 1) * limit;
const res = await axios.get
(`/posts?folder=${id}&offset=${offset}&limit=${limit}`)
return res.data
}
const {
fetchNextPage,
hasNextPage,
isLoading,
isError,
data } = useInfiniteQuery(
['fetchPosts', { folder: id }],
({ pageParam = 1}) => fetchPosts(pageParam, id, 20),
{
getNextPageParam: (lastGroup, allGroups) => {
return lastGroup?.nextPage || null; }
}
);

Let’s start with the fetchPosts function. This is a standard call to get paginated data that has variables in the query string for the: 1. folder id(same as before), 2. limit (number of posts to fetch), and 3. offset (the amount of posts to ignore before fetching).

The documentation for the useInfiniteQuery hook explains it in detail and there is also a dedicated guide to pagination.

The hook has getNextPageParam and getPreviousPageParam functions, used to page +1 forward and -1 back. I only use the getNextPageParam function here.

data returned from the hook now contains pagination data. The data progressively accumulates by default.

If we console.log the data returned from a useInfiniteQuery hook, we see an object with two properties:

{
pageParams
pages
}

data.pages contains fetched pages + associated metadata

  • Pages is an array of each returned data from the fetchPages API call
  • To display the posts, we need to loop through the array and get nested property docs , which actually contains the posts (example is below).

data.pageParams is the page params used to fetch pages

  • Its value is returned with the calc from getNextPageParam only. Discussion about this on the react-query GH.
  • pageParams keeps track of the current page. It is undefined on render, so it’s a good idea to set the current page to 1 by default, as we do when calling the function by passing the argument({ pageParam = 1 }).

The actual object returns looks something like this (for page 1):

pageParams: [undefined] 
pages:[{
docs: (20) [{...} {...} {...}]
hasNextPage: true
hasPrevPage: false
limit: 20
nextPage: 2
offset: 0
page: 1
pagingCounter: 1
prevPage: null
totalDocs: 26
totalPages: 2
}
]

For page 2:

pageParams: [undefined, 2] 
pages:[{
docs: (20) [{...} {...} {...}]
hasNextPage: true
hasPrevPage: false
limit: 20
nextPage: 2
offset: 0
page: 1
pagingCounter: 1
prevPage: null
totalDocs: 26
totalPages: 2
},
{
docs: (6) [{...} {...} {...}]
hasNextPage: false
hasPrevPage: false
limit: 20
nextPage: 2
offset: 0
page: 2
pagingCounter: 2
prevPage: 1
totalDocs: 26
totalPages: 2
}
]

A full component that displays the paginated data might look like this:

const fetchPosts = async (pg, id, limit) => {
const offset = (pg - 1) * limit;
const res = await axios.get
(`/posts?folder=${id}&offset=${offset}&limit=${limit}`)
return res.data
}
const {
fetchNextPage,
hasNextPage,
isLoading,
isError,
data } = useInfiniteQuery(
['fetchPosts', { folder: id }],
({ pageParam = 1}) => fetchPosts(pageParam, id, 20),
{
getNextPageParam: (lastGroup, allGroups) => {
return lastGroup?.nextPage || null; }
}
);
if(isLoading) return <Loading/>if(error) return <Error/>return (
<div>
{data.pages.map((el, i) => { return el?.docs.map((post, i) => { return (<Post key={post._id} post={post}/>)})})}{hasNextPage && <InlineButton label="more" handleClick={fetchNextPage}/> }</div>
)

That’s all five parts of this tutorial for fetching data with react-query. I love this library. If there’s interest, I can create a repo for this tut and/or make another guide for mutating data. This guide doesn’t dive into all of the more advanced use cases for react-query (which I’m also still learning), but I hope is a step above the info for early-state beginners.

References:

react-query docs

bits | start using react-query

dev.to | react-query: practical example

YouTube | useInfiniteQuery tutorial

YouTube | Tanner’s intro to react-query

YouTube | react-query for CRUD (1 hr)

--

--