Data Fetching Layering

Fetch remote data at the route or page level (or in a dedicated data layer) and pass it downward; avoid fetching deep in leaf widgets.

Why?  Centralises loading/error handling and enables streaming / Suspense.

If every little widget fires its own network request, you get a waterfall of spinners, duplicated calls, and tangled loading/error states. Worse, those leaf‑level fetches are hard to coordinate—try showing a single “Loading…” skeleton when five children fetch independently! Centralising data‑loading in a parent (route, page, or data layer) lets you fetch once, handle errors in one place, and stream structured data to child components.

Correct Example

// UsersPage.tsx  – page‑level component controls data fetch & state
import { useEffect, useState } from "react";
import UsersTable from "./UsersTable";

type User = { id: number; name: string; email: string };

export default function UsersPage() {
  const [users, setUsers] = useState<User[]>([]);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/users")
      .then((res) => (res.ok ? res.json() : Promise.reject(res)))
      .then(setUsers)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>Loading users…</p>;
  if (error) return <p className="text-red-600">Failed: {error.message}</p>;

  return <UsersTable users={users} />;
}
// UsersTable.tsx  – presentational; receives ready‑to‑render data
type User = { id: number; name: string; email: string };

export default function UsersTable({ users }: { users: User[] }) {
  return (
    <table className="w-full border-collapse">
      <thead>
        <tr className="bg-stone-100 text-left">
          <th className="p-2">Name</th>
          <th className="p-2">Email</th>
        </tr>
      </thead>
      <tbody>
        {users.map((u) => (
          <tr key={u.id} className="border-t">
            <td className="p-2">{u.name}</td>
            <td className="p-2">{u.email}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

What’s happening? UsersPage fetches once, manages loading/error UI, then hands clean data to UsersTable, which remains fetch‑agnostic and easy to reuse, test, or server‑render.

Incorrect Example

// UsersTableFetchesItself.tsx  – every table instance fires a request
import { useEffect, useState } from "react";

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

  useEffect(() => {
    fetch("/api/users") // ❌ leaf‑level fetch
      .then((res) => res.json())
      .then(setUsers)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>Loading…</p>;

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

// Used twice on the same page? Two identical network calls run.
// Want a single skeleton? You now need cross‑component coordination.

Key Takeaways

  • Single source of data: Fetch at the highest common parent (route, page, or dedicated provider) to avoid duplicate requests.
  • Unified loading/error handling: One spinner and one error boundary are clearer than many scattered ones.
  • Leaf components stay pure: Children focus on presentation, making them simpler to test, memoise, and reuse offline.
  • Enables streaming and Suspense: Central control lets you adopt React Suspense or server streaming without rewriting every widget.
  • Performance wins: Fewer redundant calls, better caching opportunities, and less “jank” from staggered spinners.