Data Fetching Patterns

React · Lesson 22 of 29 7 min read Updated

Almost every real app shows data from a server: products, orders, exam results, weather. React itself does not include a data-fetching tool, so there are several patterns, from a simple fetch inside an effect to libraries and frameworks that cache data for you.

In this lesson you will build the classic effect-based pattern with loading and error states, learn why "waterfalls" make apps slow, and see the modern options: TanStack Query, router loaders, use() with Suspense, and Server Components. Choosing the right one matters more than any single line of code.

Pattern 1: fetch in an effect

The most basic pattern stores three things in state: the data, whether it is loading, and any error. An effect starts the request when the component appears or when an input such as the user id changes. The examples below call jsonplaceholder.typicode.com, a free fake API for practice.

React JSXTry it yourself
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";

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

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    async function load() {
      try {
        const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`Server answered ${res.status}`);
        setUser(await res.json());
      } catch (err) {
        if (err.name !== "AbortError") setError(err.message);
      } finally {
        if (!controller.signal.aborted) setLoading(false);
      }
    }
    load();

    return () => controller.abort(); // cancel if userId changes or the card disappears
  }, [userId]);

  if (loading) return <p>Loading user {userId}...</p>;
  if (error) return <p style={{ color: "crimson" }}>Could not load: {error}</p>;
  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}, {user.address.city}</p>
    </div>
  );
}

function App() {
  const [id, setId] = useState(1);
  return (
    <>
      {[1, 2, 3, 99].map((n) => (
        <button key={n} onClick={() => setId(n)}>User {n}</button>
      ))}
      <UserCard userId={id} />
    </>
  );
}

createRoot(document.getElementById("root")).render(<App />);

Click "User 99" to see the error branch: that user does not exist, so the server answers with status 404. Four details make this version solid:

  • Check res.ok. fetch only rejects on network failure; a 404 or 500 still "succeeds", so you must check the status yourself.
  • Abort in the cleanup. When userId changes quickly, the old request is cancelled, which also prevents the race condition you met in useEffect & Side Effects.
  • The async function lives inside the effect, because the effect itself must not be async.
  • Every branch renders something: loading, error and success.

This pattern is fine for learning and small apps. Its weaknesses show as an app grows: no caching (going back to a page fetches again), repeated boilerplate, no retry, and data that only starts loading after the component has rendered.

Avoid waterfalls

A waterfall happens when requests wait for each other without needing to. A parent fetches the user, renders, and only then does a child start fetching that user's posts. Each step adds a full round trip. When requests are independent, start them together:

React JSXTry it yourself
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";

const api = "https://jsonplaceholder.typicode.com";

function Profile({ userId }) {
  const [result, setResult] = useState(null);

  useEffect(() => {
    let ignore = false;
    const started = performance.now();

    Promise.all([
      fetch(`${api}/users/${userId}`).then((r) => r.json()),
      fetch(`${api}/posts?userId=${userId}&_limit=3`).then((r) => r.json()),
    ]).then(([user, posts]) => {
      if (!ignore) {
        setResult({ user, posts, ms: Math.round(performance.now() - started) });
      }
    });

    return () => {
      ignore = true;
    };
  }, [userId]);

  if (!result) return <p>Loading profile...</p>;
  return (
    <div>
      <h3>{result.user.name}'s latest posts</h3>
      <ol>{result.posts.map((p) => <li key={p.id}>{p.title}</li>)}</ol>
      <small>Both requests together took {result.ms} ms</small>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Profile userId={3} />);

Promise.all sends both requests at the same moment, so the total time is roughly the slower of the two, not their sum. The deeper fix for waterfalls is to start loading before rendering, which is exactly what routers and frameworks do.

Pattern 2: a data library (TanStack Query)

For apps that read a lot of server data, a library such as TanStack Query (@tanstack/react-query) replaces the hand-written effect. You describe what to fetch with a key and a function; the library handles caching, de-duplication, retries, background refresh and loading states:

React JSX
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";

const queryClient = new QueryClient();

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

  if (isPending) return <p>Loading...</p>;
  if (error) return <p>Could not load: {error.message}</p>;
  return <h3>{user.name}</h3>;
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserCard userId={1} />
    </QueryClientProvider>
  );
}

Two components asking for ["user", 1] share one request and one cached result. Visiting the page again shows the cached data instantly while a fresh copy loads in the background. SWR is another popular library with a similar idea.

Pattern 3: load before rendering

The best moment to start a request is before the component renders, as soon as you know which page the user is going to. Routers and frameworks support this:

  • Router loaders. React Router lets each route declare a loader function; the router starts it during navigation and the page reads the result with useLoaderData(). See Routing with React Router.
  • use() with Suspense. Start a cached promise early (for example in an event handler) and read it with use(promise), as in Suspense, lazy & use().
  • Server Components. In frameworks such as Next.js, a component can be async and fetch on the server, so the browser receives finished HTML and no fetching code at all:
React JSX
// A Server Component (Next.js App Router)
export default async function OrdersPage() {
  const orders = await db.order.findMany({ take: 10 }); // runs on the server only
  return <ul>{orders.map((o) => <li key={o.id}>{o.item}</li>)}</ul>;
}

You will look at this model in Server Components & Next.js.

Sending data

Reading is half the story. To create or change data, send a request with a method and a body, usually from an event handler or a form action:

React JSX
async function placeOrder(formData) {
  const res = await fetch("/api/orders", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ item: formData.get("item"), qty: Number(formData.get("qty")) }),
  });
  if (!res.ok) throw new Error("Order failed");
}

Common mistakes

  • Not checking res.ok. A 404 page is not a network error, so fetch does not throw.
  • Missing loading or error states. Users see a blank screen or a crash when the API is slow or down.
  • Forgetting cleanup. Without abort or an ignore flag, a slow old response can overwrite newer data.
  • Fetching in every component separately. The same data is downloaded many times; use a cache, a library, a loader or lift the request up.
  • Sequential requests that could run in parallel. Use Promise.all for independent requests.

Key points

  • The basic pattern is fetch inside useEffect with data, loading and error state, plus abort in the cleanup.
  • Always check res.ok and show loading and error UI.
  • Waterfalls make apps slow; start independent requests together with Promise.all.
  • Libraries such as TanStack Query add caching, retries and shared requests.
  • Router loaders, use() with cached promises and Server Components start loading earlier.
  • Never ship secret keys in React code; the browser can read everything.

Exercise

Using https://jsonplaceholder.typicode.com/todos?userId=1, build a TodoSummary component that loads the to-dos in an effect with an AbortController, shows "Loading..." while waiting, an error message if res.ok is false, and otherwise "Done: X of Y" plus the titles of the first five unfinished to-dos.

Show solution
React JSX
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";

function TodoSummary() {
  const [todos, setTodos] = useState(null);
  const [error, setError] = useState("");

  useEffect(() => {
    const controller = new AbortController();
    fetch("https://jsonplaceholder.typicode.com/todos?userId=1", { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`Server answered ${res.status}`);
        return res.json();
      })
      .then(setTodos)
      .catch((err) => {
        if (err.name !== "AbortError") setError(err.message);
      });
    return () => controller.abort();
  }, []);

  if (error) return <p>Could not load: {error}</p>;
  if (!todos) return <p>Loading...</p>;

  const done = todos.filter((t) => t.completed).length;
  const pending = todos.filter((t) => !t.completed).slice(0, 5);

  return (
    <div>
      <p>Done: {done} of {todos.length}</p>
      <ul>{pending.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<TodoSummary />);

todos being null means "still loading", the error has its own state, and the counts are derived from the loaded array during render.

Quick Quiz

1. fetch receives a 404 response. What happens?

2. What is a request waterfall?

3. Why abort the request in the effect's cleanup?

4. Where is it safe to keep a secret API key?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call