DEV Community

Cover image for Why I Started Learning React Query (TanStack Query) Today
Bishoy Bishai
Bishoy Bishai

Posted on • Originally published at bishoy-bishai.github.io

Why I Started Learning React Query (TanStack Query) Today

Why I Started Learning React Query (TanStack Query) Today

Let's be honest, we've all been there. You start a new React project, full of enthusiasm for building a sleek, responsive UI. But then, almost inevitably, you hit the data wall. Suddenly, your components are littered with isLoading flags, isError checks, setData calls, and useEffect hooks with dependency arrays that make your head spin. You end up writing the same boilerplate over and over again, manually managing cache invalidation, and debugging race conditions on every other network request. It's… exhausting.

For years, I wrestled with this. I tried global state managers, custom fetch wrappers, even rolling my own caching layers. Each attempt solved one problem while introducing three others. My biggest pain point, though, wasn't just fetching data; it was managing the state of that data – when it was loading, when it was stale, when it needed refetching, and how to keep my UI consistently updated. That's precisely why I started my deep dive into React Query (now TanStack Query) today, and why I think every professional React developer should, too.

The Crucial Distinction: Server State vs. Client State

Here's the thing that most tutorials gloss over: server state is fundamentally different from client state.

  • Client state is data you fully control and own within your application. Think of a dark mode toggle, an open/closed modal, or the current step in a multi-step form. It's synchronous, predictable, and lives entirely in your browser's memory.
  • Server state is remote, asynchronous, shared across many users, and inherently stale. You don't fully control it; you're merely reflecting a snapshot of it in your UI. It needs to be fetched, cached, and often re-fetched in the background to stay fresh.

Traditional state management libraries like Redux or Zustand are fantastic for client state. They provide a predictable way to manage synchronous data flow. But when you try to shoehorn asynchronous server state into them, they start to creak under the pressure. You end up writing a ton of thunks or sagas, managing loading and error states manually, and building complex caching logic from scratch.

In my experience, this distinction is where React Query truly shines. It’s built specifically for managing server state, treating it as a first-class citizen with its own lifecycle and concerns.

How React Query Changes the Game: Less Boilerplate, More Magic

Let's look at a quick example. Imagine you want to fetch a list of posts. Without React Query, you might do something like this:

import React, { useState, useEffect } from 'react';

interface Post {
  id: number;
  title: string;
  body: string;
}

const PostsList: React.FC = () => {
  const [posts, setPosts] = useState<Post[] | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        const response = await fetch('/api/posts');
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const data: Post[] = await response.json();
        setPosts(data);
      } catch (error) {
        console.error('Failed to fetch posts:', error);
        setIsError(true);
      } finally {
        setIsLoading(false);
      }
    };

    fetchPosts();
  }, []); // Empty dependency array means fetch once on mount

  if (isLoading) return <div>Loading posts...</div>;
  if (isError) return <div>Error loading posts.</div>;
  if (!posts) return null; // Should ideally not happen if isLoading/isError are handled

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

This is fine for a single component. Now, imagine you need to display posts in several places, or update a single post, or handle pagination. The boilerplate explodes.

Now, with React Query:

import React from 'react';
import { useQuery, useMutation, QueryClient, QueryClientProvider } from '@tanstack/react-query';

interface Post {
  id: number;
  title: string;
  body: string;
}

// 1. Initialize QueryClient
const queryClient = new QueryClient();

// 2. Wrap your app with QueryClientProvider
const App: React.FC = () => (
  <QueryClientProvider client={queryClient}>
    <PostsList />
    <AddPostForm />
  </QueryClientProvider>
);

// 3. Define your fetcher function
const fetchPosts = async (): Promise<Post[]> => {
  const response = await fetch('/api/posts');
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
};

const PostsList: React.FC = () => {
  // 4. Use useQuery hook
  const { data: posts, isLoading, isError, error } = useQuery<Post[], Error>(
    ['posts'], // Unique query key
    fetchPosts
  );

  if (isLoading) return <div>Loading posts...</div>;
  if (isError) return <div>Error: {error?.message}</div>;

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

// Example for adding a post and invalidating the cache
const createPost = async (newPost: Partial<Post>): Promise<Post> => {
  const response = await fetch('/api/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newPost),
  });
  if (!response.ok) {
    throw new Error('Failed to create post');
  }
  return response.json();
};

const AddPostForm: React.FC = () => {
  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      // Invalidate and refetch the 'posts' query to show the new post
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  const handleSubmit = (event: React.FormEvent) => {
    event.preventDefault();
    mutation.mutate({ title: 'New Post Title', body: 'Lorem ipsum...' });
  };

  return (
    <form onSubmit={handleSubmit}>
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Adding...' : 'Add Post'}
      </button>
      {mutation.isError && <div>Error adding post: {mutation.error.message}</div>}
      {mutation.isSuccess && <div>Post added!</div>}
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

Suddenly, isLoading, isError, and data are handled for you. But that's just the tip of the iceberg.

What Most Tutorials Miss: The Stale-While-Revalidate Pattern

React Query's real power comes from its built-in implementation of the "stale-while-revalidate" caching strategy. Here’s what it means:

  1. Mount: When a component mounts and requests data, React Query fetches it.
  2. Cache: It stores the fetched data in a cache.
  3. Stale: By default, data becomes "stale" immediately after fetching (you can configure this with staleTime). This doesn't mean it's deleted; it just means React Query knows it might be out of date.
  4. Revalidate: If a component requests the same data again (e.g., on re-mount, window focus, or network reconnection), React Query immediately returns the cached (stale) data to keep your UI snappy. In the background, it then fetches fresh data. If the fresh data is different, it updates the cache and re-renders your component.

This pattern is a game-changer for perceived performance and user experience. Users get instant feedback, and your app feels incredibly fast because it's not always waiting for a network roundtrip. You get background refetching, automatic retries on failure, and robust caching policies with almost zero configuration.

Beyond the Basics: Practical Wisdom

  • Query Keys are Paramount: This is probably the most crucial concept. Your query keys (['posts'] in the example) are how React Query identifies and caches data. They must be unique and consistent. Think of them like primary keys for your data. A common pitfall is inconsistent key naming, leading to cache misses or unexpected behavior. Always use arrays, and for specific items, include their ID: ['posts', postId].
  • staleTime vs. cacheTime:
    • staleTime: How long data is considered "fresh" after a successful fetch. While fresh, requests for this query will not trigger a background refetch. Default is 0 (data is immediately stale).
    • cacheTime: How long inactive/unused query data stays in the cache before being garbage collected. Default is 5 minutes. If a query is inactive (no components are using it) but cacheTime hasn't expired, React Query will keep it, and reactivating it will serve the cached data immediately. Understand these two, and you master caching.
  • useMutation and onSuccess / onError: Mutations are for sending data to the server. onSuccess and onError callbacks are vital for interacting with the client-side cache, typically by queryClient.invalidateQueries to mark related data as stale, triggering a background refetch for components listening to that data.
  • Optimistic Updates: For an even smoother UX, useMutation allows for optimistic updates. You update the UI immediately before the server responds, assuming the mutation will succeed. If it fails, you roll back the UI. This is advanced but incredibly powerful for perceived speed.

Common Pitfalls and How to Avoid Them

  1. Mismanaging Query Keys: As mentioned, inconsistent keys lead to chaos. Establish a clear naming convention early on. If a query depends on variables, include them in the key: ['todos', { status: 'completed' }].
  2. Ignoring Error Boundaries: React Query throws errors from your queryFn up the component tree. Wrap components that use useQuery or useMutation in React Error Boundaries to gracefully handle network errors and present fallback UIs. Don't just rely on isError in every component.
  3. Over-fetching with select: Sometimes your API returns more data than a specific component needs. Instead of creating new, separate queries, use the select option in useQuery to transform or pick specific data from the cached result. This reduces unnecessary re-renders.
  4. refetchInterval for Everything: refetchInterval is great for dashboards or constantly updating data. But don't use it as a substitute for invalidateQueries after a mutation. invalidateQueries is reactive and efficient; refetchInterval is polling. Use the right tool for the job.

My "Why" and Your Next Steps

I started learning React Query today because I'm tired of reinventing the wheel for data fetching. I want to build features, not infrastructure. I want my apps to be fast and resilient by default. React Query offers that, not just as a library, but as a well-thought-out mental model for server state management.

If you're dealing with useEffect nightmares, inconsistent loading states, or a global store bloated with async logic, I strongly encourage you to explore React Query. Start by wrapping a single component's data fetching with useQuery. Feel the difference. You'll quickly see why it's become an indispensable tool in modern React development. It’s truly liberating.


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai


Top comments (0)