Skip to content

Mastering React Query: Data Fetching Made Simple

Learn how to use React Query (TanStack Query) for efficient data fetching, caching, and synchronization in React applications.

By 7 min read
  • React
  • React Query
  • Data Fetching
  • TypeScript
  • API
Mastering React Query: Data Fetching Made Simple

React Query (now TanStack Query) revolutionizes how we handle server state in React applications. It provides powerful data fetching, caching, synchronization, and updating capabilities with minimal boilerplate. After implementing it in production applications like Unique Sports Group, I can confidently say it's a game-changer for data management.

Why React Query?

Traditional data fetching in React has several pain points:

  • Manual loading states
  • Error handling boilerplate
  • Cache management complexity
  • Refetching logic
  • Optimistic updates complexity

React Query solves all of these elegantly.

Basic Setup

Install React Query:

Shell
npm install @tanstack/react-query

Wrap your app with QueryClientProvider:

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
    },
  },
});

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

Basic Data Fetching

The useQuery hook handles data fetching:

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

interface User {
  id: string;
  name: string;
}

interface UserProfileProps {
  userId: string;
}

function UserProfile({ userId }: UserProfileProps) {
  const { data, isLoading, error } = useQuery<User>({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
  });

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

  return <div>{data?.name}</div>;
}

Query Keys

Query keys uniquely identify queries and enable caching:

TSX
// Simple key
queryKey: ['users']

// Parameterized key
queryKey: ['user', userId]

// Complex key
queryKey: ['users', { filters: { status: 'active', page: 1 } }]

Data Mutations

Use useMutation for create, update, and delete operations:

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

interface NewUser {
  name: string;
}

interface User {
  id: string;
  name: string;
}

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

  const mutation = useMutation<User, Error, NewUser>({
    mutationFn: (newUser) => 
      fetch('/api/users', {
        method: 'POST',
        body: JSON.stringify(newUser),
      }).then(res => res.json()),
    onSuccess: () => {
      // Invalidate and refetch users list
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  return (
    <button
      onClick={() => mutation.mutate({ name: 'John' })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? 'Creating...' : 'Create User'}
    </button>
  );
}

Optimistic Updates

Update UI immediately before server confirms:

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

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

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

  const mutation = useMutation<Todo, Error, Todo>({
    mutationFn: updateTodo,
    onMutate: async (newTodo) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // Snapshot previous value
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // Optimistically update
      queryClient.setQueryData<Todo[]>(['todos'], (old) => 
        old?.map(todo => todo.id === newTodo.id ? newTodo : todo) || []
      );

      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      // Rollback on error
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos);
      }
    },
    onSettled: () => {
      // Refetch after error or success
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return mutation;
}

Dependent Queries

Fetch data based on previous query results:

TSX
interface User {
  id: string;
}

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

interface UserPostsProps {
  userId: string;
}

function UserPosts({ userId }: UserPostsProps) {
  const { data: user } = useQuery<User>({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  const { data: posts } = useQuery<Post[]>({
    queryKey: ['posts', userId],
    queryFn: () => fetchUserPosts(userId),
    enabled: !!user, // Only fetch when user exists
  });

  return <div>{/* Render posts */}</div>;
}

Infinite Queries

Handle paginated data with infinite scrolling:

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

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

interface PostsResponse {
  posts: Post[];
  hasNextPage: boolean;
}

function InfinitePosts() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery<PostsResponse>({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam as number),
    getNextPageParam: (lastPage, pages) => 
      lastPage.hasNextPage ? pages.length + 1 : undefined,
    initialPageParam: 1,
  });

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}
      {hasNextPage && (
        <button 
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Custom Hooks

Create reusable query hooks:

TSX
// hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query';

interface User {
  id: string;
  name: string;
}

interface UserFilters {
  status?: string;
}

export function useUsers(filters: UserFilters) {
  return useQuery<User[]>({
    queryKey: ['users', filters],
    queryFn: () => fetchUsers(filters),
    staleTime: 1000 * 60 * 5,
  });
}

export function useUser(userId: string | undefined) {
  return useQuery<User>({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId!),
    enabled: !!userId,
  });
}

// Usage
function UsersList() {
  const { data: users } = useUsers({ status: 'active' });
  return <div>{/* Render users */}</div>;
}

Background Refetching

React Query automatically refetches stale data:

TSX
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: true, // Refetch when window regains focus
      refetchOnReconnect: true, // Refetch when network reconnects
      refetchInterval: 1000 * 60, // Poll every minute
    },
  },
});

Error Handling

Handle errors gracefully:

TSX
interface User {
  id: string;
  name: string;
}

interface UserProfileProps {
  userId: string;
}

function UserProfile({ userId }: UserProfileProps) {
  const { data, error, isError } = useQuery<User>({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    retry: 3, // Retry 3 times on failure
    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
  });

  if (isError) {
    return <ErrorBoundary error={error} />;
  }

  return <div>{/* Render user */}</div>;
}

Prefetching

Prefetch data before it's needed:

TSX
import { useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';

interface UserLinkProps {
  userId: string;
}

function UserLink({ userId }: UserLinkProps) {
  const queryClient = useQueryClient();

  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: ['user', userId],
      queryFn: () => fetchUser(userId),
    });
  };

  return (
    <Link 
      to={`/users/${userId}`}
      onMouseEnter={handleMouseEnter}
    >
      User {userId}
    </Link>
  );
}

Real-World Example

Complete example from production:

TSX
// hooks/usePlayers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

interface Player {
  id: string;
  name: string;
}

interface PlayerFilters {
  search?: string;
  position?: string;
}

interface PlayersResponse {
  players: Player[];
}

export function usePlayers(filters: PlayerFilters) {
  return useQuery<PlayersResponse>({
    queryKey: ['players', filters],
    queryFn: () => api.getPlayers(filters),
    staleTime: 1000 * 60 * 5,
    select: (data) => data.players, // Transform data
  });
}

export function useCreatePlayer() {
  const queryClient = useQueryClient();

  return useMutation<Player, Error, Partial<Player>>({
    mutationFn: api.createPlayer,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['players'] });
    },
  });
}

// Component
interface PlayersListProps {}

function PlayersList({}: PlayersListProps) {
  const [filters, setFilters] = useState<PlayerFilters>({});
  const { data: players, isLoading } = usePlayers(filters);
  const createPlayer = useCreatePlayer();

  if (isLoading) return <Loader />;

  return (
    <div>
      <Filters filters={filters} onChange={setFilters} />
      {players?.map(player => (
        <PlayerCard key={player.id} player={player} />
      ))}
      <button onClick={() => createPlayer.mutate({ name: 'New Player' })}>
        Add Player
      </button>
    </div>
  );
}

Best Practices

  1. Use query keys consistently: Follow a naming convention
  2. Create custom hooks: Encapsulate query logic
  3. Set appropriate stale times: Balance freshness vs. performance
  4. Handle errors gracefully: Provide good UX on failures
  5. Use select for transformations: Transform data in select, not in components
  6. Invalidate strategically: Only invalidate what needs refetching
  7. Leverage prefetching: Improve perceived performance

Performance Benefits

React Query provides:

  • Automatic caching and deduplication
  • Background refetching for fresh data
  • Optimistic updates for instant UI feedback
  • Request deduplication (same query runs once)
  • Intelligent cache management
  • Reduced boilerplate code

React Query transforms data fetching from a chore into a powerful, declarative experience. It's become essential for any React application that deals with server state.

Keep reading