CCelestiallines.com
← Back to blog

How to Fetch Data in React: useEffect vs React Query

By Pawan Singh · Jun 2026

Fetching With Plain useEffect

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

    useEffect(() => {
        let ignore = false;
        setLoading(true);

        fetch(`/api/users/${userId}`)
            .then((res) => res.json())
            .then((data) => { if (!ignore) setUser(data); })
            .catch((err) => { if (!ignore) setError(err); })
            .finally(() => { if (!ignore) setLoading(false); });

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

    if (loading) return <Spinner />;
    if (error) return <ErrorMessage error={error} />;
    return <div>{user.name}</div>;
}

This works, but notice everything it took to make it merely correct: three separate state variables, manual loading/error tracking, and a race-condition guard (the ignore flag) for when userId changes before the previous request finishes. And this version still doesn't cache anything — navigate away and back to the same user, and it refetches from scratch every time.

The Same Thing With React Query

function UserProfile({ userId }) {
    const { data: user, isLoading, error } = useQuery({
        queryKey: ['user', userId],
        queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
    });

    if (isLoading) return <Spinner />;
    if (error) return <ErrorMessage error={error} />;
    return <div>{user.name}</div>;
}

Same behavior, a fraction of the code — and React Query adds things the manual version doesn't have at all: automatic caching keyed by queryKey (revisiting the same user is instant, served from cache while silently refetching in the background), automatic retry on failure, automatic refetch when the browser tab regains focus, and request deduplication (multiple components requesting the same queryKey simultaneously share one network request instead of firing several).

Why the Manual Version Is Harder to Get Right Than It Looks

Every one of these is a real bug that's easy to ship with hand-rolled useEffect fetching, and each has to be solved again in every component that fetches data:

  • Race conditions when a dependency changes faster than the request resolves (the ignore flag above).
  • No caching — the same data gets refetched every time a component remounts, even seconds after last fetching it.
  • No deduplication — two sibling components both needing the same data fire two identical requests.
  • No automatic retry, so a single transient network blip becomes a permanent error state until the user manually refreshes.
  • No stale-data revalidation — data fetched once stays exactly as fetched until something explicitly triggers a refetch, even if it's since become outdated on the server.

When Plain useEffect Is Still the Right Choice

For a one-off fetch with no caching benefit (something fetched exactly once, never revisited, in a small app not otherwise pulling in a data-fetching library), plain useEffect is a reasonable, dependency-free choice. The tradeoff shifts as soon as: the same data is needed in more than one place, it benefits from being cached across navigations, or the app is large enough that repeating the loading/error/race-condition boilerplate in every component becomes real maintenance cost.

Mutations: Writing Data, Not Just Reading It

React Query's useMutation handles the write side with the same caching-aware benefits — specifically, invalidating and automatically refetching related queries after a successful write:

const queryClient = useQueryClient();

const { mutate: updateUser } = useMutation({
    mutationFn: (updates) => fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        body: JSON.stringify(updates),
    }),
    onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
});

After a successful update, the invalidated query automatically refetches, so the UI reflects the change without manually updating local state to match what you just wrote to the server — a common source of UI/server drift bugs in manually-managed fetch state.

SWR: A Lighter Alternative

SWR (also by Vercel, the makers of Next.js) solves the same core problem — caching, revalidation, deduplication — with a smaller API surface and footprint than React Query:

const { data: user, isLoading, error } = useSWR(`/api/users/${userId}`, fetcher);

React Query has more built-in features (mutations, more granular cache control, offline support); SWR is lighter and often sufficient for straightforward read-heavy data needs. Both are dramatically better defaults than hand-rolled useEffect fetching for anything beyond a genuinely one-off request.

Building a data-heavy React app that needs this done right? Get in touch — I work as a freelance React developer.

Comments