React Query: The Missing Piece
I'd been managing server state wrong my entire React career. useEffect for fetching, useState for caching, manual refetching, stale data bugs everywhere. Then React Query showed me that server state was never meant to live in my components. Everything I thought I knew about data fetching changed.
The Data Fetching Code I Was Ashamed Of
After writing about the full circle of web development, I looked at my actual production code. The data fetching was… not great.
Every component that needed data looked like this:
function UserDashboard({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then(data => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
Eighteen lines of boilerplate. For every. Single. Component. That fetches data.
And that’s the “good” version — with cancellation handling. Most of my components didn’t even have that.
I had bugs:
- Stale data showing after navigating back to a page
- Race conditions when the user clicked fast
- No automatic refetching when the window regained focus
- Duplicate requests when multiple components needed the same data
- No loading state coordination
I was building a crappy cache, badly, in every component.
The Realization
I mentioned React Query briefly in my state management post. But I hadn’t truly understood it yet. I was still treating it like “a nicer way to fetch.”
Then I read Tanner Linsley’s blog post about the difference between server state and client state. And the penny dropped.
Client state: Data your app owns. Theme preference. Modal open/closed. Form inputs. Shopping cart.
Server state: Data your app borrows. User profiles. Products. Posts. Orders.
I had been shoving borrowed data into useState and pretending I owned it. But I didn’t own that data. The server did. My copy was just a cache — and I was managing that cache terribly.
Enter: React Query
React Query (now TanStack Query) is a server state management library. It handles caching, synchronization, and updates of server state.
Here’s that same component:
import { useQuery } from '@tanstack/react-query';
function UserDashboard({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
Six lines. No useState. No useEffect. No cancelled flag. No race condition bugs.
But that’s just the surface. The real magic is everything happening behind the scenes.
What React Query Does For Free
Caching
Navigate to a user’s profile. Navigate away. Navigate back. With useEffect, you’d see a loading spinner again. React Query shows the cached data instantly and refetches in the background.
// First visit: loading spinner → data
// Second visit: instant data (from cache) → silent background refetch
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
});
Deduplication
Two components on the same page need user data? With useEffect, that’s two identical API calls. React Query makes one.
// In Navbar
const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
// In Sidebar (same page)
const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
// Only ONE network request!
Automatic Refetching
User switched tabs and came back? React Query refetches stale data. The network reconnected after going offline? Refetch. The window regained focus? Refetch.
const { data } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchOnWindowFocus: true, // Default: true
refetchOnReconnect: true, // Default: true
refetchInterval: 30000, // Poll every 30 seconds
});
I used to build this manually. Badly. Now it’s a config option.
Retry Logic
API call failed? React Query retries automatically with exponential backoff.
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
retry: 3, // Retry 3 times
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
});
Garbage Collection
Cached data that no component is using? React Query cleans it up after a configurable time.
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
gcTime: 10 * 60 * 1000, // Keep in cache 10 minutes after last use
});
No memory leaks. No stale data piling up.
Query Keys: The Heart of It All
The query key is how React Query identifies and caches data. Get this right and everything falls into place.
// Different user = different cache entry
useQuery({ queryKey: ['user', 1], queryFn: () => fetchUser(1) });
useQuery({ queryKey: ['user', 2], queryFn: () => fetchUser(2) });
// Same user = shared cache (deduplicated)
useQuery({ queryKey: ['user', 1], queryFn: () => fetchUser(1) });
useQuery({ queryKey: ['user', 1], queryFn: () => fetchUser(1) });
// Queries with filters
useQuery({ queryKey: ['posts', { status: 'published' }], queryFn: fetchPublishedPosts });
useQuery({ queryKey: ['posts', { status: 'draft' }], queryFn: fetchDraftPosts });
I think of query keys like addresses. Same address = same mailbox. Different address = different mailbox.
Mutations: When You Need to Change Data
Reading data is useQuery. Writing data is useMutation.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePostForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost) => {
return fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(res => res.json());
},
onSuccess: () => {
// Invalidate the posts cache — triggers refetch
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleSubmit = (e) => {
e.preventDefault();
mutation.mutate({
title: e.target.title.value,
content: e.target.content.value,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" />
<textarea name="content" placeholder="Content" />
<button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
{mutation.isSuccess && <p>Post created!</p>}
</form>
);
}
Create a post → invalidate the posts cache → list refetches automatically. No manual state updates. No Redux dispatch.
Optimistic Updates
This blew my mind. You can update the UI before the server responds:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot current value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
);
return { previousTodos };
},
onError: (err, updatedTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Refetch to make sure we're in sync
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Toggle a todo? It updates instantly. If the server fails, it rolls back. The user never sees a loading state for simple interactions.
React Query DevTools
Remember how Redux DevTools changed my debugging life? React Query has its own DevTools:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
You can see:
- All active queries and their status
- Cached data for each query
- When queries were last fetched
- Which queries are stale, fresh, or fetching
- Manual refetch and invalidation
I found a bug in 30 seconds that would have taken me an hour with console.log.
The Patterns I Use Daily
Dependent Queries
Fetch one thing, then use that result to fetch another:
function UserPosts({ userId }) {
// First: get the user
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// Then: get their posts (only runs when user exists)
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPostsByAuthor(user.id),
enabled: !!user, // Don't run until user is loaded
});
}
Infinite Scroll
function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts({ page: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
return (
<div>
{data.pages.map(page =>
page.posts.map(post => <PostCard key={post.id} post={post} />)
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more posts'}
</button>
</div>
);
}
Prefetching
Start fetching data before the user needs it:
function PostList({ posts }) {
const queryClient = useQueryClient();
return (
<ul>
{posts.map(post => (
<li
key={post.id}
onMouseEnter={() => {
// Prefetch on hover — by the time they click, data is ready
queryClient.prefetchQuery({
queryKey: ['post', post.id],
queryFn: () => fetchPost(post.id),
});
}}
>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}
Hover over a link, data starts loading. Click, and it’s already there. Users think your app is instant.
What I Deleted After Adopting React Query
Here’s the before and after of my codebase:
Deleted:
- Custom
useFetchhook (45 lines) useApihook with caching attempt (120 lines)- Manual cache invalidation logic (80 lines)
- Loading state management in Redux (200+ lines across slices)
- Retry logic wrapper (30 lines)
- Window focus refetch listener (25 lines)
- Race condition prevention wrapper (35 lines)
Added:
- React Query setup (15 lines)
Total: ~535 lines deleted. 15 lines added.
What I Wish I’d Known Earlier
-
staleTimeis your best friend. Set it globally. 5 minutes is a good default. Stop refetching data that hasn’t changed. -
Query keys should mirror your API structure.
['users', userId, 'posts']maps cleanly to/api/users/:id/posts. -
Don’t put server data in Redux/Zustand. Use React Query for server state, Zustand for client state. They complement each other.
-
enabledprevents waterfalls. Use it for dependent queries instead of fetching inuseEffectchains. -
The DevTools are not optional. Install them on day one. You’ll catch bugs before your users do.
-
Invalidation > manual cache updates. When in doubt, just invalidate and let React Query refetch. Simpler and less error-prone.
The Journey Continues
React Query changed how I think about data. Server state isn’t something I manage — it’s something I synchronize with. The distinction sounds subtle, but it changes everything about how you architect a React app.
But I was still writing React without a framework. No routing. No SSR. No file-based routing. My create-react-app setup was showing its age.
Everyone kept saying the same thing: “Just use Next.js.”
P.S. — The day after I shipped my React Query migration, a user reported that data was “always up to date now.” They thought we’d added real-time updates. Nope. Just stale-while-revalidate doing its thing. Sometimes the best features are the ones users don’t notice.
Saurav Sitaula
Software Architect • Nepal