Skip to content

React Tutorial Data and APIs

Empty Data States and a Reusable useFetch Hook

Lesson 94 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

Runnable lesson · press Try it on the examples to edit and run them here.

You now know three separate states for fetched data: loading, error and success. Success has a fourth cousin that deserves its own treatment: empty, when the request worked but there is nothing to show. This lesson puts all four together and extracts them into a reusable hook.

Empty is not the same as loading or error

code
PLAIN
loading  - we are still waiting for an answer
error    - the request failed
empty    - the request worked and returned zero items
success  - the request worked and returned items

Showing “No orders yet” while still loading is a lie, and a blank page after a failure hides the problem. Keep them apart. The messages for empty lists are covered in Empty States and Conditional Lists.

Four states in one component

App.jsx
JSX
function fakeApi() {
  return new Promise((resolve) => setTimeout(() => resolve([]), 500)); // zero orders
}

function App() {
  const [status, setStatus] = React.useState("loading");
  const [orders, setOrders] = React.useState([]);

  React.useEffect(() => {
    let ignore = false;
    fakeApi()
      .then((data) => { if (!ignore) { setOrders(data); setStatus("success"); } })
      .catch(() => { if (!ignore) setStatus("error"); });
    return () => { ignore = true; };
  }, []);

  if (status === "loading") return <p>Loading</p>;
  if (status === "error") return <p role="alert">Could not load orders.</p>;
  if (orders.length === 0) return <p>No orders yet. When you buy something it will appear here.</p>;

  return <ul>{orders.map((o) => <li key={o.id}>{o.title}</li>)}</ul>;
}

Early returns keep each case flat and readable.

Extract the repeated work into a hook

The code for status, data, error and cancelling is the same for every request. A custom hook (Custom Hooks) removes the repetition:

App.jsx
JSX
function useFetch(fetcher) {
  const [state, setState] = React.useState({ status: "loading", data: null, error: null });
  const [reloadKey, setReloadKey] = React.useState(0);

  React.useEffect(() => {
    let ignore = false;
    setState((s) => ({ ...s, status: "loading", error: null }));

    fetcher()
      .then((data) => { if (!ignore) setState({ status: "success", data, error: null }); })
      .catch((error) => { if (!ignore) setState({ status: "error", data: null, error }); });

    return () => { ignore = true; };
  }, [reloadKey]);   // fetcher is expected to be stable; see the note below

  const reload = () => setReloadKey((k) => k + 1);
  return { ...state, reload };
}

function fakeUsers() {
  return new Promise((resolve) => setTimeout(() => resolve([{ id: 1, name: "Ada" }, { id: 2, name: "Grace" }]), 500));
}

function UserList() {
  const { status, data, error, reload } = useFetch(fakeUsers);

  return (
    <div>
      <button onClick={reload}>Reload</button>
      {status === "loading" && <p>Loading</p>}
      {status === "error" && <p role="alert">Failed: {error.message}</p>}
      {status === "success" && data.length === 0 && <p>No users</p>}
      {status === "success" && data.length > 0 && (
        <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
      )}
    </div>
  );
}

function App() {
  return <UserList />;
}

UserList now says what it needs and renders each state. The hook handles how: cancelling stale results, resetting and reloading.

info
A simplified hook
This useFetch ignores a changing fetcher argument on purpose, to stay short. A real version takes a URL (a string, which can safely be a dependency) or uses a ref to the latest function. In production code, prefer a tested library such as TanStack Query or SWR, which also handle caching, retries and refetching on focus. See Data-Fetching Libraries.

A small AsyncState component

If every list renders the same four states, a component can do it for you:

App.jsx
JSX
function Async({ status, error, isEmpty, empty, children }) {
  if (status === "loading") return <p>Loading</p>;
  if (status === "error") return <p role="alert">Could not load: {error.message}</p>;
  if (isEmpty) return empty;
  return children;
}

Use it like <Async status={status} error={error} isEmpty={data?.length === 0} empty={<p>Nothing here.</p>}>...</Async>. Keep these helpers small; do not hide too much behind a clever abstraction.

The question to ask for each screen

For every data-driven screen, write down what it shows in all four states. If you cannot, the design is not finished. Real users meet loading, error and empty states more often than designers expect.

Key points

  • Loading, error, empty and success are four different situations; give each its own UI.
  • Early returns keep the cases readable.
  • A useFetch-style hook removes repeated fetching code; libraries do it more completely.