ToolPopToolPop
React · Lesson 7 of 8

Fetching data the right way, loading, error, empty

12 min readUpdated 24 Jun 2026

Real React apps mostly fetch JSON, show it, and let the user poke at it. The Swiggy home page is "restaurants near me". The IRCTC train list is "trains between A and B". Same shape, different data.

Let us build the smallest version that does not embarrass you in code review.

Diagram
rendering diagram...
Three states every fetch component must render: loading, error, empty

The classic pattern

jsx
function NearbyRestaurants() {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    fetch('/api/restaurants?city=Bengaluru')
      .then(r => r.json())
      .then(setData);
  }, []);
 
  return <ul>{data?.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}

This works. This is also where 70% of bugs live, because it ignores three states.

The three states everyone forgets

A network request is not one thing. It is at least four.

  • Loading: request is in flight. Show a skeleton or spinner.
  • Error: network died, server returned 500. Show a retry button.
  • Empty: request succeeded but came back with zero items. Show a "no restaurants nearby" message, not a blank screen.
  • Success: finally, the list.
jsx
function NearbyRestaurants() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    fetch('/api/restaurants?city=Bengaluru')
      .then(r => {
        if (!r.ok) throw new Error('Server boom');
        return r.json();
      })
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);
 
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Could not load. Try again?</p>;
  if (!data?.length) return <p>No restaurants near you.</p>;
  return <ul>{data.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}

That is the minimum bar. Skip any of these and your users hit a blank white screen on bad Jio days.

Cleanup, because users navigate

User opens the restaurants page, immediately taps back. The fetch resolves two seconds later and calls setData on an unmounted component. React warns, and worse, you may overwrite state in the new component.

jsx
useEffect(() => {
  const controller = new AbortController();
  fetch('/api/restaurants', { signal: controller.signal })
    .then(r => r.json())
    .then(setData)
    .catch(e => e.name !== 'AbortError' && setError(e));
  return () => controller.abort();
}, []);

AbortController cancels the in-flight request when the effect cleans up. Use it. Always.

Now please stop writing this yourself

In 2026, almost nobody writes raw useEffect + fetch for production data. They use TanStack Query (formerly React Query) or SWR.

jsx
const { data, isLoading, error } = useQuery({
  queryKey: ['restaurants', city],
  queryFn: () => fetch(`/api/restaurants?city=${city}`).then(r => r.json()),
});

You get for free:

  • Loading, error, success states.
  • Caching. Same query in two components hits the network once.
  • Refetch on focus. User comes back to the tab, data refreshes.
  • Cancellation, retries, pagination.

Hand-rolling fetch is fine for a one-off. The moment a second component needs the same data, install TanStack Query and stop suffering.

What to remember

  • Raw fetch + useEffect: learn it once so you know what is happening.
  • TanStack Query / SWR: what you actually ship.
  • Never trust the happy path. Loading, error, empty, success. Always all four.

Last lesson next: routing and getting your app on the internet.

Free tools you can use while you learn

Chai0/1 done

Watching quietly. Tap me if you want a tip.

React Playground
preview

Try this (0 of 1 done)

  1. 1

    Add an error state. If fetch fails, show "could not load".

    show answer
    function App() {
      const [data, setData] = React.useState(null);
      const [loading, setLoading] = React.useState(true);
      const [err, setErr] = React.useState(null);
      React.useEffect(() => {
        fetch('https://api.github.com/zen')
          .then(r => r.text())
          .then(t => { setData(t); setLoading(false); })
          .catch(() => { setErr(true); setLoading(false); });
      }, []);
      if (loading) return <p>loading...</p>;
      if (err) return <p>could not load</p>;
      return <blockquote>{data}</blockquote>;
    }