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>
)
}
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.