Data Fetching in React: The Complete Guide

1. 📖 Introduction

Almost every non-trivial React app needs to talk to a server — loading a user's profile, submitting a form, or streaming live updates. Data fetching sits at the intersection of JS's async model and React's rendering lifecycle, and getting it right involves more than just calling fetch. This tutorial covers the full picture: raw API calls, loading and error states, caching, pagination, real-time updates, and modern libraries like TanStack Query and SWR.

Information

Examples use both the native fetch API and axios where relevant, since both remain widely used in production React apps.

2. ❓ What is Data Fetching?

Data fetching is the process of requesting data from a remote source — typically a REST or GraphQL API — and incorporating it into your component's rendered output. In React, this usually means: trigger a request, track its loading, success, or error state, and re-render accordingly.

3. đŸ’ģ Client-Side Data Fetching

Client-side fetching happens in the browser, after the component has mounted — typically inside a useEffect hook. The user sees an initial loading state before data arrives.

ClientFetch.jsx

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then(setUser);
  }, [userId]);

  return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}

4. đŸ–Ĩī¸ Server-Side Data Fetching

In frameworks like Next.js or Remix, data can be fetched on the server before the page is sent to the browser, eliminating client-side loading spinners for initial page content and improving SEO.

AspectClient-sideServer-side
Initial loadShows a spinnerData ready on first paint
SEOWeakerStronger
Requires a server?NoYes

5. 🌐 Fetch API

The fetch function is built into modern browsers and returns a Promise resolving to a Response object, which must be explicitly parsed (e.g. via .json()).

FetchExample.jsx

async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error("Failed to fetch user");
  return response.json();
}

Caution

fetch does not reject on HTTP error statuses like 404 or 500 — you must manually check response.ok.

6. 📮 Axios

axios is a popular third-party HTTP client that automatically parses JSON, rejects on error status codes, and offers convenient request/response interceptors.

AxiosExample.jsx

import axios from "axios";

async function getUser(id) {
  const { data } = await axios.get(`/api/users/${id}`);
  return data;
}

7. đŸ“Ĩ Making GET Requests

GET requests retrieve data without modifying anything on the server, and are the default method for both fetch and axios.

GetRequest.jsx

const response = await fetch("/api/products");
const products = await response.json();

8. 📤 Making POST Requests

POST requests typically create a new resource, sending data in the request body.

PostRequest.jsx

const response = await fetch("/api/products", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Desk Lamp", price: 29.99 }),
});

9. âœī¸ Making PUT Requests

PUT requests typically replace an entire existing resource with new data.

PutRequest.jsx

await fetch(`/api/products/${id}`, {
  method: "PUT",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Desk Lamp", price: 24.99, inStock: true }),
});

10. 🩹 Making PATCH Requests

PATCH requests partially update a resource, sending only the fields that changed rather than the entire object.

PatchRequest.jsx

await fetch(`/api/products/${id}`, {
  method: "PATCH",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ price: 19.99 }),
});

11. đŸ—‘ī¸ Making DELETE Requests

DELETE requests remove a resource, typically identified by an URL parameter and requiring no request body.

DeleteRequest.jsx

await fetch(`/api/products/${id}`, { method: "DELETE" });

12. 📋 Request Headers

Headers carry metadata alongside a request — such as content type, authentication tokens, or custom flags expected by the API.

RequestHeaders.jsx

fetch("/api/orders", {
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  },
});

13. đŸ“Ļ Request Body

The body carries the actual payload for POST, PUT, and PATCH requests. With fetch, a JS object must be explicitly serialized with JSON.stringify; axios does this automatically.

RequestBody.jsx

// fetch - manual serialization
body: JSON.stringify({ title: "New post" });

// axios - automatic
axios.post("/api/posts", { title: "New post" });

14. 📨 Response Handling

Always check the status of a response before assuming success, and read the body using the method matching its content type (.json(), .text(), .blob(), etc.).

ResponseHandling.jsx

const response = await fetch("/api/products");

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

const data = await response.json();

15. 🧾 JSON Data

JSON is the most common data format for web APIs. Both fetch (via .json()) and axios (automatically) parse JSON responses into plain JS objects.

16. 🚨 Error Handling

Robust data fetching wraps requests in try/catch, distinguishing between network failures (request never reached the server) and application errors (server responded with an error status).

ErrorHandling.jsx

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error(`Server error: ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error("Failed to fetch user:", error);
    throw error;
  }
}

17. âŗ Loading States

Tracking a loading flag lets you show a spinner or skeleton UI while a request is in flight, avoiding a blank or stale screen.

LoadingState.jsx

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

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

  if (loading) return <p>Loading...</p>;
  return <h1>{user.name}</h1>;
}

18. 📭 Empty States

An empty state is shown when a request succeeds but returns no data — for example, a search with zero matching results. This is distinct from both loading and error states, and should communicate that clearly to the user.

EmptyState.jsx

function SearchResults({ results }) {
  if (results.length === 0) {
    return <p>No results found. Try a different search.</p>;
  }
  return <ul>{results.map((r) => <li key={r.id}>{r.name}</li>)}</ul>;
}

19. 🔁 Retry Mechanisms

Transient network failures can often be resolved by retrying a request — ideally with a short delay, and sometimes with exponential backoff to avoid overwhelming a struggling server.

RetryFetch.jsx

async function fetchWithRetry(url, retries = 3, delay = 500) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error("Bad response");
      return await response.json();
    } catch (error) {
      if (attempt === retries) throw error;
      await new Promise((r) => setTimeout(r, delay * 2 ** attempt));
    }
  }
}

20. 🛑 Request Cancellation

If a component unmounts or its inputs change before a request finishes, the old request should be cancelled to avoid updating state on a stale or unmounted component.

21. đŸŽ›ī¸ AbortController

The native AbortController API lets you cancel an in-flight fetch request by calling .abort() on its associated signal.

AbortController.jsx

function SearchBox({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then((res) => res.json())
      .then(setResults)
      .catch((err) => {
        if (err.name !== "AbortError") console.error(err);
      });

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

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

Best Practice

Always cancel in-flight requests inside a useEffect cleanup function to prevent the classic "race condition" bug, where an old request resolves after a newer one.

22. â¸ī¸ Parallel Requests

When multiple independent requests don't depend on each other's results, fire them simultaneously with Promise.all rather than awaiting them one at a time.

ParallelRequests.jsx

async function loadDashboard() {
  const [user, orders, notifications] = await Promise.all([
    fetch("/api/user").then((r) => r.json()),
    fetch("/api/orders").then((r) => r.json()),
    fetch("/api/notifications").then((r) => r.json()),
  ]);
  return { user, orders, notifications };
}

23. âžĄī¸ Sequential Requests

Sometimes requests must run one after another — for example, when the second request needs an ID returned by the first.

SequentialRequests.jsx

async function loadOrderWithItems() {
  const order = await fetch("/api/orders/latest").then((r) => r.json());
  const items = await fetch(`/api/orders/${order.id}/items`).then((r) => r.json());
  return { order, items };
}

24. 🔗 Dependent Requests

A dependent request is a special case of sequential fetching in a component: one query's data determines whether — or with what parameters — the next one should run.

DependentQuery.jsx

function useUserPosts(userId) {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    if (!userId) return; // wait until we have a userId
    fetch(`/api/users/${userId}/posts`)
      .then((res) => res.json())
      .then(setPosts);
  }, [userId]);

  return posts;
}

25. 📄 Pagination

Pagination splits large datasets into discrete pages, typically controlled via a page or offset query parameter.

Pagination.jsx

function ProductList() {
  const [page, setPage] = useState(1);
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch(`/api/products?page=${page}&limit=20`)
      .then((res) => res.json())
      .then((data) => setProducts(data.items));
  }, [page]);

  return (
    <>
      <ProductGrid products={products} />
      <button onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
      <button onClick={() => setPage((p) => p + 1)}>Next</button>
    </>
  );
}

26. â™žī¸ Infinite Scrolling

Infinite scrolling appends new pages of data as the user scrolls, rather than replacing the current page — typically triggered by an IntersectionObserver watching a sentinel element.

InfiniteScroll.jsx

function useInfiniteProducts() {
  const [products, setProducts] = useState([]);
  const [page, setPage] = useState(1);

  const loadMore = async () => {
    const res = await fetch(`/api/products?page=${page}`);
    const data = await res.json();
    setProducts((prev) => [...prev, ...data.items]);
    setPage((p) => p + 1);
  };

  return { products, loadMore };
}

27. 🔄 Polling

Polling repeatedly re-fetches data on a fixed interval — a simple way to approximate real-time updates when true push-based updates (like WS) aren't available.

Polling.jsx

function LiveOrderStatus({ orderId }) {
  const [status, setStatus] = useState(null);

  useEffect(() => {
    const interval = setInterval(async () => {
      const res = await fetch(`/api/orders/${orderId}/status`);
      setStatus(await res.json());
    }, 5000);

    return () => clearInterval(interval);
  }, [orderId]);

  return <p>Status: {status?.value ?? "Loading..."}</p>;
}

28. 📡 Real-Time Data

For truly instantaneous updates, WebSocket or Server-Sent Events push data to the client as soon as it changes, avoiding the delay and overhead of repeated polling.

WebSocketExample.jsx

function LiveChat({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const socket = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);

    socket.onmessage = (event) => {
      setMessages((prev) => [...prev, JSON.parse(event.data)]);
    };

    return () => socket.close();
  }, [roomId]);

  return <MessageList messages={messages} />;
}

29. đŸ—„ī¸ Caching

Caching stores previously-fetched data so it can be reused instantly on subsequent renders or navigations, avoiding redundant network requests.

Tip

Hand-rolled caching (e.g. a simple in-memory Map keyed by URL) works for small apps, but libraries like TanStack Query (Section 32) handle cache invalidation, staleness, and garbage collection far more robustly.

30. â™ģī¸ Data Revalidation

Revalidation refreshes cached data in the background — for example, re-fetching whenever the browser window regains focus — so users see up-to-date information without an explicit manual refresh.

31. ⚡ Optimistic Updates

An optimistic update immediately updates the UI as though a mutation succeeded, then rolls back if the server request actually fails — making the app feel instantaneous.

OptimisticUpdate.jsx

async function toggleLike(postId, currentlyLiked, setPosts) {
  setPosts((prev) => updateLikeState(prev, postId, !currentlyLiked)); // optimistic

  try {
    await fetch(`/api/posts/${postId}/like`, { method: "POST" });
  } catch (error) {
    setPosts((prev) => updateLikeState(prev, postId, currentlyLiked)); // rollback
  }
}

32. đŸ”Ĩ TanStack Query (React Query)

TanStack Query (formerly React Query) is a dedicated data-fetching library that manages caching, background revalidation, retries, and loading/error states automatically via its useQuery hook.

TanStackQuery.jsx

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

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

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Something went wrong.</p>;
  return <h1>{data.name}</h1>;
}

See the TanStack Query documentation for the full API.

33. đŸĒ SWR

SWR — named after the "stale-while-revalidate" caching strategy — is a lightweight alternative to TanStack Query from the makers of Next.js, with a very similar hook-based API.

SwrExample.jsx

import useSWR from "swr";

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

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

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Failed to load user.</p>;
  return <h1>{data.name}</h1>;
}

34. 🧩 Custom Data Fetching Hooks

Wrapping repeated fetch logic in a custom hook keeps components focused on rendering, and makes the fetching logic reusable and testable in isolation.

useFetch.jsx

function useFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, loading: true, error: null });

    fetch(url, { signal: controller.signal })
      .then((res) => res.json())
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((error) => {
        if (error.name !== "AbortError") {
          setState({ data: null, loading: false, error });
        }
      });

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

  return state;
}

35. 🔑 Authentication Requests

Authenticated requests typically attach a token — often a JWT — as an Authorization header on every request, commonly centralized via an axios interceptor or a wrapped fetch helper.

AuthRequest.jsx

const api = axios.create({ baseURL: "/api" });

api.interceptors.request.use((config) => {
  const token = localStorage.getItem("token");
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

Warning

Storing tokens in localStorage is convenient but vulnerable to XSS attacks; an httpOnly cookie is generally a more secure choice for sensitive tokens.

36. 📎 File Uploads

File uploads use FormData to send binary content alongside other fields, with the browser automatically setting the correct multipart/form-data content type.

FileUpload.jsx

async function uploadAvatar(file) {
  const formData = new FormData();
  formData.append("avatar", file);

  const response = await fetch("/api/upload", {
    method: "POST",
    body: formData, // do NOT set Content-Type manually
  });
  return response.json();
}

37. 💾 File Downloads

Downloading a file fetched via JS involves reading the response as a Blob, then triggering a browser download via a temporary object URL.

FileDownload.jsx

async function downloadReport() {
  const response = await fetch("/api/reports/latest");
  const blob = await response.blob();
  const url = URL.createObjectURL(blob);

  const link = document.createElement("a");
  link.href = url;
  link.download = "report.pdf";
  link.click();
  URL.revokeObjectURL(url);
}

38. ⚡ Performance Optimization

  • Fetch data in parallel whenever requests don't depend on each other (Section 22)
  • Cache responses to avoid redundant network round-trips for unchanged data
  • Debounce fetches triggered by rapid input, like search-as-you-type
  • Cancel stale in-flight requests with AbortController to avoid wasted bandwidth and race conditions
  • Paginate or virtualize large result sets rather than fetching everything at once

39. 🔒 Security Considerations

  • Never expose secret API keys in client-side code — proxy sensitive calls through your own backend
  • Validate and sanitize data on the server, since client-side checks can always be bypassed
  • Use HTTPS for all requests carrying sensitive data
  • Be mindful of CORS policies when calling third-party APIs directly from the browser

40. 🔷 TypeScript with Data Fetching

Typing fetch responses ensures your components can't accidentally reference a field the API doesn't actually return.

TypedFetch.tsx

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

async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error("Failed to fetch user");
  return response.json() as Promise<User>;
}

41. 🏆 Best Practices

  1. Always handle loading, error, and empty states explicitly — never assume the happy path
  2. Cancel in-flight requests on unmount or dependency change to avoid stale updates
  3. Prefer a dedicated library like TanStack Query or SWR once caching and revalidation logic becomes non-trivial
  4. Centralize authenticated request logic (headers, base URL) rather than repeating it per call
  5. Keep fetching logic in custom hooks, separate from rendering logic

42. âš ī¸ Common Mistakes

  • Forgetting that fetch doesn't reject on HTTP error codes, silently treating a 404 as success
  • Not cancelling requests, causing "Can't update state on an unmounted component" warnings or race conditions
  • Fetching sequentially when requests could safely run in parallel, hurting performance
  • Storing fetched data directly in state without handling the case where the component re-fetches for a new ID mid-flight
  • Re-implementing caching, retries, and revalidation by hand instead of adopting a mature library

Danger

A very common bug: fetching data based on a prop (like userId) without cancelling the previous request. If the prop changes quickly, an older response can resolve after a newer one and overwrite it with stale data.

43. đŸ’Ŧ Frequently Asked Questions

Should I use fetch, axios, or a library like TanStack Query?

For simple, one-off requests, fetch is sufficient. For apps with many interdependent API calls, caching needs, or complex loading states, TanStack Query or SWR saves substantial boilerplate.

Do I still need useEffect if I use TanStack Query?

No — useQuery handles the fetch-on-mount and re-fetch-on-dependency-change behavior internally, replacing the manual useEffect pattern entirely.

How do I avoid showing a loading spinner on every re-fetch?

Libraries like TanStack Query expose a separate isFetching flag (background re-fetch) distinct from isLoading (no cached data yet), letting you show stale data while quietly refreshing it.

44. 📌 Summary

>>The best data fetching code is invisible to the user — they just see fast, reliable, up-to-date information.