Data Fetching Strategies: Beyond Basic Fetch

Every React app needs data. But how you fetch it determines your app’s perceived performance, reliability, and code maintainability.

This chapter explores SWR, TanStack Query, and advanced patterns like optimistic updates and infinite scroll.


The Problem with Naive Fetching

// 😰 Classic useEffect fetch
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => { setUser(data); setLoading(false); })
      .catch(err => { setError(err); setLoading(false); });
  }, [userId]);

  // ... render states
}

Problems: - No caching (refetches on every mount) - No background updates - Race conditions possible - Duplicated boilerplate everywhere


TanStack Query: The Solution

TanStack Query (formerly React Query) handles caching, deduplication, background refetching, and more.

npm install @tanstack/react-query

Setup

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

const queryClient = new QueryClient();

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

Basic Usage

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

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

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

  return <Profile user={user} />;
}
TipBenefits
  • Automatic caching — Data is stored and reused
  • Background refetching — Updates on window focus
  • Request deduplication — Multiple components share one request
  • Stale-while-revalidate — Show cached data immediately, update in background

sequenceDiagram
    participant U as User
    participant C as Cache
    participant S as Server

    U->>C: Request data
    alt Cache has data
        C->>U: Return cached (stale) instantly
        C->>S: Fetch fresh in background
        S-->>C: New data
        C->>U: Update UI with fresh data
    else Cache empty
        C->>S: Fetch from server
        S-->>C: Response
        C->>U: Show data
    end


The Caching Blueprint: Stop Refetching Unchanged Data

ImportantThe Real Pain Point

Without caching, every component mount triggers a network request. Navigate away and back? Refetch. Open a modal? Refetch. The same unchanged data, over and over.

Understanding Cache Timing

TanStack Query uses two key timers:

Timer Default What It Controls
staleTime 0 How long data is considered “fresh” (no background refetch)
gcTime 5 min How long unused data stays in cache before garbage collection

flowchart LR
    F["Fresh"] -->|staleTime expires| S["Stale"]
    S -->|Component unmounts| I["Inactive"]
    I -->|gcTime expires| G["Garbage Collected"]
    
    S -->|"Window focus / Mount"| R["Background Refetch"]
    R --> F
    
    style F fill:#22c55e
    style S fill:#eab308
    style G fill:#ef4444

Configure Cache for Your Data Type

// Global defaults - set once at app initialization
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,  // 5 minutes - data is fresh
      gcTime: 1000 * 60 * 30,   // 30 minutes - keep in cache
      refetchOnWindowFocus: false,  // Don't refetch on tab switch
      retry: 2,
    },
  },
});

Per-Query Cache Control

// Static data that rarely changes (e.g., country list, categories)
const { data: countries } = useQuery({
  queryKey: ['countries'],
  queryFn: fetchCountries,
  staleTime: Infinity,  // NEVER stale - cache forever
  gcTime: Infinity,     // NEVER garbage collect
});

// User data - fresh for 1 minute
const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 1000 * 60,  // Fresh for 1 minute
});

// Real-time data - always refetch
const { data: stockPrice } = useQuery({
  queryKey: ['stock', symbol],
  queryFn: () => fetchStockPrice(symbol),
  staleTime: 0,  // Always stale - always refetch
  refetchInterval: 5000,  // Poll every 5 seconds
});

The Query Key is the Cache Key

// These are DIFFERENT cache entries:
useQuery({ queryKey: ['user', 1] });     // User 1
useQuery({ queryKey: ['user', 2] });     // User 2
useQuery({ queryKey: ['user', 1] });     // ✅ Uses cached User 1!

// Structure keys for invalidation:
queryKey: ['posts']                      // All posts
queryKey: ['posts', 'list']              // Post list
queryKey: ['posts', 'list', { page: 1 }] // Page 1
queryKey: ['posts', 'detail', postId]    // Single post

// Invalidate all posts (list + details):
queryClient.invalidateQueries({ queryKey: ['posts'] });

Prefetching: Load Before the User Clicks

// In a list component - prefetch on hover
function PostList({ posts }) {
  const queryClient = useQueryClient();

  const prefetchPost = (postId: number) => {
    queryClient.prefetchQuery({
      queryKey: ['posts', 'detail', postId],
      queryFn: () => fetchPost(postId),
      staleTime: 1000 * 60 * 5,  // Don't prefetch if already fresh
    });
  };

  return (
    <ul>
      {posts.map(post => (
        <li 
          key={post.id}
          onMouseEnter={() => prefetchPost(post.id)}  // Prefetch on hover!
        >
          <Link to={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

Cache Data That Never Changes

For truly static data (app config, feature flags, dropdown options):

// Fetch once, cache forever
function useAppConfig() {
  return useQuery({
    queryKey: ['app-config'],
    queryFn: fetchAppConfig,
    staleTime: Infinity,
    gcTime: Infinity,
    refetchOnMount: false,
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
  });
}

// Or create a reusable option preset
const STATIC_QUERY_OPTIONS = {
  staleTime: Infinity,
  gcTime: Infinity,
  refetchOnMount: false,
  refetchOnWindowFocus: false,
  refetchOnReconnect: false,
} as const;

// Use it
useQuery({
  queryKey: ['categories'],
  queryFn: fetchCategories,
  ...STATIC_QUERY_OPTIONS,
});

Initial Data from Cache

Provide initial data while fresh data loads:

// Use cached list data as initial detail data
function PostDetail({ postId }) {
  const { data } = useQuery({
    queryKey: ['posts', 'detail', postId],
    queryFn: () => fetchPost(postId),
    initialData: () => {
      // Check if we have this post in the list cache
      const posts = queryClient.getQueryData(['posts', 'list']);
      return posts?.find(p => p.id === postId);
    },
    initialDataUpdatedAt: () => {
      // Use the list's last update time
      return queryClient.getQueryState(['posts', 'list'])?.dataUpdatedAt;
    },
  });
}

Mutations: Updating Data

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

function UpdateProfileForm({ user }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newData) => 
      fetch(`/api/users/${user.id}`, {
        method: 'PATCH',
        body: JSON.stringify(newData),
      }).then(res => res.json()),

    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['user', user.id] });
    },
  });

  const handleSubmit = (formData) => {
    mutation.mutate(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* ... form fields */}
      <button disabled={mutation.isPending}>
        {mutation.isPending ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
}

Optimistic Updates

Update the UI before the server confirms, then roll back if it fails:

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

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

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

    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    // Roll back on error
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Infinite Scroll

import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';

function InfiniteFeed() {
  const { ref, inView } = useInView();

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 1 }) => 
      fetch(`/api/posts?page=${pageParam}`).then(res => res.json()),
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  // Fetch next page when sentinel is visible
  useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, fetchNextPage]);

  return (
    <>
      {data?.pages.map(page => 
        page.posts.map(post => <PostCard key={post.id} post={post} />)
      )}
      <div ref={ref}>
        {isFetchingNextPage && <Spinner />}
      </div>
    </>
  );
}

SWR vs TanStack Query

Feature SWR TanStack Query
Bundle Size Smaller Larger
DevTools Basic Excellent
Mutations Manual Built-in
Infinite Queries Supported Excellent
Learning Curve Lower Moderate

Recommendation: Use TanStack Query for complex apps, SWR for simpler needs.


Key Patterns

  1. Stale-While-Revalidate: Show cached data immediately, update in background
  2. Prefetching: Load data before the user navigates
  3. Polling: Refresh data on an interval for near-real-time updates
  4. Dependent Queries: Fetch B only after A completes

Data fetching is the nervous system of your app. Invest in a robust solution.