How to level up your React API interactions with react-query's useQuery and useMutation
Writing async API calls from the front end has been one of the places I have observed consistent requirements for added state and complexity, often resulting in repetition, and sometimes pain.
A proper UX requires you to set up loading states, try / catch blocks, and result states in many places. While a more "brute force" method will do the job, it becomes difficult to keep clean, maintainable, and DRY (don't repeat yourself!) code as a project grows.
react-query
is a popular tool for this reason. I have had great success with it, and it should be an essential tool in any React developers toolbox.
Why we need react-query
Let's start with a tangible example of fetching some data for a page without react-query
:
import fetch from 'node-fetch';
const results = await fetch(<API_ENDPOINT>)
If we want to load in data once a component mounts, we have to utilize a React.useEffect
to trigger the function automatically. To display the result, we then need to plug the response into a state variable, so the component will re-render once data is available:
import * as React from "react";
import fetch from 'node-fetch';
const AntiPattern1 = () => {
const [data, setData] = React.useState()
const fetchData = async () => {
// just an example
return fetch(<API_ENDPOINT>)
}
// the useEffect operation can't be async, so we must call async inside of another function
React.useEffect(()=>{
const loadInData = async () => {
setData(await fetchData())
}
loadInData()
}, [])
return (
<div>
{data}
</div>
);
};
It's already quite busy and roundabout, and we are not even handling error or loading states.
To track loading, we must maintain another state variable, toggled before and after the API operation, and then we can use the loading state to render a loading indicator. For handling errors, we would also need to implement a try / catch and track an error state.
import * as React from "react";
import fetch from 'node-fetch';
const AntiPattern2 = () => {
const [data, setData] = React.useState()
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(false)
const fetchData = async ()=>{
// just an example
return fetch(<API_ENDPOINT>)
}
// the useEffect operation can't be async, so we must call async inside of another function
React.useEffect(()=>{
const loadInData = async () => {
setLoading(true)
setData(await fetchData())
setLoading(false)
}
try {
loadInData()
}
catch(){
setLoading(false)
setError(true)
}
}, [ ]);
if (loading) {
return <div>Loading!</div>
}
else if (error) {
return <div>Error!</div>
}
else {
return (
<div>
{data}
</div>
);
}
};
We can't avoid doing all of these somersaults, because they are often necessary. If an application requires many different async calls, we would have to repeat this process many times over.
The DRY philosophy would encourage us to generalize this all into a re-usable piece of code. Thankfully, somebody already did it, and created a library calledreact-query
.
How to fetch data with react-query's useQuery
To wrangle loading, results, and errors asynchronously, we can cut all of those lines down with a useQuery
, looking something like this:
import * as React from "react";
import { useQuery } from "react-query";
import fetch from 'node-fetch';
const GoodPattern1 = (|) => {
const { data, isLoading, isError } = useQuery({
queryFn: ( )=>{
// any async function can be used
return fetch(<API_ENDPOINT>)
}
});
if (isLoading) {
return <div>Loading!</div>
}
else {
return (
<div>
{data}
</div>
);
}
};
With this library, we instantiate some stateful data through a hook, and it will manage the async calling, as well as the data, loading, and error states. The queryFn
argument is what will get called by the hook, so all we need to do is define the function itself.
The hook takes other arguments as well, and can wait for a state update or manual action to trigger.
Another useful feature is passing onSuccess
and onError
methods, which we will see below.
How to use react-query to make single API calls (like PUTs or POSTs) with useMutation
Like the name implies, useQuery
is best suited to queries. If we want to make some sort of action-based mutation / PUT / POST request, we can use useMutation
.
import * as React from "react";
import { useMutation } from "react-query";
import fetch from 'node-fetch';
const GoodPattern2 = (|) => {
const fetchData = async ()=>{
// any async call can be used
return fetch(<API_ENDPOINT>, {
method: 'POST'
}
)
}
const { mutate, data, isLoading } = useMutation(fetchData, {
onSuccess: () => {
console.log("succeeded");
},
onError: () => {
console.log("error");
},
});
if (!data){
return <button onClick={mutate}>Call API</button>
}
else if (isLoading) {
return <div>Loading!</div>
}
else {
return (
<div>
{data}
</div>
);
}
};
Similar to useQuery
, we instantiate a stateful hook which manages our data for us, and we can trigger the mutation by calling the mutate
function which the hook exposes.
How leverage Typescript with useQuery
and useMutation
If you want to type the input and response of API calls, the library also gives you a framework to do so with ease.
The interface of useQuery
looks like this(all args are optional):
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
If you wish to instantiate the hook with some typing, you can do so like below:
const { data } = useQuery<TExpectedResponse>(<ARGS>)
The above will declare that our data
variable will be of type TExpectedResponse
.
If you wan't to read about typing for react-query
, this article breaks it down well.
Conclusion
react-query
is an essential tool, leverage it wherever you can! Most low-to-medium complexity API operations will be suited perfectly for this tools.
Cheers and thanks for reading! For more content or any questions, feel free to contact me on Twitter at @mikleens.