Skip To Main Content
tanstack query

Using TanStack Query for Data Fetching & Caching in React

TL;DR: TanStack Query is a data synchronization library that automatically manages the entire lifecycle of server data, from the initial fetch to keeping it fresh over time. Learn with code examples and patterns.

Most developers spend way too long writing data fetching code from scratch. The usual approach, useEffect with a dependency array, a few useState hooks for loading and errors, maybe some manual caching. It works, but it's a lot of code to maintain.

​​After using TanStack Query across multiple projects for a while now, the patterns that actually matter start to become clear. It handles a lot of the annoying stuff automatically.

Here's what actually matters when using it in production.

What Problem Does This Actually Solve?

Before jumping into the technical details, it's worth understanding what TanStack Query is actually for and when you'd want to use it.

The core problem: Every app that fetches data from an API needs to handle loading states, errors, caching, refetching when data gets stale, and keeping multiple parts of the UI in sync. Building this from scratch means writing hundreds of lines of boilerplate code for every project, and maintaining it over time as requirements change.

What TanStack Query does: It's a data synchronization library that automatically manages the entire lifecycle of server data, from the initial fetch to keeping it fresh over time. Think of it as a smart caching layer between your UI and your API.

When to use it:

  • Apps that make frequent API calls (dashboards, admin panels, social feeds)

  • Projects where data needs to stay fresh without constant page reloads

  • Teams that want to standardize how data fetching works across the codebase

  • Applications where performance and user experience matter

When you might not need it:

  • Static sites or apps with minimal API interaction

  • Projects where data is fetched once at load time and never changes

  • Very simple CRUD apps where custom useEffect hooks are sufficient

The business case: Faster development time (less boilerplate code), better user experience (instant navigation with cached data), and fewer bugs (standardized error handling and loading states). For a typical dashboard application, using TanStack Query can reduce data-fetching code by 60-70% compared to custom solutions.

Why It's Different

The main thing to understand is that TanStack Query treats server data differently than UI state. Local state, like whether a modal is open, that's predictable. Server data is always potentially out of date the moment you fetch it.

Instead of pretending cached responses are current, TanStack Query assumes they might be stale and handles keeping them fresh in the background. That shift in thinking makes more sense once you start using it.

Getting Started

The setup is pretty straightforward. Wrap your app with QueryClientProvider and configure some defaults:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      retry: 1,
      refetchOnWindowFocus: false,
    },
  },
});

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

In this setup, refetchOnWindowFocus is disabled because the app uses websockets for real-time updates. Without that, you'd probably want to keep it on. The retry count is set to 1 instead of the default 3 to avoid excessive retries in flaky environments.

These settings depend on your app, so adjust them as needed.

Here's a basic query:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error('Failed to fetch user');
      return res.json();
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Something went wrong: {error.message}</div>;

  return <h1>{data.name}</h1>;
}

The queryKey does more than it looks like. It's the cache identifier, and if two components request the same key at the same time, TanStack Query only makes one network request.

How Caching Works

The caching behavior is pretty useful once you understand it.

When you fetch data and navigate away, that data doesn't disappear immediately. It stays in the cache (default is 5 minutes). Coming back to that page shows the cached data right away while TanStack Query refetches fresh data in the background.

This makes apps feel faster because users aren't waiting for data that was already loaded recently.

Two settings control this:

  • staleTime: How long before the data is considered outdated

  • cacheTime: How long to keep data in cache after it's not being used

Short staleTime values (like 30 seconds) work well for data that changes frequently. Longer values (10+ minutes) make sense for stuff like user profiles that rarely change.

Updating Data

Mutations require more careful thought:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function useUpdateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (userData) => {
      const res = await fetch(`/api/users/${userData.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData),
      });
      if (!res.ok) throw new Error('Update failed');
      return res.json();
    },
    onSuccess: (updatedUser) => {
      queryClient.invalidateQueries({ queryKey: ['user', updatedUser.id] });
    },
  });
}

When you invalidate a query, TanStack Query refetches it automatically. You can also update the cache directly with setQueryData for instant UI updates without a refetch, but that means keeping your cache structure in sync with API responses.

For most cases, invalidating is simpler and works fine.

For UI updates before the server responds, optimistic updates work well:

1
2
3
4
5
6
7
8
9
10
11
onMutate: async (newUser) => {
  await queryClient.cancelQueries({ queryKey: ['user', newUser.id] });
  
  const previousUser = queryClient.getQueryData(['user', newUser.id]);
  queryClient.setQueryData(['user', newUser.id], newUser);
  
  return { previousUser };
},
onError: (err, newUser, context) => {
  queryClient.setQueryData(['user', newUser.id], context.previousUser);
},

The UI updates immediately, and if the request fails, it rolls back to the previous state. This works well for things like toggling favorites or updating profile info.

How to Organize Things

After working with it across projects, this structure has proven effective:

1
2
3
4
5
6
src/
  api/
    client.js
    queries/
      useUsers.js
      usePosts.js

In useUsers.js, define query keys and hooks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const userKeys = {
  all: ['users'],
  lists: () => [...userKeys.all, 'list'],
  list: (filters) => [...userKeys.lists(), filters],
  details: () => [...userKeys.all, 'detail'],
  detail: (id) => [...userKeys.details(), id],
};

export function useUser(id) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
  });
}

export function useUsers(filters) {
  return useQuery({
    queryKey: userKeys.list(filters),
    queryFn: () => fetchUsers(filters),
  });
}

The hierarchical keys make it easy to invalidate related queries. To refresh all user-related data, just invalidate userKeys.all.

TanStack Query vs SWR

Both libraries are solid choices. SWR is simpler and lighter, and it works really well with Next.js.

TanStack Query gets chosen for:

  • More detailed DevTools

  • Better control over retry logic and caching

  • Framework-agnostic (works with Vue, Svelte, etc.)

  • More intuitive mutation API

For straightforward Next.js apps, SWR is a perfectly good choice. Check out their docs to compare.

Things Worth Knowing

The DevTools are essential. Install @tanstack/react-query-devtools right away. Being able to see what queries are active, what's in the cache, and what's being refetched saves a lot of debugging time.

Prefetching works well. You can prefetch data when users hover over links:

1
2
3
4
5
6
7
8
9
10
11
<Link 
  to={`/users/${user.id}`}
  onMouseEnter={() => {
    queryClient.prefetchQuery({
      queryKey: ['user', user.id],
      queryFn: () => fetchUser(user.id),
    });
  }}
>
  View Profile
</Link>

By the time they click, the data's already loaded.

Dependent queries are straightforward:

1
2
3
4
5
6
const { data: user } = useUser(userId);
const { data: posts } = useQuery({
  queryKey: ['posts', user?.id],
  queryFn: () => fetchUserPosts(user.id),
  enabled: !!user,
});

The second query won't run until the first one completes. No complicated loading states to manage.

Performance

The library adds about 40KB (gzipped) to your bundle. Initial load time goes up by around 15-20ms. After that, the caching usually makes things faster overall because you're not constantly hitting your API.

The performance docs have more detailed benchmarks.

What Changes in Practice

What teams usually see:

  • Less lines of custom data fetching code

  • Loading and error states become more consistent

  • Cache invalidation becomes more predictable

  • New team members pick it up relatively quickly

There's definitely a learning curve. Query keys get messed up sometimes, cache invalidation gets forgotten, or data doesn't refetch when expected. But it's less work than maintaining a custom data fetching solution.

TanStack Query isn't perfect. The docs can be dense in places, and it takes time to get comfortable with the patterns. But once you do, dealing with server data becomes a lot less tedious.

Worth trying out if you're tired of writing the same data fetching code over and over.

Useful Resources:

Contact Us

Feel free to reach out.