Web Development

tanstack query simplified

Here I have compiled my understanding of React Query (TanStack Query) over the months and simplified it into my personal study reference. This is mainly written for myself, but I believe it can also serve as a good starting guide for someone who is new to learning TanStack Query.

KUZUE
August 11, 2026
15 min read
4 views

React Query (TanStack Query)

Here I have compiled my understanding of React Query over the months and simplified it into my personal study reference.

This should also prove to be a good guide for someone who is new to learning React Query.

What is React Query?

React Query, now called TanStack Query, is a client-side data-fetching and server-state management library.

But it comes with lots of abilities.

It gives us automatic caching, background refetching, loading and error states, window-focus refetching, query deduplication, retries, and cache garbage collection.

Instead of manually writing all the logic for handling data that comes from an API, React Query gives us tools to manage that server data.

For example, without React Query, I may have to manually handle:

  • Loading state
  • Error state
  • API response
  • Caching
  • Refetching
  • Retry logic
  • Keeping data fresh

React Query helps me handle these things for me.


Installation

First, install React Query:

bash
npm install @tanstack/react-query

If I also want the React Query Devtools:

bash
npm install @tanstack/react-query-devtools

Or both at once:

bash
npm install @tanstack/react-query @tanstack/react-query-devtools

Creating the QueryClient

After installing React Query, we need to create a QueryClient.

The QueryClient is the central "brain and memory storage" of React Query.

It maintains the query cache, tracks query states, manages inactive cached data, and applies default configurations.

I can think about it like this:

The QueryClient is the place where React Query keeps track of everything it knows about my server data.

In a Next.js application, I can create a providers.tsx file:

text
app/
├── layout.tsx
└── providers.tsx

Inside providers.tsx:

tsx
"use client";

import { useState } from "react";
import {
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";

export function Providers({
  children,
}: {
  children: React.ReactNode;
}) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 1000 * 60 * 5,
            refetchOnWindowFocus: true,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

Then I go to layout.tsx and wrap my application with the provider:

tsx
import { Providers } from "./providers";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

This setup gives my application a shared, in-memory cache where React Query can store the data returned from my API requests.

This means that components do not always have to make a new network request when the data is already available in the cache.


Fetching Data With useQuery

For fetching data, we use the useQuery hook.

For mutating data, we use the useMutation hook.

A simple way I remember this is:

text
useQuery
    ↓
Read/fetch server data

useMutation
    ↓
Create/update/delete server data

The useQuery Hook

A basic useQuery looks like this:

tsx
const {
  data,
  isPending,
  isFetching,
  isError,
  error,
  status,
  fetchStatus,
  refetch,
} = useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
});

The two most important properties to understand first are queryKey and queryFn.


queryKey

tsx
queryKey: ["todos"]

The queryKey is an array that uniquely identifies a query.

I like to think of it as the address of the data in the cache.

For example:

tsx
queryKey: ["todos"]

I can mentally imagine something like:

text
cache["todos"]

This is only a mental model, not how the cache is literally implemented.

React Query uses the query key to determine which cached data belongs to which query.

Using variables in queryKey

The query key can contain variables.

For example:

tsx
const { data } = useQuery({
  queryKey: ["user", userId],
  queryFn: () => fetchUser(userId),
});

If:

text
userId = 1

the query key becomes:

tsx
["user", 1]

If:

text
userId = 2

the query key becomes:

tsx
["user", 2]

React Query treats these as different queries and caches their results separately.

This is why I think of the query key somewhat like a dependency array.

When a value used in the query key changes, React Query can identify that as a different query and fetch the data associated with that new key.


queryFn

The queryFn is the function responsible for fetching the data.

For example:

tsx
async function fetchTodos() {
  const response = await fetch("/api/todos");

  if (!response.ok) {
    throw new Error("Failed to fetch todos");
  }

  return response.json();
}

Then:

tsx
const { data } = useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
});

So I remember it like this:

queryKey tells React Query what data I am talking about.

queryFn tells React Query how to get that data.


data

tsx
const { data } = useQuery(...);

data contains the value returned by the queryFn after the promise successfully resolves.

For example, if my API returns:

json
[
  {
    "id": 1,
    "title": "Learn React Query"
  },
  {
    "id": 2,
    "title": "Build a project"
  }
]

then data contains that array.


status

status answers the question:

What is the current state of my query data?

It has three possible values:

text
pending
error
success

pending

The query does not currently have successful data.

This commonly happens during the initial fetch.

success

The query successfully has data.

error

The query encountered an error.

For example:

tsx
if (status === "pending") {
  return <p>Loading...</p>;
}

if (status === "error") {
  return <p>Something went wrong.</p>;
}

return <div>{/* render data */}</div>;

isPending

Instead of checking:

tsx
status === "pending"

I can use:

tsx
isPending

For example:

tsx
if (isPending) {
  return <p>Loading...</p>;
}

isPending means that the query currently has no successful data.


isLoading

isLoading is slightly different from isPending.

It is true when the query is pending and fetching at the same time.

In simple terms:

text
isLoading = isPending + isFetching

This distinction becomes useful when I start working with cached data.

For example, I may already have cached data on the screen while React Query is fetching a newer version in the background.

In that situation:

text
isPending  → false
isFetching → true

Therefore, isLoading is not necessarily true.


isFetching

isFetching answers the question:

Is the query function currently running?

During the first request:

text
No cached data
      ↓
Fetching starts
      ↓
isPending = true
isFetching = true

But imagine that I already have cached data:

text
Cached data exists
      ↓
Background refetch starts
      ↓
isPending = false
isFetching = true

This is an important concept.

I can have data displayed on the screen while isFetching is still true.

React Query can show the cached data while it contacts the server to see whether there is a newer version.


fetchStatus

fetchStatus answers a different question:

What is happening with the fetching process?

It can have three values:

text
fetching
paused
idle

fetching

The query function is currently executing.

paused

The query wants to fetch, but fetching is currently paused. This can happen when the device is offline.

idle

There is currently no fetch happening.

The difference between status and fetchStatus is important.

I remember it this way:

| Property | Question | | ------------- | ------------------------------------------ | | status | What is the state of my data? | | fetchStatus | What is the state of the fetching process? |

For example, I can have:

tsx
status: "success"
fetchStatus: "fetching"

This means:

I already have data, but React Query is currently fetching a newer version.


isError

tsx
const { isError, error } = useQuery(...);

isError becomes true when the query encounters an error.

For example:

tsx
if (isError) {
  return <p>{error.message}</p>;
}

The queryFn should throw an error when the request fails.

For example:

tsx
async function fetchTodos() {
  const response = await fetch("/api/todos");

  if (!response.ok) {
    throw new Error("Failed to fetch todos");
  }

  return response.json();
}

refetch

refetch is a function that allows me to manually trigger a query again.

tsx
const { data, refetch } = useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
});

Then:

tsx
<button onClick={() => refetch()}>
  Refresh
</button>

I can think of refetch() as telling React Query:

"Please run this query again."


enabled

When enabled is false, React Query will not automatically run the query when the component mounts.

This is useful for conditional queries.

For example:

tsx
const { data } = useQuery({
  queryKey: ["user", userId],
  queryFn: () => fetchUser(userId),
  enabled: !!userId,
});

If userId does not exist:

text
userId = undefined
      ↓
enabled = false
      ↓
Query does not automatically run

When userId becomes available:

text
userId = 10
      ↓
enabled = true
      ↓
Query can run

refetchOnWindowFocus

By default, refetchOnWindowFocus is true.

When the user leaves the browser tab and later comes back, React Query checks the query.

If the query is stale, it can automatically perform a background refetch.

For example:

text
User opens application
        ↓
Data is fetched
        ↓
User changes to another tab
        ↓
Some time passes
        ↓
User comes back
        ↓
React Query checks the query
        ↓
If stale → background refetch

It has three common options:

true

Refetch stale queries when the window regains focus.

false

Do not refetch when the window regains focus.

"always"

Always refetch when the window regains focus, even if the data is still fresh.


staleTime

staleTime answers the question:

How long should this data remain fresh?

For example:

tsx
staleTime: 1000 * 60 * 5

This means:

text
1000 milliseconds
× 60
× 5
= 5 minutes

So:

text
Data fetched
     ↓
Fresh for 5 minutes
     ↓
Becomes stale

While the data is fresh, React Query generally will not make a background request because of triggers such as mounting or window focus.


What Does "Stale" Mean?

Stale does not mean that React Query has deleted the data.

It simply means:

React Query considers the data old enough that it may need to check the server for a newer version.

The data can still remain in the cache.

So:

text
Fresh
  ↓
Stale
  ↓
Still cached

This distinction is very important.


gcTime

gcTime answers the question:

How long should inactive cached data remain in memory?

Suppose a component is using a query:

text
Component
    ↓
Using query
    ↓
Query is active

Then the component unmounts:

text
Component unmounts
    ↓
Query becomes inactive
    ↓
gcTime timer starts

If the query remains unused until the gcTime expires, React Query can remove it from the cache.

So:

text
staleTime
    ↓
How long is the data fresh?

gcTime
    ↓
How long does inactive cached data remain?

staleTime vs gcTime

For example:

tsx
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,

The lifecycle can be understood as:

text
Data fetched
     ↓
Fresh for 5 minutes
     ↓
Data becomes stale
     ↓
Still remains cached
     ↓
Component unmounts
     ↓
gcTime timer starts
     ↓
30 minutes of inactivity
     ↓
Garbage collected

So I remember:

staleTime controls freshness.

gcTime controls how long inactive cached data remains.


placeholderData

placeholderData allows us to provide temporary data while a query is fetching.

A useful example is pagination.

Suppose I am currently viewing page 1 and then move to page 2.

Without keeping the previous data, the UI can appear empty while page 2 is loading.

We can use keepPreviousData with placeholderData:

tsx
import {
  keepPreviousData,
  useQuery,
} from "@tanstack/react-query";

const { data, isFetching } = useQuery({
  queryKey: ["todos", page],
  queryFn: () => fetchTodos(page),
  placeholderData: keepPreviousData,
});

This allows the previous page's data to remain visible while the new page is being fetched.

The important thing to remember is:

placeholderData is temporary data used while the query is fetching. It is not the new fetched result stored as the query's actual data.


initialData

initialData is different from placeholderData.

initialData provides initial data for the query and is used to seed the query's cache.

For example:

tsx
const { data } = useQuery({
  queryKey: ["user", userId],
  queryFn: () => fetchUser(userId),
  initialData: userFromServer,
});

This can be useful when I already have some data available before the query runs.

So I remember it this way:

text
placeholderData
    ↓
Temporary data

initialData
    ↓
Initial data used to seed the cache

select

select is used for data transformation.

It allows us to transform or filter the query data before using it in the component.

For example:

tsx
const { data: names } = useQuery({
  queryKey: ["users"],
  queryFn: fetchUsers,
  select: (users) =>
    users.map((user) => user.name),
});

If the API returns:

json
[
  {
    "id": 1,
    "name": "John"
  },
  {
    "id": 2,
    "name": "Mary"
  }
]

then names becomes:

tsx
["John", "Mary"]

So I can think of select as:

text
API data
   ↓
select()
   ↓
Transformed data
   ↓
Component

refetchInterval

refetchInterval automatically refetches a query at a specified interval.

For example:

tsx
refetchInterval: 3000

means the query can be refetched every 3 seconds.

text
Fetch
  ↓
Wait 3 seconds
  ↓
Fetch again
  ↓
Wait 3 seconds
  ↓
Fetch again

This is useful for polling.

For example:

tsx
const { data } = useQuery({
  queryKey: ["crypto-price"],
  queryFn: fetchCryptoPrice,
  refetchInterval: 3000,
});

It can be useful for:

  • Dashboards
  • Job status
  • Frequently changing data
  • Monitoring systems

retry

retry controls how many times React Query should retry a failed query.

For example:

tsx
retry: 3

Conceptually:

text
Request
   ↓
Fails
   ↓
Retry
   ↓
Fails
   ↓
Retry
   ↓
Fails
   ↓
Retry
   ↓
Fails
   ↓
Error state

The retry behavior can also be customized when we need more control.


A Conditional Search Example

Now let's put some of these concepts together.

Suppose I have a search input:

tsx
const [searchTerm, setSearchTerm] = useState("");
const [shouldSearch, setShouldSearch] = useState(false);

Then:

tsx
const { data: searchResults } = useQuery({
  queryKey: ["search", searchTerm],
  queryFn: () => fetchSearchResults(searchTerm),
  enabled: shouldSearch && searchTerm.length > 0,
});

Here:

tsx
queryKey: ["search", searchTerm]

means that each search term has its own query identity.

For example:

text
["search", "react"]
["search", "nextjs"]
["search", "python"]

are different query keys.

And:

tsx
enabled: shouldSearch && searchTerm.length > 0

means the query should only run when:

  1. shouldSearch is true
  2. searchTerm is not empty

So:

text
shouldSearch = false
       ↓
Query disabled

But:

text
shouldSearch = true
searchTerm = "react"
       ↓
Query enabled
       ↓
fetchSearchResults("react")

My Simple Way of Remembering useQuery

When I see:

tsx
useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
});

I think:

| Property | Question it answers | | ---------------------- | -------------------------------------------------- | | queryKey | What data am I talking about? | | queryFn | How do I get the data? | | data | What did the server return? | | status | What is the state of the query data? | | fetchStatus | What is happening with the fetch? | | enabled | Should this query run automatically? | | staleTime | How long should this data be fresh? | | gcTime | How long should inactive cached data remain? | | refetchOnWindowFocus | Should I refetch when the user returns to the tab? | | refetchInterval | Should I keep polling the server? | | select | Do I want to transform the returned data? | | placeholderData | What temporary data should I show while fetching? | | initialData | Do I already have initial data to seed the cache? | | retry | How many times should a failed query be retried? | | refetch | Do I want to manually run the query again? |


The Main Idea

The most important thing I have understood is that React Query is not simply a replacement for fetch().

fetch() mainly gives us a way to make an HTTP request.

React Query goes further.

It helps us manage the server state that comes from those requests.

That includes:

  • Fetching
  • Caching
  • Freshness
  • Refetching
  • Loading states
  • Error states
  • Retries
  • Garbage collection

So instead of thinking:

"React Query is a better fetch()."

I think:

"React Query helps me manage data that lives on the server but is being used by my React application."


Next: useMutation

So far, useQuery has mainly been about reading data.

For example:

text
GET /api/products
GET /api/sales
GET /api/users

When I want to change data on the server, I use useMutation.

For example:

text
POST
PUT
PATCH
DELETE

This leads to another important part of React Query:

  • useMutation
  • queryClient
  • invalidateQueries()
  • Updating the cache
  • Optimistic updates
  • Mutation loading and error states

These are the next concepts I need to understand.

Comments (0)

No comments yet. Be the first to share your thoughts.