To fetch data with React hooks, store the result in useState and start the request in useEffect, with the URL or ID in the dependency array. Handle loading and error states, check response.ok, and cancel stale requests with an AbortController in the effect’s cleanup. For real apps, a data-fetching library such as TanStack Query, or your framework’s data loading, handles caching, retries and race conditions for you.
This guide builds up from the simplest version to production-ready approaches: a naive useEffect fetch and the two bugs it has, a reusable useFetch custom hook, TanStack Query, and React 19’s use() with Suspense. I ran all four side by side in one app (React 19.3, TanStack Query 5.104) against a test API where user 1 responds in 800 ms and user 2 in 100 ms, and the results below come from that test.
The Test API
To make timing problems visible, the API deliberately answers slowly for one user. You can run it with Node.js to follow along:
// Test API: user 1 responds slowly, user 2 quickly
import { createServer } from 'node:http';
const users = { 1: { id: 1, name: 'Ada Lovelace' }, 2: { id: 2, name: 'Alan Turing' } };
const delay = { 1: 800, 2: 100 };
createServer((req, res) => {
const id = req.url.match(/^\/api\/users\/(\d+)/)?.[1];
res.setHeader('Access-Control-Allow-Origin', '*');
setTimeout(() => {
if (!users[id]) { res.writeHead(404, { 'Content-Type': 'application/json' }); return res.end('{"error":"Not found"}'); }
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(users[id]));
}, delay[id] ?? 50);
}).listen(3001);1. The Basic useEffect Fetch (and Its Bugs)
This is how most tutorials, including the 2023 version of this post, fetch data:
import { useState, useEffect } from 'react';
// Naive version: has a race condition
export default function NaiveUser({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`http://localhost:3001/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]);
return <p>{user ? user.name : 'Loading...'}</p>;
}It works on a happy path, but my test exposed two real bugs:
- Race condition. I selected user 2, then user 1, then user 2 again within 50 ms. The fast request for user 2 finished first, then the slow request for user 1 finished and overwrote it. The page ended up showing “Ada Lovelace” while user 2 was selected. Every other approach in this guide showed the correct “Alan Turing”.
- No error handling. For a user that doesn’t exist, the API returns 404 with
{"error":"Not found"}.fetchdoesn’t throw on a 404, so the component stored the error object as the user and rendered a blank paragraph with no message.
It also has no loading state after the first load (it keeps showing the previous user while the next one loads) and nothing to stop it from updating after the component unmounts.
2. A Reusable useFetch Hook with AbortController
The fix is to cancel the previous request whenever the URL changes, check the status, and track loading and error state. Putting that in a custom hook means you write it once:
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch doesn't throw on 404/500
return res.json();
})
.then((json) => setData(json))
.catch((err) => {
if (err.name !== 'AbortError') setError(err); // ignore requests we cancelled
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort(); // cancel when url changes or component unmounts
}, [url]);
return { data, error, loading };
}Using it is now short:
import { useFetch } from './useFetch.js';
export default function SafeUser({ userId }) {
const { data: user, error, loading } = useFetch(`http://localhost:3001/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Something went wrong: {error.message}</p>;
return <p>{user.name}</p>;
}How it fixes each problem:
- Race conditions: React runs the cleanup function before the effect runs again.
controller.abort()cancels the in-flight request for the old URL, so its result can never overwrite the new one. In my test, the browser logged the stale requests asnet::ERR_ABORTED, and the component showed the right user. - HTTP errors: checking
res.okand throwing turns a 404 into a proper error. It displayed “Something went wrong: HTTP 404” immediately. - Unmounting: the same cleanup runs when the component unmounts, so no state update happens after it is gone.
- Strict Mode: in development, React’s Strict Mode mounts components twice to surface missing cleanups, so you’ll see two requests in the Network tab, with the first one aborted. That is expected, and production builds only send one.
If your project uses Axios instead of fetch, the same pattern applies; Axios also accepts an AbortController signal (see how to use Axios with React).
3. TanStack Query (Recommended for Most Apps)
A custom hook still re-fetches every time a component mounts, and doesn’t share or cache data between components. The React docs point this out too: writing data fetching directly in Effects “gets repetitive and makes it difficult to add optimizations like caching and server rendering later”, and they suggest a custom hook or a community library. TanStack Query is the most widely used one:
npm install @tanstack/react-queryimport { useQuery } from '@tanstack/react-query';
async function fetchUser(userId, signal) {
const res = await fetch(`http://localhost:3001/api/users/${userId}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export default function QueryUser({ userId }) {
const { data: user, error, isPending } = useQuery({
queryKey: ['user', userId],
queryFn: ({ signal }) => fetchUser(userId, signal),
});
if (isPending) return <p>Loading...</p>;
if (error) return <p>Something went wrong: {error.message}</p>;
return <p>{user.name}</p>;
}Wrap your app once in a provider:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
{/* your components */}
</QueryClientProvider>
);
}You get caching keyed by queryKey, de-duplicated requests, background refetching, stale data while revalidating, and automatic cancellation through the signal it passes in. It handled the race correctly in my test.
One behaviour surprised me: for the 404 user, TanStack Query kept showing “Loading…” for several seconds before showing the error. By default it retries a failed query 3 times with exponential back-off (1 s, 2 s, 4 s), so it sent 4 requests and the error appeared after about 7 seconds. That is great for flaky networks but wrong for a definite 404. Turn it off where errors are final:
useQuery({
queryKey: ['user', userId],
queryFn: ({ signal }) => fetchUser(userId, signal),
retry: (failureCount, error) => !error.message.includes('404') && failureCount < 3,
});SWR is a lighter alternative with a similar useSWR(key, fetcher) API.
4. React 19: use() with Suspense
React 19 added the use() API, which reads the value of a promise and suspends the component until it resolves. Loading and error states move out of the component into <Suspense> and an error boundary:
import { use, Suspense } from 'react';
import { ErrorBoundary } from './ErrorBoundary.jsx';
// Cache promises so the same request isn't started on every render
const cache = new Map();
function fetchUser(userId) {
if (!cache.has(userId)) {
cache.set(userId, fetch(`http://localhost:3001/api/users/${userId}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}));
}
return cache.get(userId);
}
function UserName({ userId }) {
const user = use(fetchUser(userId)); // suspends until the promise resolves
return <p>{user.name}</p>;
}
export default function UseUser({ userId }) {
return (
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<Suspense fallback={<p>Loading...</p>}>
<UserName userId={userId} />
</Suspense>
</ErrorBoundary>
);
}The error boundary is a small class component (React still has no hook for catching render errors):
import { Component } from 'react';
export class ErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() { return this.state.hasError ? this.props.fallback : this.props.children; }
}The important detail is the cache. use() needs the same promise on every render; creating a new fetch() promise during render would start a new request each time and never settle. React’s docs recommend passing promises from Server Components or a framework, or caching them as above. In my test this version showed the correct user during the race and “Something went wrong.” for the 404. For a client-only app, TanStack Query also offers useSuspenseQuery if you like the Suspense style but want its caching.
Which Approach Should You Use?
| Approach | Race-safe | Caching | Retries | Best for |
|---|---|---|---|---|
Naive useEffect | No | No | No | Learning only |
useFetch with AbortController | Yes | No | No | Small apps, one-off requests, no dependencies |
| TanStack Query / SWR | Yes | Yes | Yes (configurable) | Most client-side React apps |
use() + Suspense | Yes, with a cached promise | Only what you build | No | Frameworks and Server Components |
| Framework loaders (Next.js, React Router) | Yes | Framework-managed | Varies | Apps built on a framework |
If you use a framework, its data loading (Server Components in Next.js, loaders in React Router) is usually the best option, because data can be fetched before the component renders, on the server and in parallel.
Common Mistakes When Fetching Data in React
- Missing dependencies: leaving
userIdout of the dependency array means the effect never re-runs when it changes. The ESLintreact-hooksplugin catches this. - Making the effect function async:
useEffect(async () => ...)returns a promise instead of a cleanup function. Define an async function inside the effect and call it, or use promise chains as above. - Not checking
res.ok: fetch resolves for 404 and 500 responses. - No cleanup: without aborting, slow responses can overwrite newer ones.
- Infinite loops: setting state that is also a dependency, or passing a new object or array as a dependency on every render, makes the effect run forever.
- Fetching the same data in many components: lift it up, put it in context (see the React Context API), or let a library cache it.
For a practical example of fetching and displaying lists, see how to paginate a dynamic table in React.
Conclusion
The classic useState plus useEffect pattern is a fine way to learn, but on its own it shows the wrong data during fast changes and hides HTTP errors. Add an AbortController cleanup and a res.ok check, ideally in a custom useFetch hook, and it becomes reliable. For most real apps, use TanStack Query or your framework’s data loading, and tune its retries; and if you are on React 19 with a framework, use() with Suspense keeps components focused on rendering.
FAQs
How do I fetch data in React with hooks?
Store the data in useState and start the request in useEffect, with the values it depends on in the dependency array. Track loading and error state, check response.ok, and abort the request in the effect’s cleanup function.
Why does my React component show old data after changing quickly?
A race condition: an earlier, slower request finished after a newer one and overwrote it. Cancel the previous request with AbortController in the effect cleanup, or use a library like TanStack Query that handles it.
Why does my useEffect fetch run twice?
In development, React Strict Mode mounts components twice to help you find missing cleanups. With an AbortController cleanup, the first request is cancelled. Production builds run the effect once.
Can I make the useEffect callback async?
No. useEffect expects the callback to return nothing or a cleanup function, but an async function returns a promise. Define an async function inside the effect and call it.
Should I use TanStack Query or useEffect?
For anything beyond simple one-off requests, TanStack Query (or SWR) is usually better: it adds caching, de-duplication, retries, background refetching and cancellation with less code.
What is the use() hook in React 19?
An API that reads the value of a promise or context. With a promise, it suspends the component until the promise resolves, so loading and error UI are handled by Suspense and an error boundary. The promise must be cached or come from a framework or Server Component.
Does fetch throw an error on 404?
No. fetch only rejects on network failures. Check response.ok or response.status and throw an error yourself for 4xx and 5xx responses.








