Data Fetching in React with react-query
One of the fundamental parts of web development is requesting data from a backend service or an API. This task is also known as data fetching in the frontend world.
In the early days of the frontend, we were usually building websites with HTML and CSS. The template or the pages were server-side rendered. And JavaScript was mainly used to do some animations and, at most, form validations.
The idea of fetching data asynchronously starts with a technique called Ajax, which stands for "Asynchronous JavaScript and XML".
I remember using XMLHttpRequest Web APIs object to request data from servers while the user interacted with the page. The usage of Ajax kept growing and one of the favorite toolings that we used back in the days was jQuery Ajax. It was very simple to use and less scary than the name XMLHttpRequest
.
Gmail and Trello were heavily using these techniques to retrieve and update info without having the browser reload the entire page.
Data fetching with Fetch
Nowadays we commonly use fetch
to handle data fetching. As MDN wrote:
"The Fetch API is basically a modern replacement for XHR; it was introduced in browsers recently to make asynchronous HTTP requests easier to do in JavaScript."
And it really made it easier to make async requests. Let's see a simple use:
fetch('https://pokeapi.co/api/v2/pokemon/pikachu')
.then((response) => response.json())
.then(console.log);
You can go right now and type this code in your Browser's console. It'll request the data from the Pokemon's API.
We pass the resource URL and the fetch
will return a Promise. The first .then
receives the request's response
object, which has a method called json
. The json
method returns the response JSON data. As the .then
returns another Promise, we can make chained promises. The console log will return the Pikachu's info.
We can use the async-await
to handle promises:
const response = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu');
const data = await response.json();
In modern browsers, we can use this code as they implemented top-level await.
React, Fetch, and custom hooks
In React application, we also need to handle data fetching. If you see 5 different codebases, it's possible to see 5 different ways of building the communication between the frontend and the server.
Let's implement our own fetch hook.
The hook's API would be very simple:
- It should receive an URL
- It can receive the initial data
- It will return the fetch state:
data
: the data returned from the API.isLoading
: a boolean that represents if the request is currently happenning or not.hasError
: a boolean that represents if the request got any error.
It should receive an URL
const useFetch = (url) => {};
It can receive the initial data
const useFetch = (url, initialData) => {
const initialState = {
isLoading: false,
hasError: false,
data: initialData,
};
};
The simple fetch can be wrapped in a useEffect
. It would look very similar to the async-await fetch that we did earlier.
useEffect(() => {
const fetchAPI = async () => {
const response = await fetch(url);
const data = await response.json();
};
fetchAPI();
}, [url]);
But now we need to build the request state: basically, if it's loading, if it got an error, the data that came from the server, and so on.
We could do that by simply using the useState
, but I'll show an example abstracting the logic in a reducer. To do that, we'll use the useReducer
hook. It looks like this:
export const FetchActionType = {
FETCH_INIT: 'FETCH_INIT',
FETCH_SUCCESS: 'FETCH_SUCCESS',
FETCH_ERROR: 'FETCH_ERROR',
};
export const fetchReducer = (state, action) => {
switch (action.type) {
case FetchActionType.FETCH_INIT:
return {
...state,
isLoading: true,
hasError: false,
};
case FetchActionType.FETCH_SUCCESS:
return {
...state,
hasError: false,
isLoading: false,
data: action.payload,
};
case FetchActionType.FETCH_ERROR:
return {
...state,
hasError: true,
isLoading: false,
};
default:
return state;
}
};
First we have all the actions:
FETCH_INIT
: when the fetch initializesFETCH_SUCCESS
: when the fetch succeedFETCH_ERROR
: when the fetch got an error
Then we have the reducer. It's basically a switch case mapping the action to the execution we do in the state.
For the FETCH_INIT
, we get the current state and update the isLoading
to true
(it's loading) and the hasError
to false
(in case it got an error before, we need to make sure that it doesn't have an error anymore as we are fetching again).
For the FETCH_SUCCESS
, we update the hasError
and the isLoading
to false
and add the request payload to the state.
For the FETCH_ERROR
, we just make sure that the isLoading
is false
and update the hasError
to true
.
Now that we have our reducer, let's use it in the custom fetch hook.
const [state, dispatch] = useReducer(fetchReducer, initialState);
As I mentioned earlier, we'll use the useReducer
hook and pass our new reducer and an initial state for it. The hook returns the current state and a function called dispatch
to dispatch actions that the reducer is listening to.
We can now modify our fetch and add the apropriate action dispatchers to it.
useEffect(() => {
const fetchAPI = async () => {
dispatch({ type: FetchActionType.FETCH_INIT });
try {
const response = await fetch(url);
const data = await response.json();
dispatch({
type: FetchActionType.FETCH_SUCCESS,
payload: data,
});
} catch (error) {
dispatch({ type: FetchActionType.FETCH_ERROR });
}
};
fetchAPI();
}, [url]);
- When the fetch starts, we dispatch the
FETCH_INIT
action. - When it got a success response, we dispatch the
FETCH_SUCCESS
action. - When it got an error, we dispatch the
FETCH_ERROR
action.
Doing this, we are always updating the state of our request. To finish we just return the state
. So the entire hook would look like this:
const useFetch = (url, initialData) => {
const initialState = {
isLoading: false,
hasError: false,
data: initialData,
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
const fetchAPI = async () => {
dispatch({ type: FetchActionType.FETCH_INIT });
try {
const response = await fetch(url);
const data = await response.json();
dispatch({
type: FetchActionType.FETCH_SUCCESS,
payload: data,
});
} catch (error) {
dispatch({ type: FetchActionType.FETCH_ERROR });
}
};
fetchAPI();
}, [url]);
return state;
};
Data fetching with react-query
Data fetching and server state are complex topics in web development when it comes to their challenges: caching, revalidation, complex async operations, retry logic, and so on.
react-query came as the solution for data fetching in React. It solves many common problems out of the box and its simplicity improves hugely the dev experience.
As stated in the Getting Started, the react-query's motivation was:
"Out of the box, React applications do not come with an opinionated way of fetching or updating data from your components so developers end up building their own ways of fetching data. This usually means cobbling together component-based state and effect using React hooks or using more general-purpose state management libraries to store and provide asynchronous data throughout their apps. While most traditional state management libraries are great for working with client state, they are not so great at working with async or server state. This is because server state is totally different." - react-query's motivation
Some people are also saying the library is the obvious choice to handle data fetching in React and it's becoming the main tool to solve this problem:
But we also have other choices to handle server cache like swr (Stale-While-Revalidate). The APIs look very similar to react-query.
react-query: set up
The library set up is very simple. All we need to do is to install it:
yarn add react-query
And add the QueryClient
as a provider for our app:
import { QueryClient, QueryClientProvider } from 'react-query';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
{/* the application here */}
</QueryClientProvider>
);
}
In addition to this first setup, we can also add our own configuration. By default, it will have its own configurations, for example, when the window is refocused, it will refetch automatically. If it's something you want to keep, you don't need to do anything. But if it has a bad UX for your app, you can overwrite this setup by passing the refetchOnWindowFocus
to the QueryClient
:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
Reading the "Important Defaults" document is really important to understand the default behavior of the library. It'll also help you debug in an easier way.
react-query: simple request
To see a simple request we can make with react-query, let's use the Pokemon's API again.
The react-query's API is super simple:
- query key: this first parameter is used for the data cache and revalidation purposes.
- fetch promise: the request should always be wrapped in a Promise.
const { data } = useQuery(['pokemon', 'pikachu'], async () => {
const response = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu');
return await response.json();
});
The query key is ['pokemon', 'pikachu']
because we can query all types of pokemons. Other variants would be:
['pokemon', 'ditto']
['pokemon', 'blastoise']
All under the namespace pokemon
. But it is up to you to define the query string and make it a convention or pattern in your application.
To reuse this same query in other places to make it more testable, we can extract this code into a custom hook. We'll call it usePokemon
.
function usePokemon(pokemon) {
return useQuery(['pokemon', pokemon], fetchPokemon(pokemon));
}
As we also extract the fetch promise, it becomes very clean. The fetchPokemon
just becomes
function fetchPokemon(pokemon) {
return async () => {
const response = await fetch(
`https://pokeapi.co/api/v2/pokemon/${pokemon}`,
);
return await response.json();
};
}
And now we can reuse this hook in all parts of our application:
const { data } = usePokemon('pikachu');
react-query: request's state
react-query also provides a nice API that represents the request's state:
isLoading
: when the request is still in process. Nice to show a loading spinner or skeleton.isError
: when the request got an error. Nice for error handling like showing a dialog, error content, or snackbar.refetch
: a function to refetch the resource. A nice example is when it gets an error and we show the user the possibility of requesting the resource again.
And so on. For the entire API, take a look at the doc.
Conclusion
In the old days of frontend, it started very simply with little and, most of the time, with no JavaScript at all. Then the websites were getting more and more complex in terms of interactivity, and JavaScript's need was very much required. So much that jQuery was created to make it easier to do DOM manipulation and data fetching using the Ajax technique.
The single page applications' growth exploded and many developers and companies are building frontend applications using a frontend framework like VueJS, AngularJS, or libraries like ReactJS.
Data fetching and server cache are really different than client state and they come with a lot of engineering challenges like caching, update state "out of date", dedup multiple requests, and so on.
To handle all these challenges, react-query was created and it's becoming the go-to option to handle data fetching out of the box in React applications. We also have other competing tools like swr. They are both very well-thought libraries, easy to use, and solve the data fetching problems out of the box.