React Query is often described as the missing data-fetching library for React, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your React applications a breeze.

Overview of React Query

React Query is an open-source library designed to streamline data management in React applications. It provides a simple and intuitive way to manage server state, enabling efficient data fetching, caching, synchronization, and updates. By using React Query, you can create responsive, real-time applications with minimal effort.

Key Power

Fetching Data

React Query simplifies data fetching by providing hooks like useQuery. These hooks make it easy to send HTTP requests to your server and retrieve data. To get started, you can create a query like this:

const { data, error, isLoading } = useQuery("userData", fetchUserData)
  • data: Contains the fetched data.
  • error: Holds any potential errors during data retrieval.
  • isLoading: Indicates whether the request is in progress.

Caching Data

React Query incorporates intelligent caching by default. When you fetch data using useQuery, it stores the results in a cache for future use. This means that subsequent requests for the same data are lightning fast, as they’re served from the cache.

Synchronizing Data

Synchronizing data with React Query is effortless. When you modify data on the server, React Query provides tools like useMutation for updating and synchronizing the client’s data automatically. Here’s an example of using useMutation:

const mutation = useMutation(updateUserData, {
  onSettled: () => {
    // Update local state or trigger refetch as needed.
  },
})

Updating Server State

React Query allows you to seamlessly update the server state. When you trigger a mutation with useMutation, it sends a request to the server, and upon success, you can update the local cache. This ensures that your client’s data remains in sync with the server.

In addition to these key points, there are some important practices to keep in mind when working with React Query:

  • Consistency in Key Names: When defining queries and mutations, use descriptive and consistent keys to avoid confusion.
  • Optimistic Updates: Implement optimistic updates to provide a smooth user experience by updating the UI before the server responds.
  • Custom Hooks: Create custom hooks for complex data-fetching logic to keep your components clean and maintainable.
  • Automatic Refetching: Configure queries to automatically refetch at specified intervals to keep data up to date.
  • Error Handling: Implement robust error handling strategies to gracefully handle server and network errors.

Let’s Rock

Effect vs ReactQuery

The Evil behind use effect

Before Going any further let’s take a look at our wonderful friend useEffect

useEffect(() => {
  // Fetch some data from an API
  fetch("https://api.example.com/data")
    .then((response) => response.json())
    .then((fetchedData) => {
      setData(fetchedData) // Update the state with fetched data
    })
}, [])

Now, let's address what's not ideal about this code and how React Query can help:

  1. Data Fetching in Components: In this example, data fetching logic is placed directly inside the component. This can lead to components becoming cluttered and harder to maintain as your application grows.
  2. Lack of Error Handling: There’s no error handling for the API request. If the request fails, the user won’t receive any feedback or see an error message.
  3. No Caching: There’s no caching of fetched data. The data will be refetched on every render of this component, which can be inefficient and lead to a poor user experience.
  4. Missing Loading Indicator: The code doesn’t provide a loading indicator while the data is being fetched, which can leave the user wondering about the state of the request.

The beauty of react query

React Query offers an elegant solution to these issues:

  1. Separation of Concerns: React Query encourages a clear separation of data fetching and component rendering. You define queries and mutations separately from your components, making your components cleaner and more focused on presentation.
  2. Built-in Error Handling: React Query has built-in error handling mechanisms, allowing you to gracefully handle errors and provide user-friendly feedback when an API request fails.
  3. Automatic Caching: React Query handles caching automatically. When you use useQuery, it caches data and provides options for cache management.
  4. Loading State Management: React Query simplifies the management of loading states. You can easily check if data is loading and display loading indicators accordingly.

    Here’s a revised example using React Query:

import React from "react"
import { useQuery } from "react-query"
 
function MyComponent() {
  const { data, isLoading, isError } = useQuery("myData", fetchData)
 
  if (isLoading) {
    return <p>Loading...</p>
  }
 
  if (isError) {
    return <p>Error fetching data.</p>
  }
 
  return (
    <div>
      <h1>My Component</h1>
      {data && <p>Data: {data}</p>}
    </div>
  )
}
 
export default MyComponent
  • Data fetching logic is abstracted into a fetchData function, decoupling it from the component.
  • Loading and error states are handled explicitly, providing better user feedback.
  • Data is automatically cached and updated as needed by React Query.

Fetching Data

React Query does not care of how you fetch your data, weather you are using the Fetch api or Axiox it surely does not give a shit.

For comparisons let’s have a look at codes that uses useEffect and codes that uses react Query

Something we don't want

 const [todos, setTodos] = useState([]);
 useEffect(() => {
 axios.get<Todo[]>(".../todos")
      .then((res) => setTodos(res.data));
      }, []);

Something we want !

interface Todo {
  id: number
  title: string
}
const fetchTodos = () => axios.get<Todo[]>(".../todos").then((res) => res.data)
 
const { data } = useQuery({
  queryKey: ["todos"],
  queryFn: () => fetchTodos(),
})

Now we have access to all the good stuffs,caching,reties

Configuration of react Query

To configure your react query, you simply go where you instantiated it, and provide a configuration object

 
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 3, // if a fetch fail, we should retry 3 more times to confirm !
      cacheTime: 300000, // If no observer for 5Minutes, this will be cached!
      staleTime: 5 * 1000, // How long the data is considered fresh(in seconds),
      ....
    },
  },

Common configurations

React query provides several useful configurations to the tip of your finger !

Patten to write your configuration objects

Config while instantiating our queryObject

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
    // You write your configurations here!
      configName: Config,
      ....
    },
  },

Set config per query

useQuery<Todo[], Error>({
    queryKey: ["todos"],
    queryFn: () => fetchTodos(),
    staleTime: 10 * 1000,
	//.... Other configurations can be used here!
  });

retry

Retry is simply the number of retries if a fetching was not successful. maybe at the exact time you were querying things your network went off or few sec. then react query will retry for you.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 3, // if a fetch fail, we should retry 3 more times to confirm !
      ....
    },
  },

cacheTime

const queryClient = new QueryClient({
defaultOptions: {
queries: {
  cacheTime: 300_000, // If no observer for 5Minutes, this will be garbage collected!
  ....
},
},

Stale time

This simply describes for how long data is considered to be fresh !

staleTime: 5 * 1000, // How long the data is considered fresh(in seconds)

Auto Refresh of stale data.

React Query automatically refresh stale data under 3 conditions.

When the network is reconnected

When a component is mounted

When the window is refocused

If we don't want this behavior we can always change it!

refetchOnMount: true,
refetchOnReconnect:true,
refetchOnWindowFocus:true,

queryKey

The queryKey is a unique identifier for the query. It can be a string or an array of strings and objects. When the queryKey changes, a new query is created.

import { useQuery } from "react-query"
 
const fetchUser = async (id: string) => {
  const response = await fetch(`/api/user/${id}`)
  return response.json()
}
 
const Component = () => {
  const { data, isLoading } = useQuery(["user", { id: "1" }], () => fetchUser("1"))
 
  if (isLoading) {
    return <div>Loading...</div>
  }
 
  return <div>{data.name}</div>
}

queryFn

The queryFn is a function that returns a promise. When the promise resolves, its result will be saved and returned from the useQuery hook.

import { useQuery } from 'react-query'
 
const fetchUser = async (id: string) => {
  const response = await fetch(`/api/user/${id}`)
  return response.json()
}
 
const Component = () => {
  const { data, isLoading } = useQuery({'user', () => fetchUser('1')})
 
  if (isLoading) {
    return <div>Loading...</div>
  }
 
  return <div>{data.name}</div>
}

staleTime

The staleTime option determines the time in milliseconds that the cached data remains fresh. After this time, if the query is rendered again, React Query will automatically refetch the data in the background.

import { useQuery } from "react-query"
 
const fetchUser = async (id: string) => {
  const response = await fetch(`/api/user/${id}`)
  return response.json()
}
 
const Component = () => {
  const { data, isLoading } = useQuery("user", () => fetchUser("1"), {
    staleTime: 1000 * 60 * 5, // 5 minutes
  })
 
  if (isLoading) {
    return <div>Loading...</div>
  }
 
  return <div>{data.name}</div>
}

cacheTime

The cacheTime option specifies the time in milliseconds that unused/inactive cache data remains in memory. After this time, the unused cache data will be garbage collected.

import { useQuery } from "react-query"
 
const fetchUser = async (id: string) => {
  const response = await fetch(`/api/user/${id}`)
  return response.json()
}
 
const Component = () => {
  const { data, isLoading } = useQuery("user", () => fetchUser("1"), {
    cacheTime: 1000 * 60 * 30, // 30 minutes
  })
 
  if (isLoading) {
    return <div>Loading...</div>
  }
 
  return <div>{data.name}</div>
}

retry

The retry option determines the number of times to retry the query in case of failure. By default, it’s set to 3.

import { useQuery } from "react-query"
 
const fetchUser = async (id: string) => {
  const response = await fetch(`/api/user/${id}`)
  return response.json()
}
 
const Component = () => {
  const { data, isLoading, isError } = useQuery("user", () => fetchUser("1"), {
    retry: 2,
  })
 
  if (isLoading) {
    return <div>Loading...</div>
  }
 
  if (isError) {
    return <div>Failed to load data</div>
  }
 
  return <div>{data.name}</div>
}

Remember, React Query provides a lot more configuration options that you can use based on your needs. Refer to the official React Query documentation for more details.

Use Query Hook (useQuery)

Ways to Configure useQuery Hook in React Query

React Query offers multiple ways to configure the useQuery hook. Here is a comprehensive list of them:

1. Using separate arguments

The most common way to configure useQuery is by passing the query key, query function, and an options object as separate arguments.

useQuery(queryKey, queryFn, options)

2. Using configuration object

Alternatively, you can pass all configuration options including the query key and query function as an object.

useQuery({
  queryKey,
  queryFn,
  ...options,
})

3. Using an inline function for queryFn

If your query function needs to access variables from the component’s scope, you can define it inline.

useQuery(queryKey, () => fetch(`/api/data/${variable}`), options)

4. Using an array for queryKey

If you need to pass variables to your query function, you can include them in the query key as an array.

useQuery([queryKey, variable], queryFn, options)

In this case, your query function will receive an object as argument, and you can extract the variables from the queryKey property.

const queryFn = async ({ queryKey }) => {
  const [_key, variable] = queryKey
  // ...
}

5. Using default options with QueryClient

If you want to set default options for all queries, you can use the defaultOptions.queries property when creating the QueryClient.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      cacheTime: 1000 * 60 * 30,
      retry: 2,
    },
  },
})

6. Using useQueries for multiple queries

If you need to run multiple queries at once, you can use the useQueries hook. It accepts an array of objects, where each object is a configuration for a query.

useQueries([
  {
    queryKey: ["data", variable1],
    queryFn: fetchFunction1,
    ...options1,
  },
  {
    queryKey: ["data", variable2],
    queryFn: fetchFunction2,
    ...options2,
  },
])

Remember, the way you configure your queries depends on your specific use case and personal preferences. You can choose the method that best suits your needs and makes your code clean and maintainable Source 1.

User Query

Using the use query hook, you can get along using the most minimal settings, you would do something like this

Your custom hook

const fetchTodos = () => axios.get<Todo[]>(".../todos").then((res) => res.data)
 
export function useTodos() {
  return useQuery<Todo[], Error>({
    queryKey: CACHE_KEY_TODOS,
 
    queryFn: fetchTodos,
 
    staleTime: 5 * 1000,
  })
}

From your component

 
 
export default function Todos() {
 
  const { data, error, isLoading } = useTodos<Todo[], Error>();
// Data is simply the data we received from our backend
// isLoading is there to specify the loading status of our fetch
 // Error is there to track the error that might happen when fetching data
 
/*
Typing our Hook useTodos<Todo[],Error>
When Typing our hook, there are few generics we can use to be explicit about the data we are receiving
	- ✅ Todo[] : the types of data we are receiving
	- ✅ Error The type of error we are gettin
*/
 
  if (isLoading) return <p className="p-10 animate-pulse">Loading...</p>;
 
  if (error) return <p>{error.message}</p>;
 
  return (
 
    <div>
 
      <h1 className="text-lg font-bold"> welcome Query</h1>
 
      <ul>
 
        {data?.map((todo) => (
 
          <li key={todo.id}>
 
            {todo.title} @{todo.time}
 
            <input type="checkbox" />
 
          </li>
 
        ))}
 
      </ul>
 
    </div>
 
  );
 
}

Pagination with React Query

Pagination is a common technique used to break large datasets into smaller, manageable chunks, improving both performance and user experience. With React Query, implementing pagination is straightforward. In this guide, we will use the useQuery hook with its configuration provided as an object to fetch paginated data.

Basic Setup

Firstly, we need to initialize a new instance of React Query by importing QueryClient and QueryClientProvider from React Query. Then, we wrap the app with QueryClientProvider Source 0.

import { QueryClient, QueryClientProvider } from "react-query"
 
const queryClient = new QueryClient()
 
ReactDOM.render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>,
  document.getElementById("root"),
)

Fetching Paginated Data

The useQuery hook will be used to fetch the paginated data. The page number will be included as part of the queryKey Source 4.

import { useQuery } from "react-query"
 
const fetchUsers = async ({ queryKey }) => {
  const [_key, page] = queryKey
  const response = await fetch(`https://api.example.com/users?page=${page}`)
  return response.json()
}
 
const UsersList = ({ page }) => {
  const { data, isLoading, isError } = useQuery({
    queryKey: ["users", page],
    queryFn: fetchUsers,
    keepPreviousData: true, // keep previous data while fetching new data
  })
 
  if (isLoading) {
    return <p>Loading...</p>
  }
 
  if (isError) {
    return <p>Error fetching users.</p>
  }
 
  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

In this example, fetchUsers is our function to fetch the paginated data. It receives an object as an argument, and we can extract the page number from the queryKey property. The keepPreviousData option is set to true, which means that the previous page’s data will be kept until the next page’s data is fetched. This prevents the UI from “jumping” between pages.

Implementing Pagination Controls

To navigate between pages, we can add Next and Previous buttons and manage the current page with a state variable.

import { useState } from "react"
 
const App = () => {
  const [page, setPage] = useState(1)
 
  return (
    <div>
      <UsersList page={page} />
 
      <button onClick={() => setPage((old) => Math.max(old - 1, 1))} disabled={page === 1}>
        Previous Page
      </button>
 
      <button onClick={() => setPage((old) => old + 1)}>Next Page</button>
    </div>
  )
}

In this example, the page state variable is managed with the useState hook and passed to the UsersList component. The Previous Page button decreases the page state variable by 1, but it cannot go below 1. The Next Page button increases the page state variable by 1. The Previous Page button is disabled when page is 1, meaning we’re on the first page.

This guide provides a basic implementation of pagination with React Query. Depending on your API and requirements, you might need to adjust this implementation. For example, if your API includes information about the total number of pages, you might want to disable the Next Page button when the user is on the last page. Also, remember that the keepPreviousData option only keeps the data from the previous page, not all previous pages. If you need to keep all pages’ data, you might want to consider using the useInfiniteQuery hook instead Source 4.

Mutations with React Query

Mutations in React Query are used for making any kind of server state change including POST, PUT, DELETE requests, etc. Unlike queries, mutations are not cached and do not automatically re-run on window focus or network reconnection. Mutations are performed using the useMutation hook

Creating a mutation hook !

const addUser = async (newUser) => {
  const response = await fetch("/api/users", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(newUser),
  })
 
  if (!response.ok) {
    throw new Error("Network response was not ok")
  }
 
  return response.json()
}

Basic Usage

We can use this mutation function in our component using the useMutation hook.

import { useMutation, useQueryClient } from "react-query"
 
const Component = () => {
  const queryClient = useQueryClient()
  const mutation = useMutation(addUser, {
    onSuccess: () => {
      queryClient.invalidateQueries("users")
    },
  })
 
  const onSubmit = (data) => {
    mutation.mutate(data)
  }
  // ...
}

In this example, useMutation receives the addUser function as the first argument. The second argument is an options object where we can specify callbacks like onSuccess, onError, and onSettled that are executed based on the mutation’s state.

In the onSuccess callback, we’re calling queryClient.invalidateQueries('users') to refetch the users after a new user is added. This ensures that our UI is updated with the latest data after the mutation.

Error and Loading States

Like useQuery, useMutation returns an object that includes properties for tracking the mutation’s error and loading states.

const Component = () => {
  const queryClient = useQueryClient()
  const mutation = useMutation(addUser, {
    onSuccess: () => {
      queryClient.invalidateQueries("users")
    },
  })
 
  if (mutation.isLoading) {
    return "Adding user..."
  }
 
  if (mutation.isError) {
    return `Error: ${mutation.error.message}`
  }
 
  const onSubmit = (data) => {
    mutation.mutate(data)
  }
 
  // ...
}

In this example, mutation.isLoading is true while the mutation is being performed, and mutation.isError is true if the mutation fails. mutation.error contains the error thrown by the mutation function.

Optimistic Updates

Optimistic updates can be used to update the UI immediately before the mutation is completed. This can make your application feel faster and more responsive

const Component = () => {
  const queryClient = useQueryClient()
  const mutation = useMutation(addUser, {
    onMutate: (newUser) => {
      queryClient.cancelQueries("users")
 
      const previousUsers = queryClient.getQueryData("users")
 
      queryClient.setQueryData("users", (old) => [...old, newUser])
 
      return { previousUsers }
    },
    onError: (err, newUser, context) => {
      queryClient.setQueryData("users", context.previousUsers)
    },
    onSettled: () => {
      queryClient.invalidateQueries("users")
    },
  })
 
  const onSubmit = (data) => {
    mutation.mutate(data)
  }
 
  // ...
}

In this example, onMutate is called immediately when the mutate method is called. We’re using it to update the cache with the new user before the mutation is performed. If the mutation fails, we rollback the update in the onError callback using the context returned from onMutate. Finally, we refetch the users in the onSettled callback, which is called whether the mutation succeeds or fails.

Remember, mutations are more complex than queries because they can change data on the server, so it’s important to handle all possible states to provide a good user experience