ReactData Fetching Basics

Data Fetching Basics

Almost every real-world React application needs to load data from an external source — a REST API, a GraphQL endpoint, or even a local JSON file. On the surface it looks simple: call fetch, get data, show it. In practice, data fetching is one of the trickiest parts of React because you have to coordinate three distinct states, avoid memory leaks, handle race conditions, and keep the UI responsive throughout.

The Three States of Every Fetch

Every network request has three possible outcomes that your UI must handle explicitly. Ignoring any one of them produces a broken experience:

State

What happened

What the UI should show

loading

Request is in-flight

Skeleton, spinner, or placeholder

success

Data arrived successfully

The actual content

error

Network failure or non-2xx response

Error message, retry button

The Basic useEffect + fetch Pattern

The standard way to fetch data in a client component is to combine useEffect (to trigger the fetch when the component mounts) with useState (to store the three states). Here is the complete, correct implementation:

JSX
import { useState, useEffect } from 'react'

function UserList() {
  const [users, setUsers]     = useState([])
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError]     = useState(null)

  useEffect(() => {
    let cancelled = false   // flag for cleanup

    async function fetchUsers() {
      try {
        setIsLoading(true)
        setError(null)

        const res = await fetch('https://jsonplaceholder.typicode.com/users')

        if (!res.ok) {
          throw new Error(`HTTP error — status ${res.status}`)
        }

        const data = await res.json()

        if (!cancelled) {       // only update state if still mounted
          setUsers(data)
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message)
        }
      } finally {
        if (!cancelled) {
          setIsLoading(false)
        }
      }
    }

    fetchUsers()

    return () => {
      cancelled = true          // cleanup: ignore stale responses
    }
  }, [])   // empty array → run once on mount

  if (isLoading) return <p>Loading users…</p>
  if (error)     return <p>Error: {error}</p>

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  )
}
Loading Skeleton UIs

A plain "Loading…" text works, but users expect something that resembles the final layout while data loads. A skeleton UI is a low-fidelity placeholder that mirrors the shape of the real content:

JSX
function UserCardSkeleton() {
  return (
    <div className="card skeleton">
      <div className="skeleton-avatar" />
      <div className="skeleton-line" style={{ width: '60%' }} />
      <div className="skeleton-line" style={{ width: '40%' }} />
    </div>
  )
}

function UserList() {
  const [users, setUsers]     = useState([])
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError]     = useState(null)

  // ... fetch logic as above ...

  if (isLoading) {
    return (
      <div>
        {Array.from({ length: 5 }).map((_, i) => (
          <UserCardSkeleton key={i} />
        ))}
      </div>
    )
  }

  // ... rest of render
}
Note
Skeleton UIs dramatically improve perceived performance. Even though the data takes the same time to load, users rate skeleton-loading apps as faster because the layout doesn't "jump" when data arrives.
Error Handling with try/catch

Two categories of errors can occur during a fetch. You must handle both:

  • Network errors — the request never completed (no internet, DNS failure, CORS). These throw an exception that catch receives.

  • HTTP errors — the server responded, but with a 4xx or 5xx status code. fetch does NOT throw for these — you must check res.ok or res.status manually.

JSX
// Common mistake: only catching network errors
try {
  const res = await fetch('/api/user')
  const data = await res.json()   // ← succeeds even for a 404!
  setUser(data)
} catch (err) {
  setError(err.message)   // ← 404/500 responses never reach here
}

// Correct: check res.ok too
try {
  const res = await fetch('/api/user')

  if (!res.ok) {
    throw new Error(`Request failed: ${res.status} ${res.statusText}`)
  }

  const data = await res.json()
  setUser(data)
} catch (err) {
  setError(err.message)   // catches both network errors AND bad HTTP status
}
Warning
fetch() only rejects its promise for network-level failures. A 404, 500, or any other HTTP error code still resolves the promise — you must manually check res.ok or res.status to detect them.
AbortController for Cleanup

If a component unmounts while a request is still in-flight (user navigates away), the fetch will complete and call setState on an unmounted component. React 18 suppresses the resulting warning, but updating unmounted component state is still a logic error. The correct fix is AbortController:

JSX
useEffect(() => {
  const controller = new AbortController()

  async function fetchData() {
    try {
      const res = await fetch('/api/data', {
        signal: controller.signal   // ← pass the abort signal to fetch
      })
      const data = await res.json()
      setData(data)
    } catch (err) {
      if (err.name === 'AbortError') return   // intentional abort, ignore
      setError(err.message)
    }
  }

  fetchData()

  return () => controller.abort()   // cleanup: cancel the in-flight request
}, [])
Race Conditions

A race condition occurs when two requests are made in quick succession and the earlier request finishes after the later one. The stale result overwrites the fresh one, leaving the UI out of sync:

JSX
// ⚠️  Race condition: user clicks "Alice" then "Bob" rapidly
// Fetch for Alice might complete after Bob's fetch → Alice's data shown

// Fix with AbortController (aborting the previous request)
useEffect(() => {
  const controller = new AbortController()

  fetch(`/api/user/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setUser(data))
    .catch(err => {
      if (err.name !== 'AbortError') setError(err.message)
    })

  return () => controller.abort()   // cancel previous request when userId changes
}, [userId])   // ← re-run whenever userId changes
Note
AbortController solves both the unmount problem and the race condition problem. Make it your default pattern whenever you fetch inside useEffect.
Server vs Client Data Fetching

The pattern above is client-side fetching — the browser makes the request after the page has loaded. When using Next.js with React Server Components, you can fetch data on the server instead:

JSX
// Next.js Server Component — runs only on the server
// No useEffect, no useState, no loading state needed
export default async function UserList() {
  const res = await fetch('https://api.example.com/users', {
    next: { revalidate: 60 }   // ISR: refresh every 60 seconds
  })

  if (!res.ok) throw new Error('Failed to fetch users')

  const users = await res.json()

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

Client Fetching (useEffect)

Server Fetching (RSC)

Where it runs

Browser, after page loads

Server, before HTML is sent

Loading state

Required

Not needed (Suspense optional)

SEO

Poor (content arrives after crawl)

Excellent

Bundle size

Fetch logic in JS bundle

Zero client JS

Auth / secrets

Exposed to client

Safe on server

When to use

Dynamic user-specific data, real-time

Static or ISR content

Why Libraries Exist for This

Writing the full pattern manually — loading state, error state, cleanup, race condition prevention, caching, deduplication, background refresh, pagination — is tedious and error-prone. That is exactly why libraries like TanStack Query (React Query) and SWR exist. They implement all of this correctly so you don't have to. For anything beyond trivial one-off fetches, reach for one of them.

Tip
For small apps or learning, the manual useEffect pattern is fine. For production apps with multiple data requirements, TanStack Query or SWR will save you weeks of debugging subtle caching and race-condition bugs.