← Back to all posts

React Query v5 (TanStack Query): Why It's Still the Best Data Fetching Library

EhsanBy Ehsan
7 min read
ReactReact NativeTanStack QueryReact QueryData FetchingServer State ManagementCachingAPI CallsTypeScriptReact Hooks

Introduction

React Query v5 (now officially called TanStack Query) was released a couple weeks ago, and I've been upgrading my projects. If you haven't used React Query before, you're missing out on one of the best libraries in the React ecosystem.

For those already using it: v5 brings significant improvements with some breaking changes. The migration is straightforward, and the benefits are worth it.

Let me explain why React Query is essential and what's new in v5.

Why React Query?

Before React Query, data fetching in React was messy. You'd write the same boilerplate over and over using React Hooks:

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  fetch('/api/songs')
    .then(res => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

Now multiply that by every component that fetches data. Add caching, refetching, pagination, and optimistic updates. It becomes a nightmare fast.

React Query solves this:

const { data, isLoading, error } = useQuery({
  queryKey: ['songs'],
  queryFn: fetchSongs
});

That's it. Caching, background refetching, error handling, and loading states—all handled automatically.

What Makes React Query Great

Automatic caching - Fetch data once, use it everywhere. No manual cache management.

Background refetching - Data stays fresh automatically. Refetches when the window regains focus or network reconnects.

Request deduplication - Multiple components requesting the same data? React Query makes one request and shares the result.

Pagination and infinite scroll - Built-in support with useInfiniteQuery. No manual page tracking.

Optimistic updates - Update UI immediately, roll back if the request fails. Makes apps feel instant.

DevTools - See all your queries, their status, and cached data in a beautiful dev panel.

Zero config, fully customizable - Works out of the box, but every behavior is configurable.

What's New in v5

1. Cleaner API with Single Object Signature

The old way with multiple signatures is gone. Everything uses a single object format now:

Before (v4):

useQuery(['songs'], fetchSongs, { staleTime: 5000 });

After (v5):

useQuery({
  queryKey: ['songs'],
  queryFn: fetchSongs,
  staleTime: 5000
});

More consistent, easier to read, better TypeScript support.

2. Built-in Suspense Support

v5 adds dedicated Suspense hooks:

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

function PlaylistDetails({ id }) {
  const { data } = useSuspenseQuery({
    queryKey: ['playlist', id],
    queryFn: () => fetchPlaylist(id)
  });

  // data is guaranteed to be defined
  // no need to check for loading states
  return (
    <View>
      <Text>{data.name}</Text>
      <Text>{data.tracks.length} songs</Text>
    </View>
  );
}

The data is guaranteed to be defined. No more data?.name everywhere. Wrap it in a Suspense boundary and you're done.

3. Better Status Names

The old isLoading was confusing. It meant "first load" but the name suggested "any loading."

v5 renames things clearly:

  • status: 'loading'status: 'pending'
  • isLoadingisPending
  • New isLoading now means "pending AND fetching"

Makes the intent clearer.

4. Improved TypeScript Support

Errors now default to Error instead of unknown:

Before (v4):

const { error } = useQuery(['songs'], fetchSongs);
// error is unknown, need to cast it

After (v5):

const { error } = useQuery({
  queryKey: ['songs'],
  queryFn: fetchSongs
});
// error is Error, can use error.message directly

TypeScript 4.7 is now the minimum version, bringing better inference and type safety.

5. Simplified Optimistic Updates

Mutations now return variables, making optimistic updates cleaner:

const queryClient = useQueryClient();

const addSongMutation = useMutation({
  mutationFn: addSong,
  onMutate: async (newSong) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['songs'] });

    // Snapshot current value
    const previous = queryClient.getQueryData(['songs']);

    // Optimistically update
    queryClient.setQueryData(['songs'], (old) => [...old, newSong]);

    return { previous };
  },
  onError: (err, newSong, context) => {
    // Rollback on error
    queryClient.setQueryData(['songs'], context.previous);
  }
});

The returned variables from the mutation make this pattern even easier in v5.

6. Better Infinite Query Control

New maxPages option limits how many pages are stored:

const { data, fetchNextPage } = useInfiniteQuery({
  queryKey: ['songs'],
  queryFn: fetchSongs,
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  maxPages: 3 // Only keep last 3 pages in memory
});

Great for memory management in apps with long infinite lists.

7. gcTime Instead of cacheTime

cacheTime is now gcTime (garbage collection time). Same functionality, clearer name:

useQuery({
  queryKey: ['songs'],
  queryFn: fetchSongs,
  gcTime: 1000 * 60 * 5 // Keep unused data for 5 minutes
});

Here's a complete example with queries and mutations:

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

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

  // Fetch playlists
  const { data: playlists, isPending } = useQuery({
    queryKey: ['playlists'],
    queryFn: fetchPlaylists
  });

  // Add playlist mutation
  const addPlaylist = useMutation({
    mutationFn: createPlaylist,
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['playlists'] });
    }
  });

  // Delete playlist mutation
  const deletePlaylist = useMutation({
    mutationFn: removePlaylist,
    onMutate: async (playlistId) => {
      // Optimistic update
      await queryClient.cancelQueries({ queryKey: ['playlists'] });
      const previous = queryClient.getQueryData(['playlists']);

      queryClient.setQueryData(['playlists'], (old) =>
        old.filter((p) => p.id !== playlistId)
      );

      return { previous };
    },
    onError: (err, playlistId, context) => {
      // Rollback on error
      queryClient.setQueryData(['playlists'], context.previous);
    }
  });

  if (isPending) {
    return <LoadingSpinner />;
  }

  return (
    <View>
      {playlists.map((playlist) => (
        <PlaylistCard
          key={playlist.id}
          playlist={playlist}
          onDelete={() => deletePlaylist.mutate(playlist.id)}
        />
      ))}
      <Button
        title="Add Playlist"
        onPress={() => addPlaylist.mutate({ name: 'New Playlist' })}
      />
    </View>
  );
}

Clean, readable, and all the complex caching logic is handled.

Migration Tips

Upgrading from v4 to v5? Here's what to focus on:

Update query signatures - Convert all queries to object format. Find and replace makes this easy.

Rename status flags - Change isLoading to isPending where needed.

Update cacheTime to gcTime - Simple rename.

Remove query callbacks - Move onSuccess, onError, onSettled from queries to mutations or side effects.

Add initialPageParam - All infinite queries need this now.

The React Query team provides a codemod to automate most of these changes:

npx @tanstack/query-codemods v5/replace-import-specifier

Why I Still Use React Query for Everything

Among all the methods I've used—Axios, custom hooks with fetch, Apollo—React Query beats them all for most use cases:

Less code - Dramatically less boilerplate than any alternative.

Better UX - Automatic background refetching keeps data fresh without user action.

Works everywhere - REST APIs, GraphQL, anything that returns a Promise.

Great DevTools - Debug data fetching visually.

React Native support - Works perfectly in mobile apps.

Active development - Regular updates, responsive maintainers.

In my opinion, for any React or React Native app, React Query is the right choice for data fetching.

When You Might Not Need It

React Query is overkill if:

You have very simple data needs - A couple of static API calls might not justify the dependency.

You're using GraphQL with Apollo - Apollo has its own excellent caching system.

You need Redux anyway - If you're already using Redux for complex state, RTK Query integrates nicely.

You only need client state management - For purely client-side state, consider Jotai which is lighter and more focused. React Query excels at server state, while Jotai handles client state beautifully.

Setup

Install it:

npm install @tanstack/react-query

Wrap your app:

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

const queryClient = new QueryClient();

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

Add DevTools in development:

npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

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

That's it. Start using useQuery and useMutation.

Final Thoughts

React Query v5 makes the best data fetching library even better. The API is cleaner, TypeScript support is stronger, and Suspense integration is built-in.

If you're not using React Query yet, try it on your next project. If you're on v4, the v5 upgrade is worth it.

Server state and client state are different. React Query handles server state better than anything else. Stop fighting with useEffect and manual cache management. Let React Query do the heavy lifting.

Your code will be cleaner, your app will feel faster, and you'll wonder how you ever built React apps without it.

Resources