Simplifying Data Fetching in React: A Guide to React Query

Yadvir KaurYadvir Kaur
10 min read

In this blog post, we'll explore how React Query simplifies data fetching in React applications. We'll examine common problems encountered when fetching data without React Query and then dive into how React Query addresses these issues. Along the way, we'll cover the setup process, custom hook creation, configuration options, parameterized queries, and conclude with the benefits of using React Query.

Common Problems with Traditional Data Fetching

Consider a typical scenario where we fetch data from an API using Axios in a React component. Below is an example of how we might implement this without React Query:

import React, { useEffect, useState } from 'react';
import axios from 'axios';

interface Todo {
  id: number;
  title: string;
}

const TodoList = () => {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [error, setError] = useState('');

  useEffect(() => {
    axios
      .get('https://jsonplaceholder.typicode.com/todos')
      .then((res) => setTodos(res.data))
      .catch((error) => setError(error));
  }, []);

  if (error) return <p>{error}</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
};

export default TodoList;

While this approach works, it comes with several limitations, including:

  1. No Separation of Concerns.

  2. Absence of retrying failed requests.

  3. Lack of automatic refresh to reflect real-time data changes.

  4. Absence of Caching Mechanism.

Now we can address all these limitations by writing more and more code, but there is a lot of extra code that we have to maintain. This is where the React Query comes into play.

Introducing React Query

React Query is a library that simplifies fetching, caching, and updating data in React applications. It uses sensible defaults to keep data fresh and show it quickly to users, making things feel super fast and giving a great user experience. Plus, you can customize it to fit your needs better when the defaults aren't enough.

Benefits of Using React Query:

  1. Automatic Retries: React Query automatically retries server calls if they fail, with the option to configure the retry behaviour.

  2. Automatic Refresh: Queries can be configured to refresh automatically after a specified period, ensuring data remains up-to-date.

  3. Caching: Data fetched for the first time is stored in the cache, remaining fresh for a set duration. Subsequent requests for the same data can be served directly from the cache, enhancing application performance significantly.

Let's see how we can refactor the above example to use React Query.

Step 1: Integration in Main Component

First of all install the following dependencies:

>npm i @tanstack/react-query
>npm i @tanstack/react-query-devtools

To integrate React Query into our main component, we start by importing the necessary dependencies QueryClient and QueryClientProvider. Now create a new instance of QueryClient, which is the core object for managing and caching remote data in React Query. Pass this instance to the component tree using QueryClientProvider. So we wrap the app component with QueryClientProvider and set the client prop to the QueryClient instance.

import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';

const queryclient = new QueryClient();

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <QueryClientProvider client={queryclient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>
);

Step 2: Fetching Data with React Query

Now, let's work on our TodoList component. To fetch data using React Query, we utilize the query hook called useQuery from the Tanstack React Query library. We import this hook, invoke it, and provide a configuration object with two properties.

Creating a query object involves defining aqueryKeyto uniquely identify the query and aqueryFnto fetch the data. Query objects have three main properties: data, error, and isLoading.

  • ThequeryKeyserves as a unique identifier for the query. It's used internally for caching purposes. It typically consists of an array of values, with the first value representing the type of data being stored (e.g., 'todos'). We stick to a single value to keep things simple.

  • ThequeryFnis a function responsible for fetching data from the backend. It should return a promise that resolves to the data or throws an error. In our example, we use axios to fetch data, but other HTTP libraries like fetch API can also be used. Now we don't want to store the response object in the cache, we want to store the actual data. So we ensure to extract and return the actual data from the response object using the .then method after the axios GET request.

Additionally, we can organize this logic outside of our query by defining a separate function, such as 'fetchtodos', and referencing it within the query. React Query will call this function at runtime, storing the resulting array of todos in the cache against the specified key.

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

interface Todo {
  id: number;
  title: string;
}

const TodoList = () => {
  const fetchTodos = () =>
    axios
      .get<Todo[]>('https://jsonplaceholder.typicode.com/todos')
      .then((res) => res.data);

  const { data: todos, error, isLoading } = useQuery<Todo[], Error>({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  if (isLoading) return <p>Loading...</p>
  if (error) return <p>{error.message}</p>;

  return (
    <ul className="list-group">
      {todos?.map((todo) => (
        <li key={todo.id} className="list-group-item">
          {todo.title}
        </li>
      ))}
    </ul>
  );
};

export default TodoList;

React Query provides a "query" object from the query hook, which includesdata,error, andisLoadingproperties.

Points to note here:

  • Optional chaining: You might get a compilation error indicating that "data" or "todos" might be undefined. This occurs because fetching data from the backend might result in an error, leaving "data" or "todos" undefined. To address this, we can use optional chaining:

      {todos?.map((todo) => ( ... ))}
    
  • error.message: Since React Query doesn't predict the specific type of errors that could occur (depending on the HTTP library used), we need to specify error types when calling the query hook.

      useQuery<Todo[], Error>()
    

    When calling the query hook, we specify generic type parameters. The first parameter, "TQueryFnData," represents the expected data type from the backend (in this case, todo[]). The second parameter, "TError," defaults to "unknown" but should be set to "Error" to align with axios's error interface. Now We can handle the compilation error by rendering the {error.message}.

With React Query, we no longer need to declare separate state variables for data, error, and loading state—everything is managed seamlessly.

Creating a Custom Hook

Now to address the issue of querying logic being embedded within components, promoting reusability and maintainability, we'll create a custom hook for fetching todos using React Query.

To implement this, let's create a new folder called "hooks" in our project. Inside this folder, add a file named "useTodos.ts". Within this file, define a function called "useTodos" and export it as the default object. Move the logic and the corresponding interface into this hook file.

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

interface Todo {
  id: number;
  title: string;
}
// const useTodos = () => {
//   const fetchTodos = () =>
//     axios
//       .get<Todo[]>('https://jsonplaceholder.typicode.com/todos')
//       .then((res) => res.data);

//       return useQuery<Todo[], Error>({
//         queryKey: ['todos'],
//         queryFn: fetchTodos,
//       });
// };
//OR you can write it like:

const useTodos = () =>
  useQuery<Todo[], Error>({
    queryKey: ['todos'],
    queryFn: () =>
      axios
        .get<Todo[]>('https://jsonplaceholder.typicode.com/todos')
        .then((res) => res.data),
  });

export default useTodos;

Now, in our TodoList component, we no longer need the state and effect hooks. Instead, call the "useTodos" hook to retrieve the query object, then destructure the data, error, and isLoading properties.

import useTodos from './hooks/useTodos';

const TodoList = () => {
  const { data: todos, error, isLoading } = useTodos();
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>{error.message}</p>;

  return (
    <ul>
      {todos?.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
};

export default TodoList;

React Query devtools

React Query comes with its own devtool, which is a powerful tool for debugging and monitoring queries. To integrate the React Query DevTools into our project, we need to import a component called "ReactQueryDevtools" at the top of our main.tsx file. Then, we add this component to our component tree after the app component. This inclusion is specific to the development build, the React Query DevTools won't be included in the production build.

In the browser, we'll see an icon in the corner for toggling the DevTools. Clicking on it reveals the content of our cache, including query details such as the key, number of observers, and last update. We can perform various actions like refreshing or invalidating queries and accessing loading or error states. Furthermore, we can explore the properties of our queries in the Query Explorer window.

Global and Local Query Configuration

React Query offers options to customize query behavior, including caching and retry settings, both globally and on a per-query basis

  • For global configuration go to main.tsx, we can configure global settings by providing a configuration object to the QueryClient() . This allows us to override default query settings for all queries.

  • For local configuration within a custom hook, such as useTodos, we can override settings directly within the hook, like setting a different staleTime:

// Global Configuration
const queryclient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 3,
      gcTime: 300_000,
      staleTime: 30000,
      refetchInterval: 60000,
      refetchOnWindowFocus: false,
      refetchOnReconnect: false,
      refetchOnMount: false,
    },
  },
});

// Local Configuration (within custom hook)
const useTodos = () =>
  useQuery<Todo[], Error>({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    staleTime: 60 * 1000, //Override staleTime for this query
  });

Key settings to note:

  • retry: Specifies the number of retries if a query fails (default is 3).

  • gcTime: Does nothing as long as a query is still in use. It only kicks in as soon as the query becomes inactive. Determines how long inactive queries remain in cache (default is 300,000 milliseconds or 5 minutes). Queries become inactive when there are no observers or all components using them have unmounted. Inactive queries are removed from the cache after this period, a process known as garbage collection. gcTime was previously known as cacheTime, but it got renamed in v5 to better reflect what it's doing.

  • staleTime: Specifies the duration data is considered fresh (default is 0, meaning data is treated as old immediately). While fresh, data is read from the cache without network requests. When the data exceeds thestaleTime, it is marked as stale, and subsequent requests for the same data will trigger a refetch.

    • Example: If you set staleTime: 30000, the data fetched by the query will be considered fresh for 30 seconds. After 30 seconds, if a new request is made for the same data, it will trigger a refetch.
  • refetchInterval: Specifies the interval at which a query should be automatically refetched. It triggers a new request for data at regular intervals, regardless of whether the existing data has become stale or not. This option is useful when you want to ensure that the data is regularly updated.

    • Example: If you set refetchInterval: 60000, the query will be automatically refetched every 60 seconds.
  • React query automatically refreshes stale data under three situations:

    • when the network is reconnected

    • when a component is mounted

    • when the window is refocused

You can configure refresh behaviour with settings like: refreshOnWindowFocus, refreshOnReconnect and refreshOnMount, tailoring it to your app's needs.

While default settings are often sufficient, adjusting staleTime per query may be necessary for less frequently updated data.

Parameterized Queries

Parameterized Queries in React Query allow us to fetch data based on specific criteria, such as filtering todos by user ID. By adding the user ID as a parameter to the query hook, we can dynamically fetch todos for different users. To structure the query key hierarchically, we start with the top-level object users followed by the user ID and then todos, similar to API URL design. React Query automatically triggers a refetch when the query key changes, making it easy to fetch data whenever the userId changes. So this is very similar to the dependency array of the effect hook.

Finally, passing parameters to the backend for filtering can be achieved by setting query string parameters, such as the userId, using the params object.

TodoList Component:

const TodoList = () => {
  const { data, error, isLoading } = useTodos(userId);
  //rest of the code
};

export default TodoList;

useTodos hook:

//rest of the code
const useTodos = (userId: number | undefined) =>
  useQuery<Todo[], Error>({
    queryKey: ['users', userId, 'todos'],
    queryFn: () =>
      axios
        .get('https://jsonplaceholder.typicode.com/todos', {
          params: { userId },
        })
        .then((res) => res.data),
    //rest of the code
  });

export default useTodos;

Conclusion

By leveraging React Query, we streamline data fetching in React applications. React Query simplifies the process, promotes separation of concerns, enhances error handling, and provides features like automatic retries, caching, and parameterized queries. Its flexible configuration options allow us to customize behaviour according to our application's needs, making it a powerful tool for managing data in React applications.

0
Subscribe to my newsletter

Read articles from Yadvir Kaur directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yadvir Kaur
Yadvir Kaur