Home/Learn/React/Fetching Data — And Why It Is Harder Than It Looks

Fetching Data — And Why It Is Harder Than It Looks

Intermediate
Data

The three-line version in an effect is a demo. The production version handles loading, errors, races, cancellation, refetching and caching — which is why a library usually wins.

Overview

Fetching in an effect looks trivial, and every tutorial shows it. What the tutorial omits is everything that makes it correct: what the user sees while it is in flight, what happens on failure, what happens when the parameter changes mid-request, whether the component is still mounted, and whether the same data is refetched by three components on the same screen. Writing this by hand once is worth doing, because it explains exactly which problems a data library is solving and why "I will just use useEffect" stops scaling around the third screen.

The Complete Manual Version

Everything the three-line version leaves out.

Correct, and already long
function useProblems(topic) {
  const [state, setState] = useState({ status: 'loading' })

  useEffect(() => {
    const controller = new AbortController()
    setState({ status: 'loading' })

    fetch(`/api/problems?topic=${encodeURIComponent(topic)}`, {
      signal: controller.signal,
    })
      .then(res => {
        if (!res.ok) throw new ApiError(res.status)   // fetch does NOT reject on 4xx
        return res.json()
      })
      .then(data => setState({ status: 'success', data }))
      .catch(err => {
        if (err.name === 'AbortError') return         // superseded, not a failure
        setState({ status: 'error', error: err })
      })

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

  return state
}

// That is the minimum for ONE endpoint. Now add: retry, caching
// across components, refetch on window focus, invalidation after a
// mutation, and pagination.

Rendering Every State

Four states, all of which the user will see. Skipping one is the most common review comment on junior code.

Loading, error, empty, success
const { status, data, error } = useProblems(topic)

if (status === 'loading') return <ProblemsSkeleton />
if (status === 'error')   return <ErrorState error={error} onRetry={refetch} />
if (data.length === 0)    return <EmptyState topic={topic} />
return <ProblemList problems={data} />

// The four, always:
//   loading  — a skeleton matching the final layout beats a spinner,
//              because it does not shift the page when data arrives
//   error    — say what failed and offer a way to retry
//   empty    — "no problems yet" is a different message from an error
//   success  — the actual UI

// A common miss: data && data.map(...) with no empty branch, so an
// empty result renders a blank screen that looks broken.

Where the Request Belongs

Keep fetch calls out of components, and keep the shape honest.

An api module per resource, and avoid waterfalls
// features/problems/api.js — one place per resource
export async function listProblems({ topic, page = 1, signal }) {
  const url = new URL('/api/problems', API_BASE)
  url.searchParams.set('topic', topic)
  url.searchParams.set('page', String(page))

  const res = await fetch(url, { signal, credentials: 'include' })
  if (!res.ok) throw new ApiError(res.status, await res.text())
  return res.json()
}

// The component never builds a URL or reads a status code.
// Benefits: one place to add auth headers, one place to change the
// base URL, and the function is testable on its own.

// Waterfalls — the performance bug of nested fetching:
// <Page> fetches user, then <Profile> fetches settings using user.id,
// then <Avatar> fetches the image. Three sequential round trips.
// Fetch in parallel where the requests are independent:
const [user, problems] = await Promise.all([getUser(), listProblems()])

Key Points to Remember

  • 1fetch does not reject on 4xx or 5xx — check res.ok explicitly or errors pass silently
  • 2A correct manual fetch needs abort, race protection, and a status for each outcome
  • 3Render all four states: loading, error, empty and success — a missing empty state looks like a bug
  • 4A skeleton that matches the final layout avoids the layout shift a spinner causes
  • 5Keep fetch calls in a per-resource api module, and parallelise independent requests to avoid waterfalls

Interview Questions

Sign in to ask Aria
1

What does a production-quality fetch inside useEffect need that a tutorial version omits?

Hard
2

Why does fetch not throw on a 404 response?

Easy
3

What is a request waterfall and how do you avoid one?

Medium

Ask Aria about Fetching Data — And Why It Is Harder Than It Looks

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…