Programming Beginner 9 min

How to Fetch Data in React with useEffect

Fetching data in React is straightforward once you understand the lifecycle. useEffect runs after render, you fire off a fetch, and when the Promise resolves you update state. But the details matter: handling loading and error states, preventing state updates on unmounted components, and knowing when to stop writing this boilerplate and reach for React Query instead.

Step-by-step

  1. 1

    Set up the three core states

    Every data-fetching component needs three pieces of state: the data itself, a loading flag, and an error. Start with these before writing a single line of fetch code. Resist the urge to combine them into a single object prematurely — three separate useState calls are clearer.

    jsx
    import { useState, useEffect } from 'react';
    
    function UserProfile({ userId }) {
      const [user,    setUser]    = useState(null);
      const [loading, setLoading] = useState(true);
      const [error,   setError]   = useState(null);
    
      // fetch logic comes next
    }
  2. 2

    Write the basic useEffect fetch

    Call useEffect with an empty dependency array ([]) to run once after mount. Do not make the effect callback itself async — React does not handle the returned Promise. Define an inner async function and call it immediately instead.

    jsx
    useEffect(() => {
      async function fetchUser() {
        try {
          const res  = await fetch(`/api/users/${userId}`);
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          const data = await res.json();
          setUser(data);
        } catch (err) {
          setError(err.message);
        } finally {
          setLoading(false);
        }
      }
    
      fetchUser();
    }, []); // runs once after mount
  3. 3

    Render the three states

    Your JSX must handle all three states explicitly. A loading spinner that never goes away, or a blank screen on error, are both bugs. Handle them up front.

    jsx
    if (loading) return <p>Loading...</p>;
    if (error)   return <p>Error: {error}</p>;
    if (!user)   return null;
    
    return (
      <div>
        <h1>{user.name}</h1>
        <p>{user.email}</p>
      </div>
    );
  4. 4

    Abort the fetch on cleanup

    If the component unmounts before the fetch completes — the user navigated away — the fetch still resolves and calls setUser on an unmounted component. This causes a React warning and, in some cases, bugs. Use AbortController to cancel the in-flight request in the cleanup function.

    jsx
    useEffect(() => {
      const controller = new AbortController();
    
      async function fetchUser() {
        try {
          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') return; // ignore — intentional abort
          setError(err.message);
        } finally {
          setLoading(false);
        }
      }
    
      fetchUser();
    
      return () => controller.abort(); // cleanup: cancel if unmounted
    }, [userId]);
  5. 5

    Re-fetch when a dependency changes

    Put any variable the effect depends on in the dependency array. Here userId is a prop — when it changes (user navigates to a different profile), the effect re-runs, re-fetching the correct data. Reset loading and error state at the start of each fetch run.

    jsx
    useEffect(() => {
      const controller = new AbortController();
    
      setLoading(true);  // reset on each new userId
      setError(null);
    
      async function fetchUser() {
        try {
          const res  = await fetch(`/api/users/${userId}`, {
            signal: controller.signal,
          });
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          setUser(await res.json());
        } catch (err) {
          if (err.name !== 'AbortError') setError(err.message);
        } finally {
          setLoading(false);
        }
      }
    
      fetchUser();
      return () => controller.abort();
    }, [userId]); // re-runs whenever userId changes
  6. 6

    Understand React 18 Strict Mode double-invoke

    In development, React 18 Strict Mode mounts every component twice on purpose. Your useEffect fires twice. This is deliberate — it catches effects that are missing a cleanup function. With AbortController correctly in place, the first fetch is cancelled and only the second succeeds. This is the expected behavior. Do not work around it by disabling Strict Mode.

  7. 7

    Know when to use React Query instead

    Once your data fetching needs caching, background refetching, pagination, optimistic updates, or deduplication, hand-rolled useEffect fetching becomes a maintenance burden. React Query (or SWR) handles all of this and more with a much smaller API surface. The threshold is roughly: if you find yourself writing the same loading/error/refetch pattern in more than two components, reach for React Query.

    jsx
    // The same fetch with React Query — notice what disappears
    import { useQuery } from '@tanstack/react-query';
    
    function UserProfile({ userId }) {
      const { data: user, isLoading, error } = useQuery({
        queryKey: ['user', userId],
        queryFn:  () =>
          fetch(`/api/users/${userId}`)
            .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }),
      });
    
      if (isLoading) return <p>Loading...</p>;
      if (error)     return <p>Error: {error.message}</p>;
    
      return <h1>{user.name}</h1>;
    }
    // React Query handles: caching, deduplication, background refetch,
    // abort on unmount, retry on failure — all automatically.

Tips & gotchas

  • Never make the `useEffect` callback itself `async`. It must return either nothing or a cleanup function — not a Promise.
  • The dependency array linting rule (`react-hooks/exhaustive-deps`) exists for a reason. If ESLint is telling you to add something to the array, listen — it is almost always right.
  • If you need to fetch on a button click (not on mount), you do not need `useEffect` at all. Just call an async function directly in the event handler.
  • Avoid storing derived data in state. If you can compute something from `user` in the render function, do not put it in a separate `useState`.
  • For sequential fetches (fetch B only after A resolves), chain them inside a single `useEffect` with one `AbortController` — do not nest `useEffect` calls.

Wrapping up

The complete pattern — three state variables, an inner async function, AbortController cleanup, and the correct dependency array — covers almost every data-fetching use case you will encounter. The boilerplate is worth writing once to understand it. After that, React Query saves you from writing it on every component.

#React #Hooks #useEffect
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.