TK
Home

Data Fetching in React with react-query

10 min read
A planePhoto by Artiom Vallat

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 initializes
  • FETCH_SUCCESS: when the fetch succeed
  • FETCH_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.


Twitter Github