본문으로 건너뛰기
Previous
Next
TanStack Query Complete Guide | React Query· Data Fetching

TanStack Query Complete Guide | React Query· Data Fetching

TanStack Query Complete Guide | React Query· Data Fetching

이 글의 핵심

TanStack Query (React Query) simplifies data fetching in React apps with automatic caching, background updates, and smart refetching. This guide covers everything from setup to advanced patterns.

Introduction

TanStack Query (formerly React Query) is a powerful data-fetching library that handles server state management in React applications. It provides automatic caching, background updates, and a clean API that eliminates most boilerplate code.

Used by thousands of production apps including those at Vercel, Shopify, and major startups, TanStack Query has become the de facto standard for server state management in React. This guide covers everything from basic setup to advanced patterns you’ll use in real projects.

Why TanStack Query?

Traditional data fetching with useEffect and useState requires managing loading states, error handling, caching, refetching, and deduplication manually. TanStack Query handles all of this automatically.

Before TanStack Query:

// Manual state management - 15+ lines per fetch
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

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

// Plus: no caching, no background refetch, no deduplication

With TanStack Query:

// Single line - handles everything automatically
const { data, isLoading, error } = useQuery({
  queryKey: ['users'],
  queryFn: () => fetch('/api/users').then(res => res.json())
});

// Includes: automatic caching, background refetch, request deduplication

What TanStack Query Provides

  • Automatic caching ??fetch once, reuse everywhere
  • Background refetching ??keep data fresh without blocking UI
  • Request deduplication ??multiple components requesting same data? Only one network call
  • Optimistic updates ??instant UI feedback before server responds
  • Infinite queries ??load more pagination made easy
  • DevTools ??inspect queries, mutations, and cache in real time

1. Installation & Setup

Install Dependencies

npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools

Configure QueryClient

// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export default function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1 minute
            cacheTime: 5 * 60 * 1000, // 5 minutes
            retry: 1,
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
// app/layout.tsx
import Providers from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

2. useQuery: Fetching Data

Basic Query

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

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

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

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

  return (
    <div>
      {isFetching && <div>Updating...</div>}
      <ul>
        {data?.map((user) => (
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
}

Query with Parameters

function UserProfile({ userId }: { userId: number }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const res = await fetch(`/api/users/${userId}`);
      return res.json();
    },
    enabled: !!userId, // Only run if userId exists
  });

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

Dependent Queries

function UserPosts({ userId }: { userId: number }) {
  // First query
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
  });

  // Dependent query - only runs after user is loaded
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetch(`/api/posts?userId=${user.id}`).then(res => res.json()),
    enabled: !!user,
  });

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

3. useMutation: Modifying Data

Basic Mutation

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

interface CreateUserData {
  name: string;
  email: string;
}

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

  const mutation = useMutation({
    mutationFn: async (newUser: CreateUserData) => {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newUser),
      });
      return response.json();
    },
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    mutation.mutate({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" />
      <input name="email" placeholder="Email" />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create User'}
      </button>
      {mutation.isError && <div>Error: {mutation.error.message}</div>}
    </form>
  );
}

Optimistic Updates

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

  const toggleMutation = useMutation({
    mutationFn: async ({ id, completed }: { id: number; completed: boolean }) => {
      const res = await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed }),
      });
      return res.json();
    },
    onMutate: async ({ id, completed }) => {
      // Cancel outgoing queries
      await queryClient.cancelQueries({ queryKey: ['todos'] });

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

      // Optimistically update
      queryClient.setQueryData(['todos'], (old: any) =>
        old.map((todo: any) =>
          todo.id === id ? { ...todo, completed } : todo
        )
      );

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

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

4. Infinite Queries

Pagination

function InfiniteUserList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['users'],
    queryFn: async ({ pageParam = 1 }) => {
      const res = await fetch(`/api/users?page=${pageParam}&limit=20`);
      return res.json();
    },
    getNextPageParam: (lastPage, allPages) => {
      return lastPage.hasMore ? allPages.length + 1 : undefined;
    },
    initialPageParam: 1,
  });

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.users.map((user: any) => (
            <div key={user.id}>{user.name}</div>
          ))}
        </div>
      ))}
      
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
          ? 'Load More'
          : 'No more data'}
      </button>
    </div>
  );
}

5. Advanced Patterns

Query Invalidation

const queryClient = useQueryClient();

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

// Invalidate all queries starting with 'users'
queryClient.invalidateQueries({ queryKey: ['users'], exact: false });

// Invalidate all queries
queryClient.invalidateQueries();

Prefetching

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

  const handleMouseEnter = (userId: number) => {
    queryClient.prefetchQuery({
      queryKey: ['user', userId],
      queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
    });
  };

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id} onMouseEnter={() => handleMouseEnter(user.id)}>
          <Link to={`/users/${user.id}`}>{user.name}</Link>
        </li>
      ))}
    </ul>
  );
}

Custom Hooks

// hooks/useUsers.ts
export function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const res = await fetch('/api/users');
      return res.json();
    },
  });
}

export function useCreateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (data: CreateUserData) => {
      const res = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });
      return res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

6. Configuration Options

Query Options

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  
  // Caching
  staleTime: 5 * 60 * 1000, // 5 minutes
  cacheTime: 10 * 60 * 1000, // 10 minutes
  
  // Refetching
  refetchOnMount: true,
  refetchOnWindowFocus: false,
  refetchOnReconnect: true,
  refetchInterval: false,
  
  // Retry
  retry: 3,
  retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
  
  // Conditional
  enabled: true,
});

7. Error Handling

Global Error Handler

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: (error) => {
        console.error('Query error:', error);
        toast.error('Failed to fetch data');
      },
    },
    mutations: {
      onError: (error) => {
        console.error('Mutation error:', error);
        toast.error('Failed to update data');
      },
    },
  },
});

Per-Query Error Handling

const { data, error } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  onError: (error) => {
    if (error instanceof Error) {
      toast.error(error.message);
    }
  },
});

8. TypeScript Integration

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

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

interface ApiError {
  message: string;
  code: string;
}

// Typed query
function useUser(userId: number): UseQueryResult<User, ApiError> {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) {
        throw { message: 'Failed to fetch', code: 'FETCH_ERROR' };
      }
      return res.json();
    },
  });
}

9. Best Practices

1. Use Query Keys Consistently

// ??Bad
queryKey: ['users', userId, 'posts']

// ??Good
queryKey: ['users', userId, 'posts'] as const

2. Create Custom Hooks

// Reusable, testable, maintainable
export const useUsers = () => useQuery({...});
export const useCreateUser = () => useMutation({...});

3. Handle Loading States

if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
if (!data) return null;

4. Use Optimistic Updates for Better UX

// Immediate feedback, rollback on error
onMutate: async (newData) => {
  await queryClient.cancelQueries({ queryKey: ['todos'] });
  const previous = queryClient.getQueryData(['todos']);
  queryClient.setQueryData(['todos'], (old) => [...old, newData]);
  return { previous };
},

10. Performance Tips

Reduce Re-renders

// ??Re-renders on every state change
const { data, isLoading, error, isFetching } = useQuery({...});

// ??Only re-render when needed
const { data } = useQuery({...});

Selective Refetching

// Only refetch when needed
queryClient.invalidateQueries({ 
  queryKey: ['users', userId],
  exact: true 
});

Use Suspense (Experimental)

const { data } = useSuspenseQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});

// No need for isLoading check - Suspense handles it

Summary

TanStack Query transforms data fetching in React:

  • Automatic caching reduces network requests
  • Background refetching keeps data fresh
  • Optimistic updates improve perceived performance
  • DevTools make debugging easy

Key Takeaways:

  1. Replace useEffect + fetch with useQuery
  2. Use queryKey for automatic caching
  3. Implement optimistic updates for better UX
  4. Create custom hooks for reusability
  5. Configure staleTime and cacheTime appropriately

Next Steps:

  • Explore [tRPC integration](/en/blog/trpc-complete-guide/ for type-safe APIs
  • Learn [Zustand](/en/blog/zustand-complete-guide/ for client state
  • Check [React 18 features](/en/blog/react-18-deep-dive/

Resources:

Real-World Use Cases

When to Use TanStack Query

TanStack Query excels in applications that:

  1. Fetch data from REST APIs frequently ??dashboards, admin panels, social feeds
  2. Need real-time updates ??chat apps, collaborative tools, live dashboards
  3. Display server data in multiple components ??shared state across the app
  4. Require offline support ??the cache can serve stale data when offline
  5. Need optimistic UI updates ??instant feedback before server confirmation

When NOT to Use TanStack Query

  • Pure client-side state ??use Zustand, Jotai, or Context for UI state
  • Static content ??SSG with Next.js is better for blogs, marketing sites
  • GraphQL-heavy apps ??consider Apollo Client or urql instead
  • WebSocket/real-time streams ??TanStack Query works but specialized libraries may fit better

Production Example: Dashboard

Here’s how a production dashboard might structure its queries:

// hooks/useDashboard.ts
export function useDashboardData() {
  const { data: stats } = useQuery({
    queryKey: ['dashboard', 'stats'],
    queryFn: fetchDashboardStats,
    staleTime: 30 * 1000, // 30 seconds
    refetchInterval: 60 * 1000, // Refresh every minute
  });

  const { data: activities } = useQuery({
    queryKey: ['dashboard', 'activities'],
    queryFn: fetchRecentActivities,
    staleTime: 10 * 1000,
  });

  return { stats, activities };
}

// components/Dashboard.tsx
export default function Dashboard() {
  const { stats, activities } = useDashboardData();

  return (
    <div>
      <StatsCards data={stats} />
      <ActivityFeed data={activities} />
    </div>
  );
}

This pattern:

  • Separates data fetching logic from UI
  • Enables independent refetch intervals
  • Makes components testable
  • Shares cache across the app automatically

Continue learning React patterns:

  • [SWR Complete Guide](/en/blog/swr-complete-guide/ ??Alternative to TanStack Query by Vercel
  • [React 18 Deep Dive](/en/blog/react-18-deep-dive/ ??Concurrent rendering and Suspense
  • [Zustand Complete Guide](/en/blog/zustand-complete-guide/ ??Lightweight client state management

Keywords

TanStack Query, React Query, Data Fetching, Cache, Server State, Mutations, Optimistic Updates, React Hooks, TypeScript