f in x
React Query — Data Fetching Caching and Server State for React Apps That Never Lose Data
> cd .. / HUB_EDITORIALE
Sviluppo di siti web

React Query — Data Fetching Caching and Server State for React Apps That Never Lose Data

[2026-07-12] Author: Ing. Calogero Bono
Zenithby Meteora Web The operating system for your business. Social, clients, bookings and invoices in one platform. Gyms, barbers, professionals. Discover Zenith Free demo · no card

Every time you write a useEffect to fetch data, you're building a house of cards. Loading states, error handling, manual cache, refetch logic, pagination — all handcrafted, with countless useState and code that balloons like a monthly invoice. We see it every day at Meteora Web: beautiful React UIs with fragile data fetching. React Query (now TanStack Query) solves this in a few lines of code. It's not just another library — it's the right way to manage server state.

What is React Query and why should you stop writing useEffect for data fetching?

React Query is a library for managing asynchronous server state. Fancy definition, but in practice it means one thing: you no longer have to worry about caching, loading, errors, or synchronization. The library handles it, and better than you could manually.

The problem with useEffect + fetch: Every time a component mounts, you start from scratch. Data you already loaded a minute ago? Loaded again. User navigates back? Another request. Multiple components using the same endpoint? Duplication. Result: stressed servers, slow UX, bloated code.

React Query's solution: A single point of access for each query key. Smart caching, automatic background refetch, stale-while-revalidate, global error handling, native pagination. And crucially — a clear separation between server state and client state. Data from the backend lives in React Query; local data (filters, modals, forms) stays in useState or useReducer. No mixing.

Example — the difference is immediate:

// Old way: useEffect + useState
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => { setUser(data); setLoading(false); })
      .catch(err => { setError(err); setLoading(false); });
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <div>{user.name}</div>;
}
// With React Query
import { useQuery } from '@tanstack/react-query';

function UserProfile({ 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 <div>{user.name}</div>;
}

Fifteen lines became eight. And you get automatic caching, refetch on window focus, retry on error, and server-state synchronization. It's not magic — it's good engineering.

Sponsored Protocol

Get started now: Install the library with npm i @tanstack/react-query, wrap your app with QueryClientProvider, and start replacing your useEffect hooks with useQuery. In 90% of cases it works on the first try.

How does React Query caching work and why does it save server calls?

React Query doesn't just memoize results — it implements a multi-level cache that decides when to show stale data and when to fetch fresh. The key concepts are staleTime and gcTime (formerly called cacheTime).

  • staleTime: The time during which data is considered fresh. If fresh, React Query returns the cache without any network request. Default is 0, so every new subscription triggers a refetch. Setting it to a positive value (e.g., 30 seconds for semi-stable data) dramatically reduces server calls.
  • gcTime: How long to keep data in memory after no component is observing it. Default is 5 minutes. If the user navigates away and returns within that time, the data is still available — no request needed.

Real-world example: an e-commerce client we work with has a product catalog that changes a few times a day. We set staleTime to 5 minutes. Result: 80% of shop visits make zero backend requests for products. The server breathes, users see pages instantly because data is already warm.

// Custom config for a product catalog
const { data: products } = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 5 * 60 * 1000, // 5 minutes
  gcTime: 10 * 60 * 1000    // keep in cache 10 min after last use
});

What to do now: Analyze your data. How often does it change? Here's a rule of thumb:

Sponsored Protocol

  • Nearly static (categories, country lists, settings): staleTime = Infinity, gcTime = Infinity (or very high).
  • Semi-stable (products, blog posts): staleTime = 30s–5min.
  • Real-time (notifications, messages): staleTime = 0 (default).

How to handle mutations (POST, PUT, DELETE) with React Query without losing cache?

The tricky part is when you write data. A form submitting an order, a profile edit, a deletion. With useEffect+fetch the standard approach: make the call, then reload everything. With React Query you use useMutation and either invalidate related queries or update the cache optimistically.

Automatic query invalidation

After a mutation, you tell React Query: “the data for this key is no longer valid, reload it”. The next component that uses that query will do a background refetch.

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddProductForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newProduct) => fetch('/api/products', {
      method: 'POST',
      body: JSON.stringify(newProduct)
    }),
    onSuccess: () => {
      // Invalidate the product list to force a refetch
      queryClient.invalidateQueries({ queryKey: ['products'] });
    }
  });

  return <form onSubmit={/* call mutation.mutate */}>...</form>;
}

Optimistic updates: When UX is critical (like, follow, comments), you can update the cache immediately before the server responds. If the request fails, React Query reverts to the previous state.

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // Cancel any outgoing 'todos' queries
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    // Snapshot current data for rollback
    const previousTodos = queryClient.getQueryData(['todos']);
    // Optimistically update
    queryClient.setQueryData(['todos'], old => [...old, newTodo]);
    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    // Rollback
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    // On success or error, invalidate to sync with server
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  }
});

Caution: Use optimistic updates only for reversible actions with reliable APIs. For payments and orders, prefer invalidation with a temporary loading state.

Sponsored Protocol

What to do now: For every mutation in your project, ask: “Does it need immediate feedback, or can I wait for the refetch?”. If immediate, implement an optimistic update. Otherwise, a simple invalidation is enough.

What are advanced strategies for prefetching and paginated queries?

React Query handles pagination, infinite scroll, and native prefetching out of the box. For a paginated list (e.g., orders, filtered products), use useQuery with a key that includes the page number. But the real power comes with useInfiniteQuery for infinite scroll.

Classic pagination

function ProductList({ page }) {
  const { data, isLoading, isPreviousData } = useQuery({
    queryKey: ['products', page],
    queryFn: () => fetch(`/api/products?page=${page}`).then(res => res.json()),
    keepPreviousData: true, // Show previous page data while loading new one
  });

  // ... render with 'Previous'/'Next' buttons
}

Prefetching: Preload the next page while the user is still reading the current one, making navigation instantaneous.

const queryClient = useQueryClient();

// When user hovers over 'Next' button
const prefetchNextPage = () => {
  queryClient.prefetchQuery({
    queryKey: ['products', page + 1],
    queryFn: () => fetch(`/api/products?page=${page + 1}`).then(res => res.json())
  });
}

Infinite scroll with useInfiniteQuery

import { useInfiniteQuery } from '@tanstack/react-query';

function InfiniteComments() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery({
    queryKey: ['comments'],
    queryFn: ({ pageParam = 1 }) => fetch(`/api/comments?page=${pageParam}`).then(res => res.json()),
    getNextPageParam: (lastPage, allPages) => {
      // Return undefined if no more pages
      return lastPage.nextPage ?? undefined;
    }
  });

  const comments = data?.pages.flatMap(page => page.items) || [];

  return (
    <div>
      {comments.map(comment => <CommentItem key={comment.id} {...comment} />)}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load more'}
        </button>
      )}
    </div>
  );
}

What to do now: If your project has paginated lists, replace your manual pagination with useQuery + prefetch. For infinite scroll, switch to useInfiniteQuery — it automatically manages page storage and fetching the next one.

Sponsored Protocol

How to integrate React Query with other tools (React Router, state management) without conflicts?

One of the most common questions we get at Meteora Web: “Does React Query replace Redux? Can I use it with Zustand?”. The answer is no conflict at all — in fact, React Query integrates perfectly with any state management library because it handles a different domain: server data. The secret is to keep the two types of state separate.

  • Server state (data from API): React Query only. Never duplicate in Redux or Zustand.
  • Client state (UI, forms, filters, modals): use useState, useReducer, Zustand, Jotai, etc.

Practical example: A filter input selects a category. The category is client state (useState), but the filtered products are server state (useQuery with queryKey that includes the category as parameter).

function ShopPage() {
  const [category, setCategory] = useState('all'); // client state

  const { data: products } = useQuery({
    queryKey: ['products', category], // server state
    queryFn: () => fetch(`/api/products?category=${category}`).then(res => res.json())
  });

  return (
    <div>
      <select value={category} onChange={e => setCategory(e.target.value)}>
        <option value="all">All</option>
        <option value="electronics">Electronics</option>
      </select>
      {products?.map(p => <ProductCard key={p.id} {...p} />)}
    </div>
  );
}

With React Router: If query parameters come from the URL (e.g., /shop?category=electronics), use useSearchParams from React Router to read/write parameters and pass them as part of the queryKey. React Query will automatically react to parameter changes.

Sponsored Protocol

What to do now: Audit your project. If you have duplicated data between React Query and a store, remove the copy. If components read from the server via useEffect and write to Redux, move everything to useQuery. You'll halve the code.

What are common React Query mistakes and how to avoid them?

After seeing dozens of implementations, here are the most frequent errors:

  1. QueryKey not specific enough: Using ['user'] for a specific user means two pages with different IDs will share the same cache — wrong data. Fix: Include every parameter that affects the result in the queryKey.
  2. Forgetting to invalidate after a mutation: User adds a product, but the list doesn't update. Fix: Call queryClient.invalidateQueries in the onSuccess of the mutation.
  3. StaleTime = Infinity without adequate gcTime: If data never becomes stale but the cache is cleared after 5 minutes, the user sees an unexpected loading spinner. Fix: If you use a high staleTime, also raise gcTime.
  4. Not showing background refetch indicators: React Query shows old data while fetching new one, but without isFetching the user doesn't know an update is happening. Fix: Use isFetching to show a small update indicator (not a full-page loader).
  5. Mixing mutations and queries without separation: Creating too many custom hooks that combine read and write can lead to confusion. Fix: Keep useQuery for reads and useMutation for writes separate. Only merge in very simple cases.

What to do now

  1. Install React Query in your project: npm install @tanstack/react-query
  2. Set up the provider at the top of your component tree (App.tsx or Layout).
  3. Replace one useEffect/fetch with useQuery. Start with a simple endpoint — products, users, articles.
  4. Add a mutation for a form and invalidate the related query.
  5. Review staleTime and gcTime based on how often your data updates.
  6. Read the official TanStack Query documentation to dive deeper into pagination and infinite queries — the time invested pays off in hours of saved debugging.
Ing. Calogero Bono

> AUTHOR_EXTRACTED

Ing. Calogero Bono

Ingegnere informatico, fondatore di Meteora Web e Zenith OS. System administrator e progettista di piattaforme, app e CMS proprietari, con esperienza in sviluppo full-stack, marketing digitale ed ecosistema Google.
[ Read Full Dossier ]

> METEORA_WEB // DIGITAL AGENCY

We build the digital presence your business deserves.

Websites, social media, online advertising, e-commerce and high-performance hosting, engineered with method by computer engineers in Sciacca, for all of Italy.

> MW_JOURNAL

> READ_ALL()