Supercharge Your React App with ‘react-query’: A Beginner’s Guide

In the world of React development, fetching, caching, and updating data are fundamental tasks. However, managing these operations can quickly become complex, leading to boilerplate code, performance bottlenecks, and a less-than-ideal user experience. Imagine having to manually handle loading states, error handling, and data synchronization every time you need to fetch data from an API. The good news is that there’s a powerful and elegant solution: react-query. This guide will walk you through everything you need to know to leverage react-query and streamline your data fetching in React applications.

What is React Query?

react-query is a powerful library for managing server state in your React applications. It provides a set of hooks and utilities to simplify data fetching, caching, and updating. Think of it as a state management library specifically designed for data coming from external sources (APIs, databases, etc.). It abstracts away the complexities of dealing with asynchronous operations, allowing you to focus on building great user interfaces.

Why Use React Query?

Here’s why react-query is a game-changer for React developers:

  • Simplified Data Fetching: Reduces boilerplate code and makes data fetching cleaner and more readable.
  • Automatic Caching: Caches data automatically, improving performance and reducing API calls.
  • Optimistic Updates: Allows for faster UI updates by optimistically updating the UI before the server responds.
  • Background Updates: Automatically refetches data in the background to keep your UI up-to-date.
  • Error Handling: Provides built-in error handling and retry mechanisms.
  • Devtools: Offers a developer tools panel for easy debugging and monitoring.

Setting Up React Query

Let’s get started by installing react-query in your React project. Open your terminal and run the following command:

npm install react-query

Once installed, you’ll need to wrap your application with the QueryClientProvider. This provider makes the react-query context available to all your components. Here’s how to do it in your main application file (e.g., src/index.js or src/App.js):

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

// Create a client
const queryClient = new QueryClient()

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  
    
      
    
  
);

Basic Data Fetching with useQuery

The core of react-query is the useQuery hook. This hook handles fetching data, caching, and providing loading, error, and data states. Let’s create a simple component that fetches data from an API.

First, let’s create a sample API endpoint to fetch some data. For this example, we’ll use a placeholder API: https://jsonplaceholder.typicode.com/todos/1. This API returns a single todo item. You can replace this with your own API endpoint.

Here’s the component:

import React from 'react';
import { useQuery } from 'react-query';

async function fetchTodo() {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
}

function Todo() {
  const { data, isLoading, error } = useQuery('todo', fetchTodo);

  if (isLoading) {
    return <span>Loading...</span>;
  }

  if (error) {
    return <span>Error: {error.message}</span>;
  }

  return (
    <div>
      <h2>Todo Item</h2>
      <p><b>Title:</b> {data.title}</p>
      <p><b>Completed:</b> {data.completed ? 'Yes' : 'No'}</p>
    </div>
  );
}

export default Todo;

Let’s break down this code:

  • useQuery('todo', fetchTodo): This is the heart of the component.
    • The first argument, 'todo', is a unique key for this query. This key is used for caching, invalidation, and debugging.
    • The second argument, fetchTodo, is an asynchronous function that fetches your data.
  • isLoading: A boolean that indicates whether the query is currently loading.
  • error: Contains any error that occurred during the fetch.
  • data: Contains the fetched data when the query is successful.

To use this component, import it into your App.js (or your main application component) and render it:

import React from 'react';
import Todo from './Todo';

function App() {
  return (
    <div>
      <h1>React Query Example</h1>
      
    </div>
  );
}

export default App;

Understanding Query Keys

Query keys are essential for react-query. They act as unique identifiers for your queries and are used for caching, invalidation, and debugging. They can be strings or arrays. Using arrays allows for more complex key structures.

Simple Keys (Strings): As shown in the previous example, a simple string key is sufficient for a basic query. This is fine when you only need to fetch data once. For example: useQuery('todos', fetchTodos).

Complex Keys (Arrays): When you have queries that depend on parameters (e.g., fetching a specific user by ID), you should use an array as the key. The first element of the array is often a descriptive string, and subsequent elements are the parameters. This allows react-query to cache based on the parameters.

function useUser(userId) {
  return useQuery(['user', userId], () => fetchUser(userId));
}

In this example, ['user', userId] is the query key. react-query will cache data based on the userId. When the userId changes, react-query automatically refetches the data.

Caching and Data Freshness

react-query provides intelligent caching out-of-the-box. By default, data is cached for a certain amount of time. You can configure this behavior to suit your needs.

Stale Time: The time after which the data is considered “stale.” When data is stale, react-query will automatically refetch it in the background if the data is requested. The default is 0 seconds, meaning data is considered stale immediately.

Cache Time: The time after which the data is garbage collected (removed from the cache) if it’s not being used. The default is 5 minutes.

You can configure these options when you create your QueryClient:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60000, // Data is considered stale after 60 seconds
      cacheTime: 300000, // Data is garbage collected after 5 minutes
    },
  },
});

This configuration sets the default staleTime to 60 seconds. Any query created after this configuration will use these defaults, unless overridden in the useQuery hook.

You can also override these defaults on a per-query basis:

const { data } = useQuery('todos', fetchTodos, {
  staleTime: 10000, // Override the default to 10 seconds
});

Mutations: Updating Data

react-query isn’t just for fetching data; it also simplifies updating data on the server with the useMutation hook. This hook handles sending data to your API (e.g., POST, PUT, DELETE requests) and managing the loading and error states.

Let’s create an example of submitting a form to create a new todo item.

import React, { useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';

async function createTodo(newTodo) {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(newTodo),
  });
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
}

function TodoForm() {
  const [title, setTitle] = useState('');
  const queryClient = useQueryClient();

  const mutation = useMutation(createTodo, {
    onSuccess: (newTodo) => {
      // Invalidate the 'todos' query to refetch the updated list
      queryClient.invalidateQueries('todos');
      // Optionally, you can also update the cache directly
      // queryClient.setQueryData('todos', (oldData) => [...oldData, newTodo]);
    },
  });

  const handleSubmit = (event) => {
    event.preventDefault();
    mutation.mutate({ title, completed: false });
    setTitle(''); // Clear the input field after submission
  };

  return (
    <div>
      <h2>Create Todo</h2>
      {mutation.isLoading && <span>Saving...</span>}
      {mutation.isError && <span>Error: {mutation.error.message}</span>}
      {mutation.isSuccess && <span>Todo created!</span>}
      
         setTitle(e.target.value)}
          placeholder="Enter todo title"
        />
        <button type="submit" disabled="{mutation.isLoading}">
          Add Todo
        </button>
      
    </div>
  );
}

export default TodoForm;

In this example:

  • createTodo: This is an asynchronous function that sends a POST request to create a new todo item.
  • useMutation(createTodo, { ... }): This hook handles the mutation.
  • onSuccess: This callback is executed when the mutation is successful. Crucially, it invalidates the ‘todos’ query to trigger a refetch of the todo list, ensuring the UI reflects the updated data.
  • queryClient.invalidateQueries('todos'): This invalidates any queries with the key ‘todos’. When the component using useQuery('todos', fetchTodos) is rendered, it will refetch the data.
  • mutation.mutate({ title, completed: false }): This triggers the mutation, passing the new todo data to the createTodo function.

Optimistic Updates

Optimistic updates provide a smoother user experience by immediately updating the UI with the changes before the server responds. This makes the application feel faster and more responsive. react-query makes implementing optimistic updates relatively straightforward.

Let’s modify the previous example to include optimistic updates. We will assume we also have a component that displays a list of todos fetched using useQuery('todos', fetchTodos). We will focus on the mutation part.

import React, { useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';

async function createTodo(newTodo) {
  // Simulate a delay (e.g., API call)
  await new Promise(resolve => setTimeout(resolve, 1000));

  const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(newTodo),
  });
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
}

function TodoForm() {
  const [title, setTitle] = useState('');
  const queryClient = useQueryClient();

  const mutation = useMutation(createTodo, {
    // Optimistic Update
    onMutate: async (newTodo) => {
      // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
      await queryClient.cancelQueries('todos');

      // Snapshot the previous value
      const previousTodos = queryClient.getQueryData('todos');

      // Optimistically update to the new value
      queryClient.setQueryData('todos', (old) => ({
        ...old,
        todos: [...old.todos, { ...newTodo, id: Math.random() }], // Add a temporary ID
      }));

      // Return a context object with the previous value
      return { previousTodos };
    },
    // If the mutation fails, use the context returned from onMutate to roll back
    onError: (err, newTodo, context) => {
      queryClient.setQueryData('todos', context.previousTodos);
    },
    // Always refetch after error or success to refetch the real data
    onSettled: () => {
      queryClient.invalidateQueries('todos');
    },
  });

  const handleSubmit = (event) => {
    event.preventDefault();
    mutation.mutate({ title, completed: false });
    setTitle(''); // Clear the input field after submission
  };

  return (
    <div>
      <h2>Create Todo</h2>
      {mutation.isLoading && <span>Saving...</span>}
      {mutation.isError && <span>Error: {mutation.error.message}</span>}
      {mutation.isSuccess && <span>Todo created!</span>}
      
         setTitle(e.target.value)}
          placeholder="Enter todo title"
        />
        <button type="submit" disabled="{mutation.isLoading}">
          Add Todo
        </button>
      
    </div>
  );
}

export default TodoForm;

Here’s what changed:

  • onMutate: This function is called immediately before the mutation is executed.
    • queryClient.cancelQueries('todos'): Cancels any pending refetches for the ‘todos’ query. This prevents the UI from briefly displaying the old data if a background refetch is in progress.
    • queryClient.getQueryData('todos'): Gets the current data from the cache (the previous todo list).
    • queryClient.setQueryData('todos', (old) => ({...old, todos: [...old.todos, { ...newTodo, id: Math.random() }] })): Optimistically updates the cache with the new todo item. A temporary ID is assigned to the new todo item because the real ID will be assigned by the server.
    • return { previousTodos }: Returns a context object containing the previous todo list. This context is passed to the onError function.
  • onError: This function is called if the mutation fails.
    • queryClient.setQueryData('todos', context.previousTodos): Restores the previous todo list from the context, effectively rolling back the optimistic update.
  • onSettled: This function is called regardless of whether the mutation succeeds or fails.
    • queryClient.invalidateQueries('todos'): Invalidates the ‘todos’ query to refetch the data from the server. This ensures that the UI is eventually synchronized with the server state.

This approach provides a smooth user experience. The UI updates instantly, and if the server request fails, the UI reverts to the previous state. If the server request succeeds, the UI updates with the actual data from the server.

Advanced Features

react-query offers many other advanced features:

  • Pagination: Easily handle paginated data by using the useInfiniteQuery hook.
  • Refetching: Manually refetch data with the refetch function returned by useQuery.
  • Polling: Automatically refetch data at regular intervals.
  • Dependent Queries: Fetch data based on the results of other queries.
  • Customization: Configure default query and mutation options globally.
  • Devtools: The react-query devtools provides a user interface for debugging and monitoring your queries.

Common Mistakes and How to Fix Them

Here are some common mistakes and how to avoid them when using react-query:

  1. Not Using Query Keys Correctly: Failing to use unique and appropriate query keys can lead to caching issues and incorrect data. Make sure your keys accurately reflect the data being fetched and any parameters involved.
  2. Forgetting to Invalidate Queries After Mutations: After performing a mutation (e.g., creating, updating, or deleting data), you must invalidate the relevant queries to ensure the UI reflects the updated data.
  3. Misunderstanding Stale Time and Cache Time: Confusing these settings can lead to unexpected data refetching or data being prematurely removed from the cache. Understand how these options affect data freshness and caching behavior.
  4. Over-Fetching Data: Fetching more data than you need. Use query parameters to request only the necessary data from your API.
  5. Ignoring Error Handling: Always handle errors gracefully. Provide informative error messages to the user and consider retry mechanisms.

Key Takeaways

  • react-query simplifies data fetching, caching, and updating in React applications.
  • Use the useQuery hook for fetching data and the useMutation hook for updating data.
  • Query keys are crucial for caching, invalidation, and debugging.
  • Leverage optimistic updates for a smoother user experience.
  • Configure caching options (staleTime, cacheTime) to optimize performance.
  • Always handle errors and invalidate queries after mutations.

FAQ

  1. What are the benefits of using react-query over using useState and useEffect for data fetching?
    react-query handles caching, automatic background updates, error handling, and loading states out of the box, reducing boilerplate code and improving performance. It also provides developer tools for easy debugging and monitoring.
  2. How do I refetch data manually with react-query?
    The useQuery hook returns a refetch function. You can call this function to manually refetch the data. For example: const { data, refetch } = useQuery('todos', fetchTodos); and then call refetch().
  3. How do I handle pagination with react-query?
    Use the useInfiniteQuery hook. This hook simplifies fetching data in chunks, such as when dealing with paginated API responses.
  4. How do I clear the cache in react-query?
    You can use the queryClient.clear() method to clear the entire cache or queryClient.removeQueries('queryKey') to remove specific queries.
  5. Can I use react-query with any API?
    Yes, react-query is designed to work with any API. You just need to provide a function that fetches the data from the API and returns the response.

By incorporating react-query into your React projects, you can significantly improve the efficiency and maintainability of your data fetching logic. The library’s powerful features, combined with its ease of use, make it an excellent choice for managing server state and building responsive, performant React applications.