Scientyfic World

How to Fetch Data in React with Hooks (useEffect, TanStack Query, Suspense)

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...

Share:

Get an AI summary of this article

Data fetching using react hooks

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:

  1. 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”.
  2. No error handling. For a user that doesn’t exist, the API returns 404 with {"error":"Not found"}. fetch doesn’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 as net::ERR_ABORTED, and the component showed the right user.
  • HTTP errors: checking res.ok and 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-query
import { 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?

ApproachRace-safeCachingRetriesBest for
Naive useEffectNoNoNoLearning only
useFetch with AbortControllerYesNoNoSmall apps, one-off requests, no dependencies
TanStack Query / SWRYesYesYes (configurable)Most client-side React apps
use() + SuspenseYes, with a cached promiseOnly what you buildNoFrameworks and Server Components
Framework loaders (Next.js, React Router)YesFramework-managedVariesApps 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 userId out of the dependency array means the effect never re-runs when it changes. The ESLint react-hooks plugin 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.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts