To build professional React applications, we must stop treating data from an API as local state. The moment we fetch data and store it in useState, we have created a static snapshot of a moving target.
Modern React architecture dictates a hard separation between two types of memory:
isModalOpen, currentTheme, formInputValues.useState, useReducer, Zustand, Redux.Using useState + useEffect to manage server data is considered an imperative manual trap because it fails to handle the complexities of the network.
Every time a component remounts, useEffect triggers a new fetch. The user is forced to see a loading spinner for data they likely just saw seconds ago, damaging the perceived performance.
When we store API data in useState, we are duplicating the database locally. There is no built-in mechanism to ensure that our local version of a user profile stays in sync with the real version on the server.
A common misconception is that data is deleted as soon as it leaves the screen. TanStack Query uses a sophisticated memory lifecycle to ensure Back/Forward navigation is instant.
User 1 to User 2), the data for User 1 is not discarded. It is moved to the Inactive state the Waiting Room.By default, TanStack Query keeps Inactive data in RAM for 5 minutes (gcTime).
TanStack Query shifts the focus from Data Fetching to Cache Management.
If five different components (Sidebar, Header, Profile, etc.) all require the same ['user'] data, TanStack Query intercepts the calls. It sends one network request and distributes the result to all observers, drastically reducing API overhead.
This is the core engine of professional UX. It follows a three-step process:
The useQuery hook is a declarative request to the Synchronization Engine. we don't tell React how to fetch; we describe what we need and where it should be stored in the cache.
A standard query requires two primary ingredients: a Unique Key and a Fetcher Function.
const { data, isLoading, error } = useQuery({
// 1. The Cache Address (The Label)
queryKey: ["user", userId],
// 2. The Network Request (The Action)
queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
// 3. Optional Configuration (The Rules)
staleTime: 1000 * 60 * 5, // 5 minutes
});Query Keys must be Arrays. TanStack Query uses these keys to:
['user', 1], only one fetch happens.The queryFn can be any function, as long as it returns a Promise. It should either resolve with data or throw an error. TanStack Query doesn't care if we use fetch, axios, or GraphQL.
Just like the dependency array in useEffect, Query Keys are reactive.
If our key contains a variable (like userId or pageNumber), TanStack Query will automatically trigger a new fetch whenever that variable changes.
// If 'page' changes from 1 to 2, a new fetch is triggered automatically.
// The data for Page 1 remains 'Inactive' in the cache.
const { data } = useQuery({
queryKey: ["products", { category, page }],
queryFn: () => fetchProducts(category, page),
});Think of Query Keys as a file-system. we can be specific or broad. This allows for Bulk Operations.
['posts']: Everything in the posts folder.['posts', 'list', { filter: 'new' }]: A specific filtered list.['posts', 123]: A specific single post.Hierarchy Tip: Always put the most general string first and the most specific IDs or objects last. This makes it easier to say: Invalidate everything that starts with 'posts'.
we don't need to put query data into a useEffect to transform it. Use the select option. This is a performance win because it memoizes the result.
const { data: userCount } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
// Transform the data before it reaches the component
select: (users) => users.length,
});TanStack Query uses a Stale-While-Revalidate (SWR) logic that hinges on two core timestamps: staleTime and gcTime.
Every piece of data in our cache exists in one of four states at any given moment:
These two settings are often confused, but they serve completely different purposes.
01000 * 60 * 60 (1 hour) to eliminate redundant network calls.5 minutesReact Query does not fetch in a loop. It is event driven. It waits for Smart Triggers to re-verify stale data:
When we navigate back to a user profile you've seen before, this sequence occurs:
staleTime is 0).| Feature | Manual (useEffect + useState) | TanStack Query |
|---|---|---|
| Philosophy | Imperative (How to get data) | Declarative (What data is needed) |
| Caching | Manual / Difficult | Automatic & Persistent |
| Deduplication | No (Fetch runs per component) | Yes (Smart request sharing) |
| Performance | Blocked by spinners | Instant via SWR Pattern |
| Syncing | Manual triggers | Auto-refetch on window focus/tab switch |
| Property | Purpose | Logic |
|---|---|---|
queryKey | Identity | Unique array used for caching and dependency tracking. |
queryFn | Action | A function returning a Promise that resolves the data. |
enabled | Control | Set to false to prevent the query from running automatically. |
select | Transformation | Filters or transforms the data returned by the fetcher. |
| Property | Default | Role |
|---|---|---|
staleTime | 0 | How long until data needs "Verification." |
gcTime | 5 mins | How long until data is "Forgotten" entirely. |
refetchOnWindowFocus | true | Re-verify when the user returns to the tab. |
refetchOnMount | true | Re-verify when a component appears. |
React Query is not a data-fetching library; it is a Synchronization Engine. It ensures that the browser's memory is an accurate, up-to-date mirror of the server's reality.
Query Keys are the 'folders' of our application's memory. If we change a key, we aren't just changing a variable; we are opening a new folder. If that folder is empty, we'll see a spinner. If it has 'Inactive' data from 2 minutes ago, the UI will be instant.
If we set
staleTimeto infinity andgcTimeto infinity, we have essentially built a 'Static App.' The data will load once and never update again unless the user refreshes the browser. Mastering these two variables is how we balance UI speed with Data Accuracy.