Step-by-step
-
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
useStatecalls are clearer.jsximport { 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
Write the basic useEffect fetch
Call
useEffectwith an empty dependency array ([]) to run once after mount. Do not make the effect callback itselfasync— React does not handle the returned Promise. Define an inner async function and call it immediately instead.jsxuseEffect(() => { 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
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.
jsxif (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
Abort the fetch on cleanup
If the component unmounts before the fetch completes — the user navigated away — the fetch still resolves and calls
setUseron an unmounted component. This causes a React warning and, in some cases, bugs. UseAbortControllerto cancel the in-flight request in the cleanup function.jsxuseEffect(() => { 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
Re-fetch when a dependency changes
Put any variable the effect depends on in the dependency array. Here
userIdis 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.jsxuseEffect(() => { 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
Understand React 18 Strict Mode double-invoke
In development, React 18 Strict Mode mounts every component twice on purpose. Your
useEffectfires twice. This is deliberate — it catches effects that are missing a cleanup function. WithAbortControllercorrectly 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
Know when to use React Query instead
Once your data fetching needs caching, background refetching, pagination, optimistic updates, or deduplication, hand-rolled
useEffectfetching 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.