DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

Fetching Data in React: The Right Way

Fetching Data in React: The Right Way — fetch, axios, React Query, SWR & More Compared

If you've built anything in React beyond a todo app, you've had to fetch data from somewhere. And if you've been doing this for a while, you've probably noticed the landscape shifting under your feet every couple of years. Class component lifecycle methods gave way to useEffect, which gave way to dedicated data-fetching libraries, and now server components are changing the game again.

This guide walks through every major approach to data fetching in React, with real code, honest trade-offs, and a clear decision framework at the end so you can stop second-guessing your choices.


The Evolution of Data Fetching in React

Let's zoom out for a second. How did we get here?

Timeline of React Data Fetching
================================

2015        2019         2020          2022           2024+
  |           |            |             |              |
  v           v            v             |              v
Class      Hooks        React Query     v           Server
Components (useEffect)  & SWR        Suspense      Components
                                     (stable)      (RSC + Next.js)

componentDidMount  ->  useEffect  ->  useQuery  ->  async components
     ^                    ^              ^               ^
     |                    |              |               |
  Manual state      Still manual     Automatic       No client
  + lifecycle       but cleaner      caching,        state needed
  management        API              dedup, retry    for fetches
Enter fullscreen mode Exit fullscreen mode

Each generation solved problems the previous one created. Class components were verbose. useEffect was cleaner but introduced subtle bugs (race conditions, stale closures). Libraries like React Query abstracted those problems away. And now server components let you skip the client entirely for many data-fetching scenarios.

Let's dig into each approach.


1. Native fetch API in React

The simplest approach. No dependencies. Just the browser's built-in fetch.

Basic Pattern

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const controller = new AbortController(); // For cleanup

    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const data = await response.json();
        setUser(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchUser();

    return () => controller.abort(); // Cleanup on unmount or re-render
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

What fetch Does Well

  • Zero dependencies — it's built into every modern browser and Node.js 18+
  • Streaming support — you can read response bodies as streams
  • Full control — nothing is abstracted away, you see exactly what's happening

What fetch Does Poorly

  • No automatic JSON parsing — you always need .json() (unlike axios)
  • Doesn't throw on HTTP errors — a 404 response is a "successful" fetch. You have to check response.ok yourself
  • No request/response interceptors — want to attach an auth token to every request? You'll write a wrapper
  • No built-in timeout — you need AbortController + setTimeout
  • No retry logic — you build it yourself
  • No caching — every render triggers a new request unless you manage it

Adding a Timeout

async function fetchWithTimeout(url, options = {}, timeout = 5000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });
    return response;
  } finally {
    clearTimeout(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

The fetch API is fine for quick prototypes or when you truly need minimal overhead. But for production apps, you'll almost always want something more.


2. Axios — The Reliable Workhorse

Axios has been around since 2014 and it's still going strong. It wraps HTTP requests in a developer-friendly API that fixes most of fetch's annoyances.

Setup and Basic Usage

npm install axios
Enter fullscreen mode Exit fullscreen mode
import axios from 'axios';

// Create a configured instance
const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// GET request
const { data } = await api.get('/users/123');

// POST request
const { data: newUser } = await api.post('/users', {
  name: 'Jane Doe',
  email: 'jane@example.com',
});
Enter fullscreen mode Exit fullscreen mode

Interceptors — The Killer Feature

Interceptors let you hook into every request or response globally. This is huge for auth, logging, and error handling.

// Request interceptor — attach auth token
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Response interceptor — handle token refresh
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const { data } = await axios.post('/auth/refresh', {
          refreshToken: localStorage.getItem('refreshToken'),
        });
        localStorage.setItem('accessToken', data.accessToken);
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        // Redirect to login
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);
Enter fullscreen mode Exit fullscreen mode

Axios in a React Component

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const source = axios.CancelToken.source();

    api.get('/users', { cancelToken: source.token })
      .then(({ data }) => setUsers(data))
      .catch((err) => {
        if (!axios.isCancel(err)) {
          console.error(err);
        }
      })
      .finally(() => setLoading(false));

    return () => source.cancel('Component unmounted');
  }, []);

  // render...
}
Enter fullscreen mode Exit fullscreen mode

Why People Love Axios

  • Automatic JSON parsing (request and response)
  • Throws on HTTP errors by default (no response.ok checks)
  • Request/response interceptors
  • Built-in request cancellation
  • Progress tracking for uploads/downloads
  • Works identically in browser and Node.js

Why Some People Are Moving Away

  • It's another dependency (~13KB gzipped)
  • The fetch API has gotten better over time
  • Libraries like React Query handle the HTTP layer differently
  • The CancelToken API was deprecated in favor of AbortController support

3. React Query (TanStack Query) — The Recommended Approach

If you're building a React app that talks to a server, React Query is almost certainly what you should be using. It's not just a data-fetching library; it's a server state management solution.

The core insight: server state and client state are fundamentally different things. Server state is remote, asynchronous, shared, and can become stale. Treating it the same as local UI state (like a modal being open) is a recipe for bugs.

Installation and Setup

npm install @tanstack/react-query @tanstack/react-query-devtools
Enter fullscreen mode Exit fullscreen mode
// app.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      retry: 3,
      refetchOnWindowFocus: true,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

useQuery — Reading Data

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

function UserProfile({ userId }) {
  const {
    data: user,
    isLoading,
    isError,
    error,
    isFetching,    // true during background refetches
    isStale,
  } = useQuery({
    queryKey: ['users', userId],  // Unique cache key
    queryFn: () => api.get(`/users/${userId}`).then(res => res.data),
    staleTime: 1000 * 60 * 5,     // Consider fresh for 5 min
    gcTime: 1000 * 60 * 30,       // Keep in cache for 30 min
    enabled: !!userId,             // Don't run if userId is falsy
  });

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

  return (
    <div>
      <h1>{user.name}</h1>
      {isFetching && <SmallSpinner />} {/* Background refetch indicator */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Query Keys — The Cache Backbone

Query keys are how React Query identifies and caches data. They're arrays, and the structure matters.

// These are all DIFFERENT cache entries:
useQuery({ queryKey: ['users'] })                    // All users
useQuery({ queryKey: ['users', 'list'] })            // User list
useQuery({ queryKey: ['users', userId] })            // Single user
useQuery({ queryKey: ['users', { status: 'active' }] }) // Filtered users

// Query key hierarchy enables smart invalidation:
queryClient.invalidateQueries({ queryKey: ['users'] })
// ^ Invalidates ALL queries starting with 'users'
Enter fullscreen mode Exit fullscreen mode
Query Key Hierarchy
====================

['users']
  |
  +-- ['users', 'list']
  |     +-- ['users', 'list', { page: 1 }]
  |     +-- ['users', 'list', { page: 2 }]
  |
  +-- ['users', 42]
  +-- ['users', 99]

invalidateQueries(['users'])        --> invalidates EVERYTHING above
invalidateQueries(['users', 'list'])--> invalidates list + pages only
invalidateQueries(['users', 42])    --> invalidates only user 42
Enter fullscreen mode Exit fullscreen mode

useMutation — Writing Data

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

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

  const mutation = useMutation({
    mutationFn: (newUser) => api.post('/users', newUser),

    onMutate: async (newUser) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['users'] });

      // Snapshot previous value
      const previousUsers = queryClient.getQueryData(['users']);

      // Optimistically update
      queryClient.setQueryData(['users'], (old) => [
        ...old,
        { ...newUser, id: 'temp-id' },
      ]);

      return { previousUsers }; // Context for rollback
    },

    onError: (err, newUser, context) => {
      // Rollback on error
      queryClient.setQueryData(['users'], context.previousUsers);
      toast.error('Failed to create user');
    },

    onSuccess: () => {
      toast.success('User created!');
    },

    onSettled: () => {
      // Refetch regardless of success/error
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

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

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button
        type="submit"
        disabled={mutation.isPending}
      >
        {mutation.isPending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Infinite Queries — Pagination and Infinite Scroll

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

function InfiniteFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam = 1 }) =>
      api.get(`/posts?page=${pageParam}&limit=20`).then(res => res.data),
    getNextPageParam: (lastPage, allPages) => {
      return lastPage.hasMore ? allPages.length + 1 : undefined;
    },
    initialPageParam: 1,
  });

  const allPosts = data?.pages.flatMap(page => page.items) ?? [];

  return (
    <div>
      {allPosts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
          ? 'Load More'
          : 'No more posts'}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Prefetching — Make It Feel Instant

// Prefetch on hover
function UserLink({ userId }) {
  const queryClient = useQueryClient();

  const prefetchUser = () => {
    queryClient.prefetchQuery({
      queryKey: ['users', userId],
      queryFn: () => api.get(`/users/${userId}`).then(res => res.data),
      staleTime: 1000 * 60 * 5,
    });
  };

  return (
    <Link
      to={`/users/${userId}`}
      onMouseEnter={prefetchUser}
    >
      View Profile
    </Link>
  );
}

// Prefetch in route loaders (React Router)
const router = createBrowserRouter([
  {
    path: '/users/:id',
    loader: ({ params }) => {
      queryClient.prefetchQuery({
        queryKey: ['users', params.id],
        queryFn: () => api.get(`/users/${params.id}`).then(res => res.data),
      });
      return null;
    },
    element: <UserProfile />,
  },
]);
Enter fullscreen mode Exit fullscreen mode

React Query DevTools

One of React Query's best features. It gives you a panel showing every query in your cache, its status, when it was last fetched, and lets you manually trigger refetches or clear the cache. Invaluable during development.


4. SWR by Vercel — Stale-While-Revalidate

SWR takes its name from the HTTP cache invalidation strategy stale-while-revalidate. The idea: return cached (stale) data first, then fetch in the background, then swap in the fresh data.

Basic Usage

npm install swr
Enter fullscreen mode Exit fullscreen mode
import useSWR from 'swr';

const fetcher = (url) => fetch(url).then(res => res.json());

function UserProfile({ userId }) {
  const { data, error, isLoading, isValidating, mutate } = useSWR(
    `/api/users/${userId}`,
    fetcher
  );

  if (isLoading) return <Skeleton />;
  if (error) return <div>Failed to load</div>;

  return (
    <div>
      <h1>{data.name}</h1>
      {isValidating && <SmallSpinner />}
      <button onClick={() => mutate()}>Refresh</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Global Configuration

import { SWRConfig } from 'swr';

function App() {
  return (
    <SWRConfig
      value={{
        fetcher: (url) => api.get(url).then(res => res.data),
        revalidateOnFocus: true,
        revalidateOnReconnect: true,
        dedupingInterval: 2000,
        errorRetryCount: 3,
      }}
    >
      <Router />
    </SWRConfig>
  );
}
Enter fullscreen mode Exit fullscreen mode

SWR vs React Query — Honest Comparison

SWR is simpler and lighter. React Query is more powerful and opinionated. Here's how they differ in practice:

// SWR — the key IS the URL, simpler mental model
const { data } = useSWR('/api/users', fetcher);

// React Query — separate key and function, more flexible
const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});
Enter fullscreen mode Exit fullscreen mode

SWR wins on simplicity. React Query wins on features (mutations, infinite queries, query invalidation patterns, devtools). For most production apps, React Query is the better choice. For simpler apps or when you want minimal API surface, SWR is great.


5. useEffect Pitfalls — The Bugs You'll Hit

Before we move on, let's talk about the problems you'll encounter if you do data fetching with raw useEffect. These aren't hypothetical; they happen all the time.

Race Condition

// BUG: Race condition
function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    // If query changes rapidly, responses arrive out of order.
    // Search for "react" then "vue" — the "react" response might
    // arrive AFTER the "vue" response, showing wrong results.
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => setResults(data));
  }, [query]);

  return <ResultsList results={results} />;
}

// FIX: Use a cleanup flag
function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    let cancelled = false;

    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setResults(data);
        }
      });

    return () => { cancelled = true; };
  }, [query]);

  return <ResultsList results={results} />;
}
Enter fullscreen mode Exit fullscreen mode

Memory Leak on Unmount

// BUG: Setting state on unmounted component
useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => setData(data)); // Component might be unmounted!
}, []);

// FIX: AbortController
useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });

  return () => controller.abort();
}, []);
Enter fullscreen mode Exit fullscreen mode

The Double-Fetch in Strict Mode

React 18's Strict Mode intentionally mounts, unmounts, and remounts components in development. If your useEffect fires a fetch, you'll see two requests. This is by design — it's exposing bugs in your code. The fix is proper cleanup (AbortController) or using a library that handles it.

This is exactly why libraries like React Query exist. They handle all these edge cases internally so you don't have to think about them.


6. React Suspense + Server Components

This is the newest paradigm shift and it's a big one, especially in Next.js.

Suspense for Data Fetching

import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userId={42} />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component "suspends" while loading, and React shows the fallback. No loading state management. No isLoading boolean. The component either has data or it doesn't.

Server Components (Next.js App Router)

This is the real game-changer. Server Components can be async functions that fetch data directly — no hooks, no state, no client-side waterfall.

// app/users/[id]/page.tsx — This runs on the SERVER
async function UserPage({ params }) {
  const user = await fetch(`https://api.example.com/users/${params.id}`, {
    next: { revalidate: 3600 }, // Cache for 1 hour
  }).then(res => res.json());

  const posts = await fetch(`https://api.example.com/users/${params.id}/posts`, {
    next: { tags: ['user-posts'] }, // Tag-based revalidation
  }).then(res => res.json());

  return (
    <div>
      <h1>{user.name}</h1>
      <PostList posts={posts} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Parallel Data Fetching with Server Components

// Avoid waterfalls — fetch in parallel
async function Dashboard() {
  // These all start simultaneously
  const [user, stats, notifications] = await Promise.all([
    getUser(),
    getStats(),
    getNotifications(),
  ]);

  return (
    <div>
      <UserCard user={user} />
      <StatsPanel stats={stats} />
      <NotificationFeed notifications={notifications} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Streaming with Suspense Boundaries

// The page shell loads immediately, each section streams in
async function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />  {/* Streams in when ready */}
      </Suspense>

      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />  {/* Streams in independently */}
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />  {/* Streams in independently */}
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
Server Component Streaming
============================

Browser receives:       What the user sees:

1. HTML shell      -->  [Header] [Dashboard Title]
                        [Skeleton] [Skeleton] [Skeleton]

2. Stats chunk     -->  [Header] [Dashboard Title]
                        [  Stats ] [Skeleton] [Skeleton]

3. Feed chunk      -->  [Header] [Dashboard Title]
                        [  Stats ] [  Feed  ] [Skeleton]

4. Chart chunk     -->  [Header] [Dashboard Title]
                        [  Stats ] [  Feed  ] [  Chart ]

Each section arrives independently — no waterfalls!
Enter fullscreen mode Exit fullscreen mode

7. Custom Hooks for Data Fetching

If you're not using React Query but want reusable patterns, custom hooks help.

Generic useFetch Hook

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    let cancelled = false;

    async function doFetch() {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url, {
          ...options,
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const json = await response.json();

        if (!cancelled) {
          setData(json);
        }
      } catch (err) {
        if (!cancelled && err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    doFetch();

    return () => {
      cancelled = true;
      controller.abort();
    };
  }, [url]);

  return { data, error, loading };
}

// Usage
function UserList() {
  const { data: users, error, loading } = useFetch('/api/users');
  // ...
}
Enter fullscreen mode Exit fullscreen mode

useApi Hook with Caching

const cache = new Map();

function useApi(key, fetcher, options = {}) {
  const { cacheTime = 60000, enabled = true } = options;
  const [state, setState] = useState(() => {
    const cached = cache.get(key);
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      return { data: cached.data, error: null, loading: false };
    }
    return { data: null, error: null, loading: true };
  });

  useEffect(() => {
    if (!enabled) return;

    const controller = new AbortController();

    fetcher({ signal: controller.signal })
      .then(data => {
        cache.set(key, { data, timestamp: Date.now() });
        setState({ data, error: null, loading: false });
      })
      .catch(error => {
        if (error.name !== 'AbortError') {
          setState({ data: null, error, loading: false });
        }
      });

    return () => controller.abort();
  }, [key, enabled]);

  return state;
}
Enter fullscreen mode Exit fullscreen mode

Honestly though, once you start adding caching, deduplication, retry logic, and background refetching to a custom hook, you've just rebuilt React Query — poorly. Use the library.


8. Error Handling Patterns

Error Boundaries

import { Component } from 'react';

class ErrorBoundary extends Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
    // Send to error tracking service
    reportError(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Try Again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// Usage
function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<Loading />}>
        <Dashboard />
      </Suspense>
    </ErrorBoundary>
  );
}
Enter fullscreen mode Exit fullscreen mode

Retry with Exponential Backoff (React Query)

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 3,
      retryDelay: (attemptIndex) => Math.min(
        1000 * 2 ** attemptIndex, // 1s, 2s, 4s
        30000 // Max 30 seconds
      ),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Per-Query Error Handling

const { data, error } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  throwOnError: (error) => {
    // Only throw to error boundary for 5xx errors
    return error.response?.status >= 500;
  },
  retry: (failureCount, error) => {
    // Don't retry 4xx errors
    if (error.response?.status < 500) return false;
    return failureCount < 3;
  },
});
Enter fullscreen mode Exit fullscreen mode

9. Loading State Patterns

Don't just show a spinner. Make your loading states informative and pleasant.

Skeleton UI

function UserCardSkeleton() {
  return (
    <div className="user-card">
      <div className="skeleton skeleton-avatar" />
      <div className="skeleton skeleton-text w-3/4" />
      <div className="skeleton skeleton-text w-1/2" />
    </div>
  );
}

function UserList() {
  const { data: users, isLoading } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isLoading) {
    return (
      <div>
        {Array.from({ length: 5 }).map((_, i) => (
          <UserCardSkeleton key={i} />
        ))}
      </div>
    );
  }

  return users.map(user => <UserCard key={user.id} user={user} />);
}
Enter fullscreen mode Exit fullscreen mode

Progressive Loading with placeholderData

function UserProfile({ userId }) {
  const { data, isPlaceholderData } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
    placeholderData: (previousData) => previousData,
    // ^ Keep showing previous user's data while new user loads
  });

  return (
    <div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
      <h1>{data?.name}</h1>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

10. The Big Comparison Table

Feature fetch Axios React Query SWR Server Components
Bundle size 0 KB ~13 KB ~39 KB ~12 KB 0 KB (server)
Caching Manual Manual Automatic Automatic HTTP cache / ISR
Deduplication No No Yes Yes N/A
Retry logic Manual Manual Built-in Built-in Manual
Devtools No No Excellent Limited N/A
SSR support Manual Manual Excellent Good Native
Optimistic updates Manual Manual Built-in Manual N/A
Infinite queries Manual Manual Built-in Built-in Manual
Mutations Manual Manual Built-in useSWRMutation Server Actions
Background refetch No No Yes Yes Revalidation
Type safety Basic Good Excellent Good Full
Learning curve Low Low Medium Low Medium-High
Race conditions You handle You handle Handled Handled N/A
Error boundaries Manual Manual Supported Supported Supported
Polling Manual Manual Built-in Built-in N/A
Prefetching No No Yes Yes <Link prefetch>
Offline support No No Yes Limited No

11. Best Practices and Anti-Patterns

Do This

// 1. Colocate queries with the components that use them
function UserAvatar({ userId }) {
  const { data } = useQuery({
    queryKey: ['users', userId, 'avatar'],
    queryFn: () => fetchUserAvatar(userId),
  });
  return <img src={data?.url} />;
}

// 2. Use query key factories
const userKeys = {
  all:    () => ['users'],
  lists:  () => [...userKeys.all(), 'list'],
  list:   (filters) => [...userKeys.lists(), filters],
  details:() => [...userKeys.all(), 'detail'],
  detail: (id) => [...userKeys.details(), id],
};

// Now invalidation is clean:
queryClient.invalidateQueries({ queryKey: userKeys.lists() });

// 3. Separate your API layer
// api/users.js
export const usersApi = {
  getAll: (filters) => api.get('/users', { params: filters }),
  getById: (id) => api.get(`/users/${id}`),
  create: (data) => api.post('/users', data),
  update: (id, data) => api.put(`/users/${id}`, data),
  delete: (id) => api.delete(`/users/${id}`),
};
Enter fullscreen mode Exit fullscreen mode

Don't Do This

// 1. DON'T fetch in useEffect without cleanup
useEffect(() => {
  fetch('/api/data').then(r => r.json()).then(setData);
}, []); // No cleanup = race conditions + memory leaks

// 2. DON'T put the entire response object in state
const [response, setResponse] = useState(null);
// Instead, extract what you need: setUser(response.data.user)

// 3. DON'T use useEffect for data that depends on other data
useEffect(() => { fetchUser(userId) }, [userId]);
useEffect(() => { fetchPosts(user?.id) }, [user]); // Waterfall!

// Instead, use dependent queries:
const { data: user } = useQuery({
  queryKey: ['users', userId],
  queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
  queryKey: ['posts', user?.id],
  queryFn: () => fetchPosts(user.id),
  enabled: !!user?.id, // Only runs when user is loaded
});

// 4. DON'T ignore error states
// Always handle loading, error, AND empty states

// 5. DON'T store server data in Redux/Zustand
// That's what React Query/SWR are for
Enter fullscreen mode Exit fullscreen mode

12. Decision Framework: Which Approach Should You Use?

START
  |
  v
Is this a Next.js App Router project?
  |
  YES --> Can this data be fetched on the server?
  |         |
  |         YES --> Use Server Components (async/await)
  |         |        + React Query for client-side mutations
  |         |
  |         NO  --> Use React Query on the client
  |
  NO
  |
  v
Is it a simple project with few API calls?
  |
  YES --> Are you allergic to dependencies?
  |         |
  |         YES --> fetch + custom hook (but be careful)
  |         |
  |         NO  --> SWR (simpler API, smaller bundle)
  |
  NO
  |
  v
Does the app have complex server state?
(mutations, optimistic updates, pagination, caching needs)
  |
  YES --> React Query (TanStack Query) <-- THIS IS THE DEFAULT CHOICE
  |
  NO  --> SWR or React Query (can't go wrong with either)
Enter fullscreen mode Exit fullscreen mode

The TL;DR

Scenario Use This
New React SPA React Query
Next.js App Router Server Components + React Query for client mutations
Simple app, few endpoints SWR
Need HTTP interceptors Axios (as the fetcher for React Query)
Learning / prototyping fetch in useEffect (just know its limits)
Enterprise app with complex state React Query + Axios + Error Boundaries
Static site / blog Server Components or getStaticProps

Recommended Stack for Most Projects

React Query (server state) + Axios (HTTP client) + Zustand (client state)
Enter fullscreen mode Exit fullscreen mode

React Query manages everything that comes from the server. Axios handles the actual HTTP requests with interceptors for auth and error handling. Zustand (or your preferred state manager) handles purely client-side state like UI toggles and form state. Clean separation, each tool doing what it's best at.


Wrapping Up

Data fetching in React has come a long way from componentDidMount. The ecosystem has matured to the point where you really don't need to manage loading/error/data states manually anymore. React Query handles the hard parts — caching, deduplication, background refetching, retry logic, race conditions — so you can focus on building features.

If you take one thing from this post: stop putting fetch calls in useEffect for anything beyond a toy project. Use React Query. Your future self will thank you.


If you found this helpful, let's connect! I write about frontend engineering, system design, and developer tooling.

Connect with me on LinkedIn

Top comments (0)