Tanstack Query 🏝️ / React Query 🌸

Intro

This article is more like a note or cheatsheet. I hope this approach is not going to be confusing for you if you are already familiar with redux or such libraries before.

Most of it is actually noted from the Code Genix’s Tutorial.

export function useTodosIds() {
  return useQuery({
    queryKey: ["todos"],
    queryFn: getTodosIds,
  })
}

The useQuery part is the most basic of tanstack-query.

Here. queryFn is the query function. Here getTodosIds is the function that mainly makes the API call. Here is its definition:

import axios from "axios"
import { Todo } from "../types/todo"

const BASE_URL = "http://localhost:8080"
const axiosInstance = axios.create({
  baseURL: BASE_URL,
})

export const getTodosIds = async () => {
  return (await axiosInstance.get<Todo[]>("todos")).data.map((todo) => todo.id)
}

There are other options in the useQuery object as well. Some of them are refetchOnWindowFocus and enabled:

export function useTodosIds(something: boolean) {
  return useQuery({
    queryKey: ["todos"],
    queryFn: getTodosIds,
    refetchOnWindowFocus: false,
    enabled: something, // place a boolean value.
  })
}

if refetchOnWindowFocus is true, it will make an api call whenever you get back to the app window.

if enabled is true, this query is going to work. Otherwise, it won’t.

By the way, the default value of refetchOnWindowFocus and enabled are always true.

Setup

Wrap your main ui or the App.tsx file with queryClientProvider and add the query client to it.

In the query-client object, there are many options. I incorporated 2 of them here:

import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "./index.css"
import App from "./App.tsx"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 5,
      retryDelay: 1000,
    },
  },
})

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>
)

Here, you can add Devtools as well (Tanstack-query specific)

import { ReactQueryDevtools } from "@tanstack/react-query-devtools"

<QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

The notable thing here is that initialIsOpen's value of true means the dev tools will be open by default, while its value of false means they will not be open by default.

Rendering The Query Content

You can write useQuery right into the component where the query value is used. But separating them into a different module could be the better approach. With that, we get different states of the query in status form like .isPending and .isError

import { FC } from "react"
import { useTodosIds } from "../services/queries"

interface ComponentProps {}

const Todo: FC<ComponentProps> = () => {
  const todosIdsQuery = useTodosIds()

  if (todosIdsQuery.isPending) return <span>loading...</span>

  if (todosIdsQuery.isError) return <span>there is an error!</span>

  return (
    <>
      {todosIdsQuery.data.map((id) => (
        <p key={id}>{id}</p>
      ))}
    </>
  )
}

export default Todo

We can look at those query state things more closely.

isLoading vs isPending vs isFetching, fetchStatus, status

const {data, isLoading, isPending, isFetching, isError, error, status} = useQuery({
    ...
    ......
})

status

status has 3 possible values →

pending = when there's no cached data and no query attempt was finished yet.

error = when the query attempt resulted in an error. error property has the error received from the attempted fetch

success = when the query has received a response with no errors and is ready to display its data.

fetchStatus

fetchStatus is another thing that tells you the current status of data fetching.

const todosIdsQuery = useTodosIds()

return (
    <>
      <p>Query function status: {todosIdsQuery.fetchStatus}</p>
      {todosIdsQuery.data.map((id) => (
        <p key={id}>{id}</p>
      ))}
    </>
  )

Normally, it returns idle as value when nothing is happening. But when the fetching is going on, it returns fetching.

isLoading vs isPending vs isFetching

isLoading: It gets true during the first fetch when no cached data exists.

isPending: It is not typically used for queries in TanStack Query (React Query). It is primarily used for  mutations  to indicate that a mutation is in progress (e.g., a POST or PUT request is being sent).

isFetching: Indicates active data fetching. This flag is true when queryFn is being executed, either for the first time or during background re-fetching.

Parallel queries with useQueries

With useQueries we can make multiple queries simultaneously and get the result in an array.

Example of useQueries Fetching Multiple APIs at the Same Time:

const results = useQueries({
  queries: [
    { queryKey: ["todos"], queryFn: getTodos },
    { queryKey: ["users"], queryFn: getUsers },
    { queryKey: ["posts"], queryFn: getPosts },
  ],
})

// Each query runs in parallel
const todos = results[0].data
const users = results[1].data
const posts = results[2].data

Another way of doing it could be the following:

export function useTodos(ids: (number | undefined)[] | undefined) {
  return useQueries({
    queries: (ids ?? []).map((id) => {
      return {
        queryKey: ["todo", id],
        queryFn: () => getTodo(id!),
      }
    }),
  })
}

This example serves a different purpose here.

useMutation() used for posting, updating and deleting

useQuery and useQueries Both are used to fetch data from the server. But useMutation() is used to make any change in the data saved in the server.

// ./api.ts
export const createTodo = async (data: Todo) => {
  await axiosInstance.post("todos", data)
}

// ./mutations.ts
export function useCreateTodo() {
  return useMutation({
    mutationFn: (data: Todo) => createTodo(data),
  })
}

But this is not what Tanstack-query was built for.

You can intercept the process of making the mutation request to the server by different other life-cycle events/callbacks like onMutate, onError, and so on:

export function useCreateTodo() {
  return useMutation({
    mutationFn: (data: Todo) => createTodo(data),
    onMutate: () => {
      console.log("mutate")
    },
    onError: () => {
      console.log("error")
    },
    onSuccess: () => {
      console.log("success")
    },
    onSettled: () => {
      console.log("settled")
    },
  })
}
  • onMutate: This callback is triggered immediately before the mutation function (mutationFn) is fired. It's often used to update the UI.

  • onError: This callback is triggered if the mutation encounters an error. You can also use this to revert optimistic updates or show error messages to the user.

  • onSuccess: This callback is triggered when the mutation gets completed successfully.

  • onSettled: This callback is triggered once the mutation is either successfully completed or encounters an error (like how finally in try-catch block works).

onSettled

onSettled has some useful parameters.

onSettled: (data, error, variables) => {
      console.log("mutate")
    },

Here, data is the returned data and variables is the parameter you pass through mutationFn function.

Invalidating Queries

onSettled can be very useful. Within this callback, we can invalidate data.

export function useCreateTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data: Todo) => createTodo(data),
    ...
    onSettled: async (_, error) => {
      console.log("settled")
      if (error) {
        console.log(error)
      } else {
        await queryClient.invalidateQueries({ queryKey: ["todos"] })
      }
    },
  })
}

So, here it refetches the query with query key ‘todos’ with the latest included data.

The following is how you would use the mutation in a React component:

const createTodoMutation = useCreateTodo()

const handleCreateTodoSubmit: SubmitHandler<TodoType> = (data) => {
   createTodoMutation.mutate(data)
}

Update Data with useMutation

// api.ts
export const updateTodo = async (data: Todo) => {
  await axiosInstance.put(`todos/${data.id}`, data)
}

// mutations.ts
export function useUpdateTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data: Todo) => updateTodo(data),
    onSettled: async (_, error, variables) => {
      if (error) {
        console.log(error)
      } else {
        await queryClient.invalidateQueries({ queryKey: ["todos"] })
        await queryClient.invalidateQueries({
          queryKey: ["todo", { id: variables.id }],
        })
      }
    },
  })
}

Here, we are invalidating the whole to-do list and the single to-do we updated.

The earlier example shows how to use this mutation in a React component.

Delete Data with useMutation

export const deleteTodo = async (id: number) => {
  await axiosInstance.delete(`todos/${id}`)
}

export function useDeleteTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (id: number) => deleteTodo(id),

    onSuccess: async (_, error) => {
      if (error) {
        console.log(error)
      } else {
        await queryClient.invalidateQueries({ queryKey: ["todos"] })
      }
    },
  })
}

If you want to do something after the mutation, use mutateAsync instead of mutate like this:

  const handleDeleteTodo = async (id: number) => {
    await deleteTodoMutation.mutateAsync(id)
  }

placeholderData: keepPreviousData

export const getProjects = async (page = 1) => {
  ;(await axiosInstance.get<Project[]>(`projects?_page=${page}&limit=3`)).data
}

export function useProjects(page: number) {
  return useQuery({
    queryKey: ["projects", { page }],
    queryFn: () => getProjects(page),
    placeholderData: keepPreviousData,
  })
}

placeholderData: keepPreviousData

This keeps the previous data in the matter of pagination. It keeps the previous page data even when the next page data has not been fetched yet.

Pagination

Define mutation:

export const getProjects = async (page = 1) => {
  return (await axiosInstance.get<Project[]>(`projects?_page=${page}&_limit=3`))
    .data
}

export function useProjects(page: number) {
  return useQuery({
    queryKey: ["projects", { page }],
    queryFn: () => getProjects(page),
    placeholderData: keepPreviousData,
  })
}

Now, use this mutation in the React component for pagination:

import { useState } from "react"
import { useProjects } from "../services/mutations"

export default function Projects() {
  const [page, setPage] = useState(1)

  const { data, isPending, error, isError, isPlaceholderData, isFetching } =
    useProjects(page)

  return (
    <div>
      {isPending ? (
        <div>loading...</div>
      ) : isError ? (
        <div>Error: {error.message}</div>
      ) : (
        <div>
          {data.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </div>
      )}
      <span>Current page: {page}</span>
      <button onClick={() => setPage((old) => Math.max(old - 1, 0))}>
        Previous Page
      </button>{" "}
      <button
        onClick={() => {
          if (!isPlaceholderData) {
            setPage((old) => old + 1)
          }
        }}
        disabled={isPlaceholderData}
      >
        Next Page
      </button>
      {isFetching ? <span>Loading...</span> : null}
    </div>
  )
}
0
Subscribe to my newsletter

Read articles from Abeer Abdul Ahad directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abeer Abdul Ahad
Abeer Abdul Ahad

I am a Full stack developer. Currently focusing on Next.js and Backend.