TanStack Query Complete Guide | React Query, Data Fetching, Caching, Mutations

TanStack Query Complete Guide | React Query, Data Fetching, Caching, Mutations

이 글의 핵심

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.

Why TanStack Query?

Before TanStack Query:

// Manual state management
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));
}, []);

With TanStack Query:

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

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:

Resources: