C
React/API Integration/Lesson 16

Managing Server State with React Query

40 min·theory
This chapter
2/2
TypeScript

Managing Server State with React Query

💡 Why Should You Learn This?

🎯 Automatically handles server data caching and synchronization, reducing boilerplate code by 70%.
💼 Background revalidation keeps your data always up to date while improving UX.
Error handling, retries, and loading state management become declaratively simple.
🔗 It is an essential technology at large-scale services (Karrot Market, Toss, etc.).
🏢 실무에서는
In production, when fetching a product list, React Query automatically handles caching so that even if the user navigates away and comes back, the cached data is shown instantly. It also auto-refreshes in the background every 5 minutes to always display the latest prices.

Concepts

React Query is a library that automates caching, synchronization, and revalidation of server state. It dramatically reduces repetitive fetch code and loading/error state management.

Why It Matters

React Query automatically handles 70% of server state management. You can declaratively implement caching, background revalidation, optimistic updates, and infinite scrolling.

Core Concepts

React Query Core Concepts

Query State Machine

code
fetching (initial loading)
    ↓
fresh (within staleTime)
    ↓
stale (outdated, awaiting revalidation)
    ↓
inactive (no subscribers, removed after cacheTime)

Key Configuration

javascript
{
  staleTime: 5 * 60 * 1000,  // 5 minutes: considered fresh
  cacheTime: 10 * 60 * 1000, // 10 minutes: inactive cache maintained
  retry: 3,                   // Number of retries on failure
  refetchOnWindowFocus: true, // Revalidate when tab is active
  refetchInterval: 30000,     // Polling every 30 seconds
}

Cache Key Design Principles

javascript
// Array form: allows hierarchical invalidation
['users']                    // All user list
['users', userId]            // Specific user
['users', userId, 'posts']  // Posts of a specific user

// Invalidate all user-related caches at once with invalidateQueries(['users'])

Background Revalidation (stale-while-revalidate)

1. Immediately return cached data (fast response)

2. Fetch fresh data in the background

3. Update the UI when new data arrives

→ Users see previous data instantly instead of a blank screen

Key Points

  • stale-while-revalidate: return cache immediately + background revalidation
  • Query keys: hierarchical design enables bulk invalidation of related caches
  • useMutation + onMutate: improve UX with optimistic updates
  • useInfiniteQuery: declarative implementation of infinite scrolling
💻 useQuery + useInfiniteQuery in Practice
import {
  useQuery,
  useInfiniteQuery,
  useQueryClient
} from '@tanstack/react-query';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';

// === 1. Basic Data Lookup ===
function LearningItem({ itemId }) {
  const {
    data,
    isLoading,
    isError,
    error,
    isFetching  // Background revalidation in progress
  } = useQuery({
    queryKey: ['learning-items', itemId],
    queryFn: async () => {
      const res = await fetch(`/api/items/${itemId}`);
      if (!res.ok) throw new Error('Failed to retrieve item');
      return res.json();
    },
    staleTime: 10 * 60 * 1000, // Fresh for 10 minutes
    enabled: !!itemId,
  });

  if (isLoading) return <Skeleton />;
  if (isError) return <ErrorMessage message={error.message} />;

  return (
    <article>
      {isFetching && <span className="refreshing">Refreshing...</span>}
      <h1>{data.title}</h1>
      <p>{data.content}</p>
    </article>
  );
}

// === 2. Infinite Scroll ===
function ItemList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam = 0 }) =>
      fetch(`/api/items?cursor=${pageParam}&limit=20`).then(r => r.json()),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });

  // Detect end of scroll with IntersectionObserver
  const [sentinelRef, isIntersecting] = useIntersectionObserver();

  React.useEffect(() => {
    if (isIntersecting && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [isIntersecting, hasNextPage, isFetchingNextPage]);

  if (isLoading) return <Loading />;

  // pages: array of data for each page
  const allItems = data.pages.flatMap(page => page.items);

  return (
    <div>
      {allItems.map(item => (
        <ItemCard key={item.id} item={item} />
      ))}
      {/* Scroll detection sentinel */}
      <div ref={sentinelRef}>
        {isFetchingNextPage && <Spinner />}
        {!hasNextPage && <p>All items have been loaded.</p>}
      </div>
    </div>
  );
}
💻 useMutation + Cache Invalidation Patterns
import { useMutation, useQueryClient } from '@tanstack/react-query';

// === Learning Progress Update + Optimistic Update ===
function ProgressButton({ itemId, isCompleted }) {
  const queryClient = useQueryClient();

  const toggleMutation = useMutation({
    mutationFn: async (completed) => {
      const res = await fetch(`/api/progress/${itemId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed })
      });
      if (!res.ok) throw new Error('Failed to update progress');
      return res.json();
    },

    // Optimistic update: UI changes immediately before response
    onMutate: async (completed) => {
      // Cancel ongoing queries
      await queryClient.cancelQueries({ queryKey: ['progress', itemId] });

      // Snapshot for rollback
      const previousProgress = queryClient.getQueryData(['progress', itemId]);

      // Update UI immediately
      queryClient.setQueryData(['progress', itemId], old => ({
        ...old,
        completed,
        completedAt: completed ? new Date().toISOString() : null
      }));

      return { previousProgress };
    },

    // Rollback on error
    onError: (err, completed, context) => {
      queryClient.setQueryData(
        ['progress', itemId],
        context?.previousProgress
      );
      alert('Update failed: ' + err.message);
    },

    // Re-synchronize with server data after success/failure
    onSettled: () => {
      // Invalidate all related caches (progress + overall progress stats)
      queryClient.invalidateQueries({ queryKey: ['progress', itemId] });
      queryClient.invalidateQueries({ queryKey: ['stats'] });
    }
  });

  return (
    <button
      onClick={() => toggleMutation.mutate(!isCompleted)}
      disabled={toggleMutation.isPending}
      className={isCompleted ? 'completed' : ''}
    >
      {toggleMutation.isPending ? 'Saving...' : (isCompleted ? 'Completed ✓' : 'Mark as Complete')}
    </button>
  );
}

💡 ⚠️ Common Mistakes

  • Using a single string for queryKey — use an array for hierarchical structure to enable bulk invalidation of related caches
  • Not setting staleTime/cacheTime — default values trigger a refetch on every window focus; tune them to match your data characteristics
  • Forgetting invalidateQueries after useMutation — new items won't appear in lists; always invalidate related queries in onSuccess
  • Catching errors inside queryFn without rethrowing — React Query only handles error state when errors are thrown; always rethrow after catching

💡 🎯 Interview Prep

Q: What is the difference between React Query and Redux?
Q: Explain the difference between staleTime and cacheTime
Q: How do you implement optimistic updates?

Hint: Redux manages the entire client state, whereas React Query specializes in server state and automates caching, revalidation, and synchronization. staleTime is the duration during which data is considered fresh (no refetch occurs), while cacheTime is how long an inactive cache is kept in memory. Optimistic updates work by using setQueryData in onMutate to update the UI immediately, rolling back to the previous data in onError if something goes wrong, and re-syncing with server data in onSettled.

⚛️ React Patterns — Managing Server State with React Query

Learn step by step, with code examples, how to use server state management with React Query in React.
1 🧩 1. When to Use Server State Management with React Query
Situations where this feature is needed.
2 💻 2. Writing the Code
Basic usage of server state management with React Query.
3 🎨 3. Rendering Result
What the user sees on screen.
4 💡 4. Practical Tips
Common pitfalls and best practices.

🎮 Managing Server State with React Query — Step-by-Step Understanding

Click each step to read the content, then mark your progress with the ✓ I understand button.
🖥️ Result — rendered React component
✏️ React 코드 수정하기 (클릭해서 열기)
⚛️ React 18 + Babel Standalone — see the result first, then edit the code yourself.

Check Your Knowledge

Which of the following is NOT a core feature of React Query?
💡 React Query specializes in managing server state (API data). It automatically handles caching, automatic refetching, and loading/error states. For global client state (such as UI state), Zustand or Context is more appropriate than React Query.
React Query (TanStack Query) - React