| |

React 33 ⚛️ Fetching API Data with useEffect

Fetching data is the most common side effect in a React application. A component mounts, it needs data from a server, it shows a loading state, the data arrives, it shows the data. The pattern is simple to describe and full of traps: the effect that fetches can run twice, the response can arrive after the component unmounts, the dependencies can cause an infinite loop, and the loading and error states can be out of sync. This chapter covers data fetching with useEffect in full: the basic pattern, the loading and error states, the abort mechanism, the dependency rules, and the limitations that lead to dedicated data-fetching libraries.

Key point: Fetching in useEffect requires three states: the data, the loading flag, and the error. The effect fetches when the dependencies change, sets the loading state to true before the request, and sets the data or the error when the request completes. The cleanup aborts the request so that a response from a stale request does not set state on an unmounted component or overwrite a newer response. The pattern works, but it is manual, and the manual parts are where the bugs are.


Why fetching in useEffect matters

The mounting problem. A component that needs data from a server has to request it at some point. The request is a side effect, so it belongs in an effect, not in the render. The render should be pure. The effect runs after the render, and the request is made.

The state problem. A fetch is asynchronous. The component renders before the data arrives. It renders again when the data arrives. The component needs to represent the intermediate state: loading, loaded, or error. The states are managed with useState and updated by the effect.

The race problem. If the dependencies change while a request is in flight, a second request is made. The two requests can resolve in any order. The older response can arrive after the newer one and overwrite it with stale data. The cleanup aborts the older request, so the race cannot happen.

The unmount problem. A request that is in flight when the component unmounts resolves after the component is gone. The response handler calls setData on an unmounted component. In React 18, this is no longer a warning, but it is still a waste. The cleanup aborts the request on unmount.

The manual problem. The fetch pattern in useEffect requires the developer to manage the loading state, the error state, the abort, and the dependencies. Each is a place for a bug. The dedicated libraries—React Query, SWR, TanStack Query—exist because the manual pattern is repetitive and error-prone. But the manual pattern is the foundation, and understanding it is understanding what the libraries do.


a. The basic fetch pattern with loading and error states

The pattern uses three states: the data, the loading flag, and the error.

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <p>{user.name}</p>;
}

The effect sets loading to true and clears the error before the request. The fetch returns a promise. The first .then checks the response status and parses the JSON. The second .then sets the data and clears the loading flag. The .catch sets the error and clears the loading flag.

The res.ok check is important. The fetch promise resolves for HTTP errors like 404 and 500. It only rejects for network errors. The res.ok property is true for status codes in the 200–299 range. A 404 response produces res.ok === false, and the code throws an error that the .catch handles.

The three render branches handle the three states. The loading branch is shown while the request is in flight. The error branch is shown when the request fails. The data branch is shown when the request succeeds.

An alternative is to use a single state object.

const [state, setState] = useState({
  data: null,
  loading: true,
  error: null,
});

useEffect(() => {
  setState({ data: null, loading: true, error: null });

  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => setState({ data, loading: false, error: null }))
    .catch((error) => setState({ data: null, loading: false, error }));
}, [userId]);

The single object avoids the possibility of inconsistent states, where loading is false but data and error are both null. The three separate states can produce that combination if the updates are not coordinated.

The single object is the safer pattern. The three separate states are more common because they are simpler to write. Either works if the updates are coordinated.


b. The abort and the race condition

A fetch that is not aborted can resolve after the dependencies have changed or the component has unmounted. The cleanup aborts the request.

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

  setLoading(true);
  setError(null);

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    })
    .then((data) => {
      setUser(data);
      setLoading(false);
    })
    .catch((err) => {
      if (err.name === 'AbortError') return;
      setError(err);
      setLoading(false);
    });

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

The AbortController creates a signal. The fetch is given the signal. The cleanup calls controller.abort(), which cancels the request. The .catch checks for the AbortError and returns early, because the abort is expected and should not set the error state.

When userId changes from 1 to 2:

  1. The cleanup from the userId = 1 effect runs, aborting the request for user 1.
  2. The new effect runs, creating a new controller and fetching user 2.

The aborted request for user 1 rejects with an AbortError, which the .catch ignores. The response for user 1 never reaches setUser.

The abort on unmount works the same way. The cleanup runs when the component unmounts, and the request is cancelled. The response never sets state on the unmounted component.

Without the abort, the race is real.

// BAD: no abort
useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then(setUser);
}, [userId]);

If userId changes from 1 to 2 quickly, both requests are in flight. If the request for user 1 resolves after the request for user 2, the data for user 1 overwrites the data for user 2. The component shows the wrong user. The abort prevents this.

A simpler alternative to AbortController is a boolean flag.

useEffect(() => {
  let cancelled = false;

  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => {
      if (!cancelled) setUser(data);
    });

  return () => {
    cancelled = true;
  };
}, [userId]);

The flag is set to true in the cleanup. The .then checks the flag before setting state. The flag does not cancel the request; it only ignores the response. The AbortController cancels the request, which saves bandwidth. The flag is simpler and works when the fetch library does not support aborting.

The AbortController is the preferred pattern in modern code. It cancels the request, and the AbortError is a clear signal that the request was intentionally cancelled.


c. The dependency rules and the stale closure

The effect depends on the values it reads. The userId is a prop, so it belongs in the dependencies. The fetch URL is built from userId, so the effect re-runs when userId changes.

useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then(setUser);
}, [userId]);

The effect reads userId and the setUser function. The setUser is stable and does not need to be in the dependencies. The userId is reactive and does.

An effect that reads a value without listing it is a stale closure.

// BAD: stale closure
useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then(setUser);
}, []); // userId not in dependencies

The effect runs once on mount and fetches the user for the initial userId. When userId changes, the effect does not re-run, and the component shows the wrong user. The dependency is the fix.

An effect that depends on an object or a function re-runs when the object or function changes identity.

// BAD: options is a new object every render
function Component({ userId }) {
  const options = { headers: { Authorization: token } };

  useEffect(() => {
    fetch(`/api/users/${userId}`, options)
      .then((res) => res.json())
      .then(setUser);
  }, [userId, options]); // runs on every render
}

The options object is a new object on every render. The Object.is comparison is false, and the effect runs on every render. The fix is to depend on the primitive values.

useEffect(() => {
  const options = { headers: { Authorization: token } };
  fetch(`/api/users/${userId}`, options)
    .then((res) => res.json())
    .then(setUser);
}, [userId, token]);

The effect reads userId and token. Both are primitives. The effect re-runs when either changes, not on every render.

An alternative is to memoize the object.

const options = useMemo(
  () => ({ headers: { Authorization: token } }),
  [token]
);

useEffect(() => {
  fetch(`/api/users/${userId}`, options)
    .then((res) => res.json())
    .then(setUser);
}, [userId, options]);

The useMemo returns the same object until token changes. The effect depends on the memoized object, which is stable between renders.

The effect should not update a value in its dependencies.

// BAD: infinite loop
useEffect(() => {
  fetch('/api/data')
    .then((res) => res.json())
    .then(setData);
}, [data]); // data is in dependencies and set by the effect

The effect sets data, which is in the dependencies, which causes the effect to re-run, which sets data again. The loop is infinite. The fix is to remove data from the dependencies. The effect does not read data; it only sets it.

useEffect(() => {
  fetch('/api/data')
    .then((res) => res.json())
    .then(setData);
}, []); // runs once

The effect runs once and fetches the data. The setData does not need data in the dependencies because the effect does not read it.


Complete Example Session

// ============================================
// PART 1: BASIC FETCH
// ============================================
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <p>{user.name}</p>;
}
// ============================================
// PART 2: SINGLE STATE OBJECT
// ============================================
const [state, setState] = useState({
  data: null,
  loading: true,
  error: null,
});

useEffect(() => {
  setState({ data: null, loading: true, error: null });

  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => setState({ data, loading: false, error: null }))
    .catch((error) => setState({ data: null, loading: false, error }));
}, [userId]);
// ============================================
// PART 3: ABORT CONTROLLER
// ============================================
useEffect(() => {
  const controller = new AbortController();

  setLoading(true);
  setError(null);

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => {
      setUser(data);
      setLoading(false);
    })
    .catch((err) => {
      if (err.name === 'AbortError') return;
      setError(err);
      setLoading(false);
    });

  return () => controller.abort();
}, [userId]);
// ============================================
// PART 4: CANCELLED FLAG
// ============================================
useEffect(() => {
  let cancelled = false;

  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => {
      if (!cancelled) setUser(data);
    });

  return () => {
    cancelled = true;
  };
}, [userId]);
// ============================================
// PART 5: RESPONSE STATUS CHECK
// ============================================
fetch(url)
  .then((res) => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })
  .then(setData)
  .catch(setError);
// ============================================
// PART 6: ASYNC/AWAIT FORM
// ============================================
useEffect(() => {
  const controller = new AbortController();

  const load = async () => {
    try {
      setLoading(true);
      const res = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      setUser(data);
    } catch (err) {
      if (err.name !== 'AbortError') setError(err);
    } finally {
      setLoading(false);
    }
  };

  load();
  return () => controller.abort();
}, [userId]);
// ============================================
// PART 7: OBJECT DEPENDENCY PROBLEM
// ============================================
// BAD: new object every render
const options = { headers: { Authorization: token } };
useEffect(() => {
  fetch(url, options).then(setData);
}, [url, options]); // runs on every render
// ============================================
// PART 8: FIX WITH PRIMITIVES
// ============================================
useEffect(() => {
  const options = { headers: { Authorization: token } };
  fetch(url, options).then(setData);
}, [url, token]); // runs only when url or token changes
// ============================================
// PART 9: FIX WITH useMemo
// ============================================
const options = useMemo(
  () => ({ headers: { Authorization: token } }),
  [token]
);
useEffect(() => {
  fetch(url, options).then(setData);
}, [url, options]);
// ============================================
// PART 10: AVOIDING THE INFINITE LOOP
// ============================================
// BAD: data is set by the effect and in the dependencies
useEffect(() => {
  fetch('/api/data').then((res) => res.json()).then(setData);
}, [data]); // infinite loop

// GOOD: data is not read by the effect
useEffect(() => {
  fetch('/api/data').then((res) => res.json()).then(setData);
}, []); // runs once

The ten parts covered the basic fetch, the single state object, the AbortController, the cancelled flag, the response status check, the async/await form, the object dependency problem, the primitive fix, the useMemo fix, and the infinite loop.


Quick Reference

Basic Fetch Pattern

StepCode
Data stateconst [data, setData] = useState(null)
Loading stateconst [loading, setLoading] = useState(true)
Error stateconst [error, setError] = useState(null)
Fetchfetch(url).then(res => res.json()).then(setData)
Abortreturn () => controller.abort()

Response Handling

CheckPurpose
res.okStatus 200–299
res.statusNumeric status
res.json()Parse JSON
res.text()Parse text
res.headers.get()Read header

Abort vs Flag

AspectAbortControllerCancelled Flag
Cancels requestYesNo
Ignores responseYesYes
Saves bandwidthYesNo
Error handlingAbortErrorCheck flag
SupportedModern fetchAny promise

Dependency Rules

ValueIn Dependencies
userId propYes
token propYes
setDataNo (stable)
URL stringYes
Options objectNo (new each render)
Options primitiveYes
useMemo optionsYes

Common Bugs

BugCauseFix
Infinite loopData in dependenciesRemove
Race conditionNo abortAbortController
Stale dataMissing dependencyAdd to array
Re-fetch every renderObject dependencyPrimitives or useMemo
Set state after unmountNo cleanupAbort or flag

Best Practices

✅ Do This:

// Use three states for data, loading, and error
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);                      // ✅

// Check res.ok before parsing
if (!res.ok) throw new Error(`HTTP ${res.status}`);            // ✅

// Abort the request in the cleanup
const controller = new AbortController();
return () => controller.abort();                               // ✅

// Ignore AbortError in the catch
if (err.name === 'AbortError') return;                        // ✅

// Use primitives in the dependencies
}, [userId, token]);                                           // ✅

// Use useMemo for object dependencies
const options = useMemo(() => ({ ... }), [token]);             // ✅

// Use a single state object for consistency
setState({ data, loading: false, error: null });               // ✅

❌ Don’t Do This:

// Don't fetch without handling errors
fetch(url).then((res) => res.json()).then(setData);            // ❌

// Don't forget to check res.ok
fetch(url).then((res) => res.json()).then(setData);            // ⚠️

// Don't fetch without aborting
fetch(url).then(setData);                                      // ❌ race

// Don't put the data state in the dependencies
useEffect(() => { setData(...); }, [data]);                    // ❌ loop

// Don't use a new object in the dependencies
useEffect(() => {}, [{ id }]);                                 // ❌ re-runs

// Don't set state after unmount
fetch(url).then(setData);                                      // ❌

// Don't put the fetch function in the dependencies
useEffect(() => { fetchData(); }, [fetchData]);                // ⚠️ if unstable

Common Pitfalls

PitfallWhy It HappensFix
Infinite loopData in dependenciesRemove data
Race conditionNo abortAbortController
Stale dataMissing dependencyAdd dependency
Runs on every renderObject dependencyPrimitives
Set state after unmountNo cleanupAbort or flag
Loading never clearsError before settingfinally block
Error ignoredNo .catchHandle errors

Real-World Examples

1. Basic Fetch

useEffect(() => {
  fetch('/api/data').then((r) => r.json()).then(setData);
}, []);

2. Fetch on Prop Change

useEffect(() => {
  fetch(`/api/users/${userId}`).then((r) => r.json()).then(setUser);
}, [userId]);

3. With Loading and Error

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

4. Abort Controller

useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal }).then(setData);
  return () => controller.abort();
}, [url]);

5. Async/Await

useEffect(() => {
  const load = async () => {
    const res = await fetch(url);
    setData(await res.json());
  };
  load();
}, [url]);

6. Search with Debounce

useEffect(() => {
  const id = setTimeout(() => {
    fetch(`/api/search?q=${query}`).then(setResults);
  }, 300);
  return () => clearTimeout(id);
}, [query]);

7. POST Request

useEffect(() => {
  fetch('/api/items', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(item),
  }).then((r) => r.json()).then(setResult);
}, [item]);

8. With Authentication

useEffect(() => {
  fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
  }).then(setData);
}, [url, token]);

9. Pagination

useEffect(() => {
  fetch(`/api/items?page=${page}`).then(setItems);
}, [page]);

10. Refetch on Demand

const [refresh, setRefresh] = useState(0);

useEffect(() => {
  fetch(url).then(setData);
}, [url, refresh]);

// Refetch: setRefresh((r) => r + 1)

Visual

Fetch Lifecycle

┌─────────────────────────────────────────────────────────────┐
│  MOUNT                                                      │
│  Render (loading: true) → Effect → fetch                    │
│                                                             │
│  REQUEST IN FLIGHT                                          │
│  Component shows loading state                              │
│                                                             │
│  RESPONSE ARRIVES                                           │
│  setData + setLoading(false) → Re-render with data          │
│                                                             │
│  OR ERROR                                                   │
│  setError + setLoading(false) → Re-render with error        │
│                                                             │
│  UNMOUNT                                                    │
│  Cleanup → abort                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Race Condition

┌─────────────────────────────────────────────────────────────┐
│  WITHOUT ABORT                                              │
│                                                             │
│  userId = 1: fetch starts                                   │
│  userId = 2: fetch starts                                   │
│                                                             │
│  Request 1 resolves slowly: setUser(user1)                  │
│  Request 2 resolves fast: setUser(user2)                    │
│                                                             │
│  Order of resolution: 1 then 2 → correct                    │
│  Order of resolution: 2 then 1 → WRONG (user1 overwrites)   │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  WITH ABORT                                                 │
│                                                             │
│  userId = 1: fetch starts                                   │
│  userId = 2: cleanup aborts request 1, fetch starts         │
│                                                             │
│  Request 1 aborted: no response                             │
│  Request 2 resolves: setUser(user2)                         │
│                                                             │
│  Always correct.                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Three States

┌─────────────────────────────────────────────────────────────┐
│  STATE 1: LOADING                                           │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  loading: true                                      │    │
│  │  data: null                                         │    │
│  │  error: null                                        │    │
│  │  Renders: "Loading…"                                │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  STATE 2: SUCCESS                                           │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  loading: false                                     │    │
│  │  data: { ... }                                      │    │
│  │  error: null                                        │    │
│  │  Renders: the data                                  │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  STATE 3: ERROR                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  loading: false                                     │    │
│  │  data: null                                         │    │
│  │  error: Error                                       │    │
│  │  Renders: "Error: …"                                │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Dependency Rules

┌─────────────────────────────────────────────────────────────┐
│  useEffect(() => {                                          │
│    fetch(`/api/users/${userId}`, { headers: { Auth: token }})│
│      .then(setUser);                                        │
│  }, [userId, token]);                                       │
│                                                             │
│  Reads: userId, token                                       │
│  Sets: user                                                 │
│                                                             │
│  Dependencies: [userId, token]                              │
│    ├── userId is read → include                             │
│    ├── token is read → include                              │
│    └── setUser is stable → exclude                          │
│                                                             │
│  The effect re-runs when userId or token changes.           │
│  It does not re-run when user changes (it set it).          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Data stateuseState(null)
Loading stateuseState(true)
Error stateuseState(null)
Fetchfetch(url).then(res => res.json())
Status checkif (!res.ok) throw
AbortAbortController + controller.abort()
Ignore abortif (err.name === 'AbortError') return
Cleanupreturn () => controller.abort()
DependenciesProps and state the effect reads
Object dependencyuseMemo or primitives
Infinite loopData in dependencies

Key takeaways:

  • Fetching in useEffect requires three states: data, loading, and error. The effect sets the loading state before the request and the data or error state when the request completes. The render branches on the three states.
  • fetch does not reject on HTTP errors. The promise resolves for 404 and 500. The res.ok check throws an error for status codes outside the 200–299 range, which the .catch handles.
  • The cleanup aborts the request. The AbortController cancels the request when the dependencies change or the component unmounts. The .catch ignores the AbortError. Without the abort, a stale response can overwrite a newer one.
  • The dependencies include every value the effect reads. The userId and token are read, so they are in the dependencies. The setUser function is stable, so it is not. A missing dependency causes a stale closure; an object dependency causes re-runs on every render.
  • The effect should not read the data it sets. An effect that sets data and reads data has data in its dependencies and causes an infinite loop. The effect fetches and sets; it does not read.
  • The single state object avoids inconsistent states. The three separate states can produce loading: false with data: null and error: null. The single object updates all three together.
  • The manual pattern is the foundation for the libraries. React Query, SWR, and TanStack Query exist because the manual pattern is repetitive. They handle caching, deduplication, retries, and background refetching. The manual pattern is what they replace, and understanding it is understanding what they do.

Remember: Fetching in useEffect is the pattern that every React developer writes and every React codebase contains. It is simple to write and full of traps. The three states, the res.ok check, the abort, and the dependencies are the parts that must be right. The abort is the part that is most often forgotten, and the race condition it prevents is the bug that appears only under load. Write the cleanup. Check the status. Include the dependencies. And when the manual pattern becomes repetitive, reach for the library that does it for you.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!