ReactFetching with useEffect

Fetching with useEffect

useEffect is the standard way to perform side effects — including data fetching — in function components. It runs after the component renders, making it the right place to trigger network requests. But getting it right requires understanding dependency arrays, cleanup, and the subtle ordering guarantees React provides.

Complete User Profile Fetcher

The following component fetches a user profile and re-fetches automatically whenever the userId prop changes — a common pattern for detail pages:

JSX
import { useState, useEffect } from 'react'

function UserProfile({ userId }) {
  const [user, setUser]         = useState(null)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError]       = useState(null)

  useEffect(() => {
    if (!userId) return   // nothing to fetch

    const controller = new AbortController()
    setIsLoading(true)
    setError(null)
    setUser(null)   // clear previous user while loading new one

    async function fetchUser() {
      try {
        const res = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`,
          { signal: controller.signal }
        )

        if (!res.ok) {
          throw new Error(`Could not load user ${userId} (HTTP ${res.status})`)
        }

        const data = await res.json()
        setUser(data)
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message)
        }
      } finally {
        setIsLoading(false)
      }
    }

    fetchUser()

    return () => controller.abort()   // cancel on userId change or unmount
  }, [userId])   // ← re-run whenever userId changes

  if (isLoading) return <p>Loading user {userId}…</p>
  if (error)     return <p style={{ color: 'red' }}>{error}</p>
  if (!user)     return null

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.phone}</p>
    </div>
  )
}
The Dependency Array in Depth

The second argument to useEffect controls when the effect re-runs. React compares each dependency with its previous value using Object.is (similar to ===):

  • [] — run once, on mount only. The effect never re-runs.

  • [userId] — run on mount AND whenever userId changes.

  • No array at all — run after every render (almost never what you want for fetching).

  • [user.id] — if user is a new object on every render, this runs every render. Depend on primitive values, not object references.

Warning
Omitting the dependency array entirely is almost always a bug for data fetching. The effect will run on every render, causing an infinite loop: fetch → setState → re-render → fetch → …
Refetching When a Prop Changes

Including a prop in the dependency array causes the fetch to repeat whenever that prop changes. This is how the UserProfile example above works — changing the userId prop triggers a fresh fetch while cleanly cancelling the previous in-flight request:

JSX
function UserBrowser() {
  const [selectedId, setSelectedId] = useState(1)

  return (
    <div style={{ display: 'flex', gap: 24 }}>
      <nav>
        {[1, 2, 3, 4, 5].map(id => (
          <button key={id} onClick={() => setSelectedId(id)}>
            User {id}
          </button>
        ))}
      </nav>

      {/* UserProfile re-fetches automatically when selectedId changes */}
      <UserProfile userId={selectedId} />
    </div>
  )
}
Pagination with useEffect

Adding a page state variable and including it in the dependency array makes pagination straightforward:

JSX
import { useState, useEffect } from 'react'

const PAGE_SIZE = 10

function PostList() {
  const [posts, setPosts]     = useState([])
  const [page, setPage]       = useState(1)
  const [isLoading, setIsLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)

  useEffect(() => {
    const controller = new AbortController()
    setIsLoading(true)

    fetch(
      `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${PAGE_SIZE}`,
      { signal: controller.signal }
    )
      .then(res => {
        if (!res.ok) throw new Error('Fetch failed')
        return res.json()
      })
      .then(data => {
        setPosts(prev => page === 1 ? data : [...prev, ...data])
        setHasMore(data.length === PAGE_SIZE)
        setIsLoading(false)
      })
      .catch(err => {
        if (err.name !== 'AbortError') setIsLoading(false)
      })

    return () => controller.abort()
  }, [page])   // re-fetch whenever page changes

  return (
    <div>
      {posts.map(post => <p key={post.id}>{post.title}</p>)}
      {isLoading && <p>Loading…</p>}
      {hasMore && !isLoading && (
        <button onClick={() => setPage(p => p + 1)}>Load more</button>
      )}
    </div>
  )
}
Common Mistakes

These are the errors that trip up developers most often when fetching inside useEffect:

JSX
// ✗ Mistake 1: async effect function
// useEffect cannot receive an async function directly
useEffect(async () => {           // ← async returns a Promise, not a cleanup fn
  const data = await fetch('/api/data').then(r => r.json())
  setData(data)
}, [])

// ✓ Fix: define an async function inside the effect
useEffect(() => {
  async function load() {
    const data = await fetch('/api/data').then(r => r.json())
    setData(data)
  }
  load()
}, [])

// ✗ Mistake 2: missing cleanup
useEffect(() => {
  fetch(`/api/user/${userId}`).then(r => r.json()).then(setUser)
  // if userId changes before this completes, stale data overwrites fresh data
}, [userId])

// ✓ Fix: cancel with AbortController (shown above)

// ✗ Mistake 3: object in dependency array
useEffect(() => {
  fetch(`/api/search?q=${options.query}`).then(...)
}, [options])   // ← 'options' is a new object every render → infinite loop

// ✓ Fix: depend on primitive values
useEffect(() => {
  fetch(`/api/search?q=${options.query}`).then(...)
}, [options.query])   // ← string, stable comparison
The Tearing Problem in Concurrent Mode

React 18 introduced concurrent rendering, where React can pause and resume rendering. This creates a subtle issue called tearing: the UI can display inconsistent state if an external data source changes mid-render.

For useState-based local state this is not a problem — React ensures consistency within its own state system. But if you read from an external store (like a global variable or third-party state) during render, and that store updates while React is rendering, different parts of the tree might see different values of the same data.

Note
For most useEffect + fetch patterns, tearing is not a concern because the fetched data lives in useState which React keeps consistent. Tearing only affects external mutable stores read during render — which is why the useSyncExternalStore hook was added.
Cleanup Is Not Optional

Always return a cleanup function from useEffect when you fetch. Three things can go wrong without it:

  • Memory leakssetState calls accumulate on unmounted components.

  • Race conditions — an older request resolves after a newer one and overwrites the correct data.

  • Strict Mode double-invocation — React 18 Strict Mode deliberately runs effects twice in development to surface missing cleanup. Without cleanup you will see doubled requests and state inconsistencies in dev.

JSX
// The pattern to internalize — copy this every time you fetch:
useEffect(() => {
  const controller = new AbortController()

  async function load() {
    try {
      setLoading(true)
      const res = await fetch(url, { signal: controller.signal })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      const json = await res.json()
      setData(json)
    } catch (err) {
      if (err.name !== 'AbortError') setError(err.message)
    } finally {
      setLoading(false)
    }
  }

  load()
  return () => controller.abort()
}, [url])
Tip
Once you find yourself repeating this pattern for every fetch in your app, it's time to extract it into a custom hook (e.g. useFetch) or switch to TanStack Query / SWR, which handle all of this for you automatically.