React Query: The Missing Piece

SS Saurav Sitaula

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 useFetch hook (45 lines)
  • useApi hook 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

  1. staleTime is your best friend. Set it globally. 5 minutes is a good default. Stop refetching data that hasn’t changed.

  2. Query keys should mirror your API structure. ['users', userId, 'posts'] maps cleanly to /api/users/:id/posts.

  3. Don’t put server data in Redux/Zustand. Use React Query for server state, Zustand for client state. They complement each other.

  4. enabled prevents waterfalls. Use it for dependent queries instead of fetching in useEffect chains.

  5. The DevTools are not optional. Install them on day one. You’ll catch bugs before your users do.

  6. 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.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism