DEV Community

Cover image for React query - The juicy parts
Yussif Mohammed
Yussif Mohammed

Posted on

React query - The juicy parts

So I came across react query when I was building my first startup and I checked it out. On the one of the pages it said if you handled caching, pagination etc, then you're good, so I was like "I've handled all these myself, I'm good" (I was wrong) and I ended up not using react-query, but then I kept seeing people praise react-query so I decided to use it for my second startup and ooh boy, it's so dang good that I'm glad I didn't use it at first because I now appreciate the problems it solves, the time it saves and the overall developer experience is excellent.
In this article, I'll talk about;

  • Problems react-query solves
  • Using react-query (focusing on the juicy parts)

Problems React-query solves

In a typical full-stack application there is an HTTP request and response cycle going on. There are different request methods these include; GET, POST, PUT, DELETE, PATCH etc. These methods can be grouped into two groups i.e fetching data from server and updating server data. GET falls under fetching data and the rest fall under updating data, because of this, the foundation of react-query is also fetching and updating data. Just fetching and updating data is easy you can do it with axios or the browser fetch api, but in most applications just fetching and updating data is not enough to give users a good experience and also not optimizing this process will result to excess load on the server and so you want to cache data when necessary, when a user makes a POST request you update already fetched data or invalidate cache if necessary, also if data is too large, you fetch the data in chunks (pages) and so you'll need to handle pagination
in summary, react-query solves these common problems;

  • querying (parallel queries, dependent queries etc)
  • caching
  • pagination
  • mutations (updating data)
  • invalidating and updating cached data (managing state)

React-query is a lot and so based on the above common problems I'll be focusing on the juicy parts.

Using react-query (focusing on the juicy parts)

Based on my experience, I found the juicy parts to be;

  • useQuery
  • useQueries
  • useMutation
  • useQueryClient
  • useInfiniteQuery

1. useQuery

useQuery is the hook used to make http GET requests.
A basic usage example is

   const { data, isLoading, isError } = useQuery(
        ['users'],
        () => axios.get('https://api.example.com').then((res) => res.data),
        {
            staleTime: 1000,
        }
    );

Enter fullscreen mode Exit fullscreen mode

The useQuery hook takes three arguments, you can also use the object syntax which is basically the same thing.

  • query key
  • query function and
  • options

useQuery(queryKey, queryFn, options)

Query key

The query key is what react-query uses to know which cached data to return when you're making the same request.react-query checks if there is data associated with the cache key and if the data is valid, then it returns that data.
How does react query know if data is stale or invalid?
you tell react-query when cached data should not be used by using the staleTime option and invalidateQuery method of useQueryClient we'll get into those later.
They query key can be a string, an array of strings and an array with nested objects. When a single string is used as query Key, it's converted to an array with that string as the only item.
Examples

//Valid query keys
 queryKey = "followers" //Converted to ["followers"]
 queryKey = ["followers", {user_name:"john_doe"}]
 queryKey = ["followers", 5, {user_name:"jane_doe", verified:true}]
Enter fullscreen mode Exit fullscreen mode

Query Keys are hashed deterministically, which simply means for query keys with nested objects, the order of the keys of those objects don't matter.
These query keys are all equal

 useQuery(['users', { verified, followers_count }], queryFn, options)
 useQuery(['users', { followers_count, verified }], queryFn, options)
Enter fullscreen mode Exit fullscreen mode

However for array items the order is important

// These query keys are not equal
useQuery(["followers", user_name], queryFn, options)
useQuery([user_name, "followers"], queryFn, options)
Enter fullscreen mode Exit fullscreen mode

Query function

The query function is the function that makes the API call and whatever data the function returns is what get assigned to {data} returned by the useQuery hook.
For example:

 const fetchUsers = () => axios.get("https://api.example.com")
 //Here "data" will be axios response
 const {data} = useQuery(["users"], fetchUsers) 
 console.log(data) /*
    {
  // `data` is the response that was provided by the server
  data: {users: ["user1", "user2", "user3", "user4"]},
  status: 200,
  statusText: 'OK',
  headers: {},
  config: {},
  request: {}
}
*/
Enter fullscreen mode Exit fullscreen mode

With this value of data if we want to access the data returned by the server (users) will have to use data.data.users
But when making API mostly is the data returned by the server that we want, so it's good to make your query function return that instead
Example

 const fetchUsers = () => axios.get("https://api.example.com").then(res => res.data)

 //Here "data" will be what server returns
 const {data} = useQuery(["users"], fetchUsers) 
 console.log(data) // {users: ["user1", "user2", "user3", "user4"]
Enter fullscreen mode Exit fullscreen mode

You don't need to catch errors, react-query will do that for you and return it as error in the useQuery return object, more on that later.

Options

Options is the optional third parameter of the userQuery hook.
There are lot of options you can add to the Options object but as usual, we are going to focus on the juicy ones, you can check the react-query documentation for the rest.
The ones we are focusing on include;

  • staleTime
  • enabled
  • retry
  • onSuccess
  • onError
  • onSettledd
staleTime

The time in milliseconds after which data is considered stale. If set to Infinity the cached data will never be stale. It defaults to 0.
When staleTime is 0, react-query will always re-fetch data whenever the component is mounted or when user focuses on window (when they focus after leaving the browser) but when staleTime is set, react-query by default will not re-fetch data until the time set has passed
Example:

  const { data, isLoading } = useQuery(
        ["users"],
        fetchUsers,
        {
            staleTime: 60 * 1000 // 1 minute
        }
    )
Enter fullscreen mode Exit fullscreen mode

react-query by default will only re-fetch data if last fetch time was more than 1 minute ago.

Notice I keep using "by default"? this is because this behavior can be changed, you can tell react-query to re-fetch data on component mount or on window focus regardless of if data is stale or not.
Here is how

    const options = {
        refetchOnMount: "always",
        refetchOnWindowFocus: "always" 
    }
Enter fullscreen mode Exit fullscreen mode
enabled

The enabled option is used to tell react-query whether to fetch the data or not, react-query fetches data only if enabled is true. For example if you have a search feature on your app and you only want to make the API call when the user enters up to three characters. You can do that by using the enabled option.

Example:

  const [query, setQuery] = useState("")

  const { data, isLoading } = useQuery(
        ["search"],
        searchUsers,
        {
            enabled: query.length > 2
        }
    )
Enter fullscreen mode Exit fullscreen mode
retry

retry option simply tell react-query what to do when a query fails.
if set to true react-query will retry infinitely until the query is successful.
if set to false react-query will not retry
alternatively you can set it to a number
eg: if set to 3 ({retry: 3}) react-query will retry 3 times when the query first fails

onSuccess, onError and onSettled

onSuccess is a function that gets called when a query is successful
onError is a function that gets called when a query fails and onSettled is a function that gets called when query is settled (whether it's successful or it failed)

    const [query, setQuery] = useState("")

    const { data, isLoading } = useQuery(
        ["search"],
        searchUsers,
        {
            enabled: query.length > 2,
            onSuccess: (data) => { 
                // Called when data is successfully fetched

            },
            onError: (error) => {
                // Called when query fails

            },
            onSettled: (data, error) => { 
                // Always called regardless of the outcome

            }
        }
    )
Enter fullscreen mode Exit fullscreen mode

useQuery return Object

Went through how to use useQuery, now let's talk about what it returns. the object returned by the useQuery hooks has a lot of properties and will focus on the ...
Here there are

  • data this is the data returned by the query function if query is successful
  • error this receives the error as a result of a failed query (network error or error return by server)
  • isLoading a boolean which is set to true if there's no cached data and no query attempt was finished yet and false otherwise
  • isError a boolean which is set to true if query fails
  • isFetching a boolean which is set to true whenever the queryFn is executing, which includes initial loading as well as background refetches and false otherwise.
  • isSuccess is true if a query is successful

The difference between isLoading and isFetching is that isLoading is only set to true on first fetch (i.e no cache data, or query has not be executed before) but isFetching is set to true on first fetch and when fetching is happening in the background (refetching on mount, refetching on window focus etc)

2. useQueries

useQueries is simply making two or more useQuery at the same time, and it returns and array of useQuery return object for each query
Usage:

const results = useQueries({
  queries: [
    { queryKey: ['user', user_name], queryFn: fetchUser, staleTime: Infinity},
    { queryKey: ['posts', user_name], queryFn: fetchUserPosts, staleTime: Infinity}
  ]
})
Enter fullscreen mode Exit fullscreen mode

In the above example results is an array of useQuery return value for each query inside the queries array

3. useMutation

This hooks is used to make update HTTP requests. these requests make changes to the data on the server as a result it could make already cached data with useQuery in correct, we'll learn how to use useMutation in combination with useQueryClient to make changes to data on the server and invalidate cached data that may be affected by those changes.

Basic Usage:

 const changeName = useMutation(
        (newName) => {
            return axios.put(`/user/name`, {name: newName})
        },
        {
            onSuccess: (data) => {
                const { message, type } = data.data 
            },
            onError: (err) => {
               console.log(err)
            }
        }
    )
Enter fullscreen mode Exit fullscreen mode

That is how you initialize a react-query mutation, But this doesn't make any request yet. calling useMutation returns and object with the following properties

  • mutate
  • isLoading
  • isError
  • isSuccess
  • data
  • error etc.

We'll be focusing mutate because the rest work similar to that of useQuery

mutate is function that when called makes the actual update http request.
with regards to the above example, to make api request to change a user's name will do something like this

function ChangeName() {

    const changeName = useMutation(
        (newName) => {
            return axios.put(`/user/name`, { name: newName })
        },
        {
            onSuccess: (data) => {
              // Success same as that of useQuery
            },
            onError: (err) => {
                // Error same as that of useQuery
            },
        }
    )
    return (
        <form
            onSubmit={(event) => {
                event.preventDefault()
                // Call mutate to invoke the useMutation function
                changeName.mutate(new FormData(event.currentTarget).get('new_name'))
            }}
        >
            <input name="new_name" />
            <button type="submit">Change</button>
        </form>
    )
}
Enter fullscreen mode Exit fullscreen mode

That's mostly all you need to know about useMutation.
In the above code the use just change their name, assuming we've fetched user data with useQuery and used ["user"] as our query key with staleTime of Inifinity this will mean that old name will continue to be showed even though the name has changed. We need a way to tell react-query that the cached data is not longer correct and it should be refetched. we'll discuss that in the next chapter

4. useQueryClient

The useQueryClient hooks returns the QueryClient instance which is passed as a prop to QueryClientProvider which means it gives us access to the heart of react-query and we can do cools things with it. We'll focus on using it to invalidate queries and also update cached data

Invalidating queries

So in the above useMutation example we needed a way to tell react-query that the user data is no longer accurate and so it should refetch. This is how we do that using useQueryClient.

function ChangeName() {
    const queryclient = useQueryClient()
    const changeName = useMutation(
        (newName) => {
            return axios.put(`/user/name`, { name: newName })
        },
        {
            onSuccess: (data) => {
                // This will invalidate the cached user data
                // and the user data will be refetched
                queryclient.invalidateQueries(["user"])
            },
            onError: (err) => {
                // Error same as that of useQuery
            },
        }
    )

    return (
        <form
            onSubmit={(event) => {
                event.preventDefault()
                // Call mutate to invoke the useMutation function
                changeName.mutate(new FormData(event.currentTarget).get('new_name'))
            }}
        >
            <input name="new_name" />
            <button type="submit">Change</button>
        </form>
    )
}
Enter fullscreen mode Exit fullscreen mode

What we just did above is good, but should be we refetch the user data just because the name has changed? NO. Since the change happened on the users browser and we know the new name we could just update the user's cached data with the new name by use setQueryData method of the queryClient you can use that to modify cached data.
Here's how:

function ChangeName() {
    const queryclient = useQueryClient()
    const [newName, setNewName] = useState("")
    const changeName = useMutation(
        (newName) => {
            return axios.put(`/user/name`, { name: newName })
        },
        {
            onSuccess: (data) => {
                // This doesn't cause refetch and 
                // we also have correct data 
                queryclient.setQueryData(["user"], (oldData) => {
                    return { ...oldData, name: new_name }
                })
            },
            onError: (err) => {
                // Error same as that of useQuery
            },
        }
    )

    return (
        <form
            onSubmit={(event) => {
                event.preventDefault()
                // Call mutate to invoke the useMutation function
                changeName.mutate(newName)
            }}
        >
            <input name="new_name" value={newName} onChange={(e) => setNewName(e.target.value)} />
            <button type="submit">Change</button>
        </form>
    )
}
Enter fullscreen mode Exit fullscreen mode

Another important aspect of invalidating queries is using the right query keys.

 const queryclient = useQueryClient()

    // The code below also invalidates queries with the following keys
    // ["user", "followers"]
    // ["user", {user_name}]
    queryclient.invalidateQueries(["user"])
Enter fullscreen mode Exit fullscreen mode

Alternative you can use the exact property to make sure only queries that exactly match the query key get invalidated

  const queryclient = useQueryClient()

    // Does not invalidate queries with the following keys
    // ["user", "followers"]
    // ["user", {user_name}]
    queryclient.invalidateQueries(["user"], { exact: true })
Enter fullscreen mode Exit fullscreen mode

5. useInfiniteQuery

Assuming you're building an application that has comment section and a post has 2000 comments, when a user clicks to see the comments you wouldn't want to send all 2000 comments to the user reason being the user can not read all 2000 comments, you'll be using too much bandwidth and it'll take a long time to load, instead you'll want to fetch the comments in chunks say 1-10 and when user clicks load more or has finished reading those (when the last comment is in view) you then fetch 11-20, then 21-30 and so on. You can do this with axios and useState but react-query made incredibly easy.
Like the useQuery hook, useInfiniteQuery takes queryKey, queryFn and options you can also box queryKey and queryFn in the options object and it's also what the react-query team recommends.
useInfiniteQuery also takes another important option which is getNextPageParam and it also returns an important function called fetchNextPage. These two functions are very important when using useInfiteQuery

Usage:

const fetchComments = ({ pageParam = 0 }) => axios.get(`${BASEURL}/comments/${postID}/${pageParam}`).then(res => res.data)

 const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
  } = useInfiniteQuery(["comments", { postID }], fetchComments,
    {
      // Function that tells react-query the page to fetch next
      getNextPageParam: (lastPage, pages) => {
        if (lastPage.pagination.isEnd) {
          return undefined
        }
        return lastPage.pagination.next
      },
      staleTime: Infinity
    })
Enter fullscreen mode Exit fullscreen mode

Let's break the above code down starting with pageParam
pageParam is cursor or the page we're fetching, for the initial fetch, it's 0 or 1 depending on you handle pages on the backend. So what about the subsequent queries how does react-query now the page it should fetch? that's what the getNextPageParam does, It is a callback function that react-query passes lastPage and pages to and then calls the function whatever the function returns becomes the pageParam for the next query, if it returnsundefined it means we've reached the last page and there are no more pages to fetch, If the getNextPageParam returns undefined, the hasNextPage property of the returned value of useInfiniteQuery is set to false otherwise it's set to true. You can use the hasNextPage value to know whether to show load more button or not.
the lastPage parameter is the data returned by the server after the last fetch and pages is data of all fetches (pages)
do determine what page to fetch next, the server normally returns something like {comments: [], pagination:{next:11, isEnd: false}}
So the get the next pageParam we use lastPage.pagination.next and if the isEnd is true we return undefined to tell react-query that we've reached the end of the comments. Once you have your getNextPageParam function set up all you need to do to fetch next page is to call the fetchNextPage property of the useInfiniteQuery return object. So when user clicks on load more you call fetchNextPage

Example putting it all together

function Comments() {
    const fetchComments = ({ pageParam = 0 }) => axios.get("/post/comments/${postID}/${pageParam}").then(res => res.data)
    const {
        data,
        fetchNextPage,
        hasNextPage,
        isFetching,
    } = useInfiniteQuery(["comments", { postID }], fetchComments,
        {
            getNextPageParam: (lastPage, pages) => {
                if (lastPage.pagination.end) {
                    return undefined
                }
                return lastPage.pagination.next
            },
            staleTime: Infinity
        })
    // Since data returns an array of pages
    // you'll need to combine all the pages into one array
    // so you can map all comments and render on user's screen
    let comments: any[] = []
    if (data?.pages) {
        for (let page of data?.pages) {
            comments = comments.concat(page.comments)
        }
    }

    return (
        <div>
            {
                comments.map((comment) => {
                    return (
                        <Comment
                            key={comment.id}
                            user_name={comment.user_name}
                            comment={comment.comment}
                            date={new Date(comment.created_at)}
                            likes_count={comment.likes_count}
                        />
                    )
                })
            }
            {
                hasNextPage && (
                    <div style={{height:"10px"}}>
                        {!isFetching ?
                            <button onClick={() => fetchNextPage()}>Load more</button>
                            :
                            <Loading />
                        }

                    </div>
                )
            }
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can use intersectionObserver to automatically load comments so user doesn't have to click on load more making it an infinite scroll.
Example:

function Comments() {
    const fetchComments = ({ pageParam = 0 }) => axios.get("/post/comments/${postID}/${pageParam}").then(res => res.data)
    const {
        data,
        fetchNextPage,
        hasNextPage,
        isFetching,
    } = useInfiniteQuery(["comments", { postID }], fetchComments,
        {
            getNextPageParam: (lastPage, pages) => {
                if (lastPage.pagination.end) {
                    return undefined
                }
                return lastPage.pagination.next
            },
            staleTime: Infinity
        })
    // Since data returns an array of pages
    // you'll need to combine all the pages into one array
    // so you can map all comments and render on user's screen
    let comments: any[] = []
    if (data?.pages) {
        for (let page of data?.pages) {
            comments = comments.concat(page.comments)
        }
    }

    // Load comments automatically use intersectionObserver api
    const loaderRef = useRef(null)
    useEffect(() => {
        const observer = new IntersectionObserver(
            ([entry]) => {
                if (entry.isIntersecting) {
                    fetchNextPage()
                }
            },
            { threshold: 0.5 }
        );

        if (loaderRef.current) {
            observer.observe(loaderRef.current);
        }

        return () => observer.disconnect();
    }, []);

    return (
        <div>
            {
                comments.map((comment) => {
                    return (
                        <Comment
                            key={comment.id}
                            user_name={comment.user_name}
                            comment={comment.comment}
                            date={new Date(comment.created_at)}
                            likes_count={comment.likes_count}
                        />
                    )
                })
            }
            {
                hasNextPage && (
                    <div ref={loaderRef} style={{ height: "10px" }}>
                        {isFetching && <Loading />}
                    </div>
                )
            }
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

If you are not familiar with intersectionObserver you can learn more about it here

Conclusion

The topics we've discussed in this blog are the ones I found most useful, If you find yourself trying to do something not covered in this article, there is a high chance react-query allows you to do that, check the react-query documentation and you'll find it, the react-query team thought of everything.

Top comments (0)