Back

Using TanStack Query for Smarter Data Fetching in React

Using TanStack Query for Smarter Data Fetching in React

If you’ve built React apps that talk to APIs, you know the pattern: useEffect for the fetch, useState for the data, another useState for loading, maybe one more for errors. Before you know it, you’re managing a tangled mess of state just to display a list of users.

This manual approach works, but it’s fragile. What happens when the user navigates away and comes back? Do you refetch? Use stale data? What about retries when the network fails? These aren’t edge cases—they’re the reality of production apps.

TanStack Query (formerly React Query) solves these problems by treating server state differently from client state. Instead of imperatively fetching data, you declare what you need and let the library handle caching, synchronization, and updates. This article shows you how to move from manual data fetching to a smarter, declarative approach that scales.

Key Takeaways

  • Replace manual useEffect + useState patterns with declarative useQuery hooks for cleaner, more maintainable code
  • Leverage automatic caching, background refetching, and request deduplication to improve user experience
  • Use mutations with optimistic updates to create responsive UIs that feel instant
  • Implement proper query invalidation strategies to keep data synchronized across your application
  • Avoid common pitfalls like using TanStack Query for local state or forgetting dynamic parameters in query keys

The Problem with Manual Data Fetching

Here’s a typical React component fetching data:

import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    
    async function fetchUsers() {
      setIsLoading(true);
      setError(null);
      
      try {
        const response = await fetch('/api/users');
        if (!response.ok) throw new Error('Failed to fetch');
        
        const data = await response.json();
        if (!cancelled) {
          setUsers(data);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setIsLoading(false);
        }
      }
    }
    
    fetchUsers();
    
    return () => { cancelled = true };
  }, []);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

This works, but look at what we’re managing:

  • Three separate pieces of state
  • Cleanup logic to prevent state updates on unmounted components
  • No caching—every mount triggers a new fetch
  • No retry logic for failed requests
  • No way to know if our data is stale

Now multiply this complexity across dozens of components. That’s where TanStack Query comes in.

How TanStack Query Makes Data Fetching Smarter

TanStack Query treats server state as a first-class citizen. Instead of manually orchestrating fetches, you describe your data requirements declaratively:

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

function UserList() {
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(res => {
      if (!res.ok) throw new Error('Failed to fetch');
      return res.json();
    }),
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <ul>
      {users?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Same functionality, but notice what’s missing:

  • No useState for data, loading, or errors
  • No useEffect or cleanup logic
  • No manual state synchronization

But here’s what you gain:

  • Automatic caching: Navigate away and back, see instant results
  • Background refetching: Stale data updates silently
  • Request deduplication: Multiple components can share the same query
  • Built-in retry logic: Failed requests retry automatically
  • Optimistic updates: UI updates before server confirmation

Core Concepts for Smarter Data Management

Queries: Reading Server State

Queries fetch and cache data. The useQuery hook accepts a configuration object with two essential properties:

const { data, isLoading, error } = useQuery({
  queryKey: ['todos', userId, { status: 'active' }],
  queryFn: () => fetchTodosByUser(userId, { status: 'active' }),
  staleTime: 5 * 60 * 1000, // 5 minutes
  gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
});

Query Keys uniquely identify each query. They’re arrays that can include:

  • Static identifiers: ['users']
  • Dynamic parameters: ['user', userId]
  • Complex filters: ['todos', { status, page }]

When any part of the query key changes, TanStack Query knows to fetch fresh data.

Mutations: Changing Server State

While queries read data, mutations modify it:

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

function CreateTodo() {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: (newTodo) => 
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
        headers: { 'Content-Type': 'application/json' },
      }).then(res => {
        if (!res.ok) throw new Error('Failed to create todo');
        return res.json();
      }),
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return (
    <button 
      onClick={() => mutation.mutate({ title: 'New Todo' })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? 'Creating...' : 'Create Todo'}
    </button>
  );
}

Mutations handle the complete lifecycle: loading states, error handling, and success callbacks. The onSuccess callback is perfect for updating your cache after changes.

Query Invalidation: Keeping Data Fresh

Invalidation marks queries as stale, triggering background refetches:

// Invalidate everything
queryClient.invalidateQueries();

// Invalidate specific queries
queryClient.invalidateQueries({ queryKey: ['todos'] });

// Invalidate with exact matching
queryClient.invalidateQueries({ 
  queryKey: ['todo', 5], 
  exact: true 
});

This is how TanStack Query keeps your UI in sync after mutations. Change a todo? Invalidate the todos list. Update a user? Invalidate that user’s data.

Setting Up TanStack Query in Your React App

First, install the package:

npm install @tanstack/react-query

Then wrap your app with the QueryClient provider:

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minute
      gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
      retry: 3,
      retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}

The QueryClient manages all caching and synchronization. You typically create one instance for your entire app.

Advanced Patterns for Production Apps

Optimistic Updates

Update the UI immediately, then sync with the server:

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    
    // Snapshot previous value
    const previousTodos = queryClient.getQueryData(['todos']);
    
    // Optimistically update
    queryClient.setQueryData(['todos'], old => 
      old ? [...old, newTodo] : [newTodo]
    );
    
    // Return context for rollback
    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    // Rollback on error
    if (context?.previousTodos) {
      queryClient.setQueryData(['todos'], context.previousTodos);
    }
  },
  onSettled: () => {
    // Always refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Dependent Queries

Chain queries that depend on each other:

const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: () => getUserByEmail(email),
});

const { data: projects } = useQuery({
  queryKey: ['projects', user?.id],
  queryFn: () => getProjectsByUser(user.id),
  enabled: !!user?.id, // Only run when user ID exists
});

Infinite Queries

Handle paginated data with infinite scrolling:

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

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
} = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: ({ pageParam = 0 }) => fetchProjects({ page: pageParam }),
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  initialPageParam: 0,
});

Common Pitfalls and Solutions

Forgetting the QueryClientProvider

If your queries return undefined or don’t work at all, check that your app is wrapped with QueryClientProvider. This is the most common setup mistake.

Using TanStack Query for Local State

TanStack Query is for server state. Don’t use it for form inputs, UI toggles, or other client-only state. Stick with useState or useReducer for those.

Static Query Keys with Dynamic Data

Always include dynamic parameters in your query keys:

// ❌ Wrong - will show same data for all users
useQuery({
  queryKey: ['user'],
  queryFn: () => fetchUser(userId),
});

// ✅ Correct - unique cache entry per user
useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
});

Conclusion

TanStack Query transforms React data fetching from a manual, error-prone process into a declarative, robust system. By treating server state as fundamentally different from client state, it eliminates boilerplate while adding powerful features like caching, synchronization, and optimistic updates.

The mental shift is simple: stop thinking about how to fetch data and start thinking about what data you need. Declare your requirements through query keys and functions, then let TanStack Query handle the complexity of keeping everything in sync.

Start small—replace one useEffect fetch with useQuery. Once you see the instant loading states, automatic retries, and seamless caching in action, you’ll understand why TanStack Query has become essential for modern React applications.

FAQs

They're the same library. React Query was renamed to TanStack Query to reflect that it now supports multiple frameworks beyond React. The React-specific package is still maintained and uses the same API you're familiar with.

Yes, but you probably don't need to for server state. TanStack Query handles caching and sharing data between components automatically. Use Redux or Context for true client state like user preferences or UI state, and TanStack Query for anything that comes from an API.

By default, queries refetch on window focus, reconnect, and mount. You control freshness with staleTime (how long data stays fresh) and gcTime (how long to keep unused data). Stale queries refetch in the background while showing cached data instantly.

Invalidation marks queries as stale but doesn't immediately refetch—it waits for the query to be used again. Direct refetching triggers an immediate network request. Use invalidation after mutations for better performance, as it only refetches queries that are actually being displayed.

TanStack Query excels at near real-time updates through polling and refetch intervals. For true real-time needs, combine it with WebSockets: use the socket for live updates and TanStack Query for initial data loading and fallback when the socket disconnects.

Yes. TanStack Query 5+ is fully compatible with React 19. The APIs used here (queries, mutations, invalidation) are unchanged, so the examples in this article will work without modification.

Listen to your bugs 🧘, with OpenReplay

See how users use your app and resolve issues fast.
Loved by thousands of developers