React Query: The Misunderstood Data-Fetching Library

Despite its name and reputation as a "data-fetching library," React Query doesn't actually fetch anything. It's a common misconception that needs clearing up.
What React Query Really Is
React Query is data-fetching agnostic. It's purely a caching and state management library that doesn't care how you fetch your data. Whether you use fetch(), axios, XMLHttpRequest, or even a custom GraphQL client, React Query treats them all the same.
Think of React Query as a sophisticated data orchestrator. You're responsible for choosing a data fetcher, then React Query handles storing it, synchronizing it across components, and only refetching after the specified stale time.
Understanding the Architecture
React Query's architecture separates concerns beautifully:
Your Responsibility (The Data Fetcher):
Choose your HTTP client (fetch, axios, etc.)
Define API endpoints and request structure
Handle request formatting (REST, GraphQL, etc.)
Configure authentication and headers
React Query's Responsibility (The Cache Manager):
Store fetched data in memory
Track cache freshness and staleness
Coordinate when to refetch
Manage loading and error states across your app
This separation means you have complete flexibility in how you retrieve data, while React Query handles the complex orchestration of keeping that data fresh and accessible.
You Can Use ANY Fetching Method
Here's the beauty of React Query's agnostic approach—all of these work equally well:
// Option 1: Native fetch()
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
});
// Option 2: Axios
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => axios.get(`/api/users/${userId}`).then(res => res.data)
});
// Option 3: GraphQL client
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => graphqlClient.request(GET_USER_QUERY, { userId })
});
// Option 4: Your custom API wrapper
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => myCustomApi.users.getById(userId)
});
Notice the pattern? React Query only cares that your queryFn returns a Promise. Everything else is up to you.
What React Query DOES Handle
React Query excels at server state management and data synchronization:
✅ Caches results - Stores data in memory with configurable cache times, eliminating redundant requests
✅ Manages loading/error states - Provides isLoading, isError, error, isFetching without manual useState calls
✅ Handles refetching/invalidation - Automatically refetches stale data and invalidates caches when mutations occur
✅ Prevents duplicate requests - Deduplicates simultaneous requests for the same data across your entire app
✅ Background updates - Refetches data in the background when windows refocus, or network reconnects
✅ Pagination/infinite scroll helpers - Built-in utilities for complex data loading patterns
✅ Optimistic updates - Update UI immediately before the server confirms changes
✅ Global state synchronization - Automatically updates all components using the same query when data changes
✅ Request retry logic - Configurable retry attempts for failed requests
✅ Garbage collection - Removes unused cache entries to prevent memory leaks
What React Query DOESN'T Do
Understanding the boundaries is just as important:
❌ Make HTTP requests - You provide the fetching function
❌ Know about REST/GraphQL - It's protocol-agnostic
❌ Handle network layer - No built-in retry logic, interceptors, or request transformation (though you can add these yourself)
Our Real-World Implementation
In our production codebase, we chose the native fetch() API to make GraphQL requests. React Query manages the entire data lifecycle:
const useProject = (projectId) => {
return useQuery({
queryKey: ['project', projectId],
queryFn: async () => {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query GetProject($id: ID!) {
project(id: $id) {
id
name
description
updatedAt
}
}
`,
variables: { id: projectId }
})
});
const { data, errors } = await response.json();
if (errors) throw new Error(errors[0].message);
return data.project;
},
staleTime: 5 * 60 * 000, // React Query manages staleness
cacheTime: 10 * 60 * 1000, // React Query manages cache
});
};
We handle the GraphQL request formatting and network call. React Query handles caching that response, tracking when it becomes stale, managing loading states across components, and automatically refetching when needed.
Understanding staleTime vs cacheTime (gcTime)
Two of React Query's most important but confusing configurations are staleTime and cacheTime (renamed to gcTime in v5). They control different aspects of the data lifecycle:
staleTime - When data becomes "stale"
This determines how long data is considered "fresh." Fresh data won't trigger a refetch.
useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
staleTime: 5 * 60 * 1000, // 5 minutes
});
Default:
0(immediately stale)When staleTime expires, data becomes "stale" but remains in cache
Stale data triggers background refetches on window focus, component mount, or manual refetch
Fresh data (within staleTime) won't refetch even if you revisit the component
cacheTime/gcTime - When cache gets garbage collected
This determines how long unused data stays in memory after all components are unmounted.
useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
cacheTime: 10 * 60 * 1000, // Cached for 10 minutes (v4)
// gcTime: 10 * 60 * 1000, // In v5, use gcTime instead
});
Default:
5 minutesStarts counting down when the last component using this query unmounts
When gcTime expires, data is removed from memory completely
Next fetch will be a fresh request, not served from cache
How They Work Together:
Time: 0s -------- 5min -------- 10min -------- 15min
| | | |
Mount --> [--FRESH---][---STALE----] |
| | | |
Unmount --------> | | |
| [--CACHED--][--DELETED--]
Real-world example:
// User profile - changes infrequently
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
staleTime: 10 * 60 * 1000, // Don't refetch for 10 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
});
// Real-time stock prices - always fresh
useQuery({
queryKey: ['stock', symbol],
queryFn: fetchStock,
staleTime: 0, // Always stale, always refetch
gcTime: 1 * 60 * 1000, // Clear from cache after 1 minute
});
// Static reference data - rarely changes
useQuery({
queryKey: ['countries'],
queryFn: fetch
The Bottom Line
React Query isn't a data-fetching library—it's a server state management and synchronization library. This distinction matters because it means you maintain full control over your data layer while delegating the complex state orchestration to a battle-tested solution.
Why "State Management" Matters
Traditional state management libraries like Redux or Zustand are designed for client state—data that lives entirely in your application (UI state, form inputs, user preferences). Server state is fundamentally different:
Server State Characteristics:
Asynchronously fetched from remote sources
Can become outdated or "stale"
Shared across multiple components and users
Requires loading and error states
Needs periodic refreshing
Benefits of caching to reduce network requests
React Query specializes in server state. It solves problems that client state managers weren't designed for:
// Without React Query - manual state management
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [lastFetch, setLastFetch] = useState(null);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const result = await fetch('/api/data');
setData(await result.json());
setLastFetch(Date.now());
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};
// Need to track staleness manually
const isStale = !lastFetch || Date.now() - lastFetch > 60000;
if (isStale) fetchData();
}, [lastFetch]);
// With React Query - automatic server state management
const { data, isLoading, error } = useQuery({
queryKey: ['data'],
queryFn: () => fetch('/api/data').then(res => res.json()),
staleTime: 60000
});
React Query automatically handles what you'd need dozens of lines to manage manually: caching, staleness tracking, background refetching, request deduplication, and state synchronization across components.
Using React Query as Global State Management
One of React Query's most powerful features is automatic global state synchronization. When you fetch data with the same queryKey in multiple components, they all share the same cached data and update together:
// UserProfile.jsx
function UserProfile() {
const { data: user } = useQuery({
queryKey: ['user', 'current'],
queryFn: fetchCurrentUser,
});
return <div>{user.name}</div>;
}
// UserAvatar.jsx (different component, same data!)
function UserAvatar() {
const { data: user } = useQuery({
queryKey: ['user', 'current'],
queryFn: fetchCurrentUser,
});
return <img src={user.avatar} />;
}
// Settings.jsx (updates everywhere automatically)
function Settings() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateUserName,
onSuccess: (updatedUser) => {
// This updates BOTH UserProfile and UserAvatar instantly
queryClient.setQueryData(['user', 'current'], updatedUser);
}
});
return <button onClick={() => mutation.mutate({ name: 'New Name' })}>
Update Name
</button>;
}
What just happened?
Multiple components use the same
queryKey: ['user', 'current']React Query fetches once and shares the result
When we update the cache with
setQueryData, all components re-render with new dataNo props drilling, no context providers, no manual subscriptions
This replaces traditional global state patterns:
// Traditional approach - lots of boilerplate
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchUser().then(setUser);
}, []);
return (
<UserContext.Provider value={{ user, setUser, loading }}>
{children}
</UserContext.Provider>
);
}
// React Query approach - automatic
// Just use the same queryKey wherever you need the data!
The queryKey acts as a global identifier. Any component using that key gets the same data, loading states, and updates—automatically.



