Skip to main content

Data Fetching Patterns

Data Fetching in React

Every real application fetches data from an API. React doesn't have a built-in data-fetching solution, so you have choices — each with tradeoffs.

ApproachWhen to use
useEffect + fetchSimple apps, learning, one-off fetches
Custom useFetch hookWhen you want reusability without a library
React Query / SWRProduction apps, complex data requirements

The useEffect Pattern

The foundational approach — fetch in a useEffect and store in state:

import { useState, useEffect } from "react";

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

useEffect(() => {
let cancelled = false;

setLoading(true);
setError(null);

fetch(`/api/users/${userId}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});

return () => {
cancelled = true; // prevent state update if component unmounts
};
}, [userId]); // re-fetch when userId changes

if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;

return <div>{user.name}</div>;
}

This works but gets repetitive across components. Extract it into a useFetch custom hook (covered in the Custom Hooks section).

Common Pitfalls with useEffect

Missing dependency array

// WRONG — runs after every render (infinite loop if setData triggers re-render)
useEffect(() => {
fetch("/api/data").then(...);
});

// CORRECT — runs once on mount
useEffect(() => {
fetch("/api/data").then(...);
}, []);

// CORRECT — runs when id changes
useEffect(() => {
fetch(`/api/data/${id}`).then(...);
}, [id]);

Forgetting to handle the unmount case

Without the cleanup function, if the component unmounts before the fetch completes, React will try to call setState on an unmounted component:

useEffect(() => {
let cancelled = false;

fetchData().then((data) => {
if (!cancelled) setData(data); // only update if still mounted
});

return () => { cancelled = true; }; // cleanup on unmount
}, []);

Race conditions with rapid re-fetches

If userId changes quickly (user clicks fast), multiple fetches run in parallel. The slowest one wins — you might show stale data. The cancelled flag solves this: only the latest fetch's result is applied.

React Query — Server State Management

For production apps, React Query (now TanStack Query) is the industry standard. It handles caching, background refetching, loading/error states, and much more — out of the box.

npm install @tanstack/react-query

Setup

Wrap your app with QueryClientProvider:

src/main.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // data is fresh for 5 minutes
retry: 2, // retry failed requests twice
},
},
});

import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);

useQuery — Fetching Data

import { useQuery } from "@tanstack/react-query";

async function fetchUser(userId) {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error("Failed to fetch user");
return res.json();
}

function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ["user", userId], // cache key — unique per userId
queryFn: () => fetchUser(userId),
});

if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;

return <div>{user.name}</div>;
}

The queryKey is how React Query identifies and caches this data. Two components with the same queryKey share the same cached data — no duplicate requests.

useMutation — Creating, Updating, Deleting

import { useMutation, useQueryClient } from "@tanstack/react-query";

function CreatePost() {
const queryClient = useQueryClient();

const { mutate, isPending, error } = useMutation({
mutationFn: async (newPost) => {
const res = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error("Failed to create post");
return res.json();
},
onSuccess: () => {
// Invalidate and refetch the posts list after creating one
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});

const handleSubmit = (e) => {
e.preventDefault();
mutate({ title: "New Post", body: "Content here" });
};

return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
{error && <p>Error: {error.message}</p>}
</form>
);
}

What React Query Gives You Automatically

FeatureWithout React QueryWith React Query
Loading stateManual useStateisLoading
Error stateManual useStateerror, isError
CachingNoneAutomatic, configurable
Background refetchNoneOn window focus, reconnect
DeduplicationNoneIdentical requests merged
Retry on failureNoneAutomatic (configurable)
Optimistic updatesComplexBuilt-in pattern
PaginationComplexuseInfiniteQuery

Axios Instead of Fetch

Many teams prefer Axios over the native fetch for its cleaner API and automatic JSON parsing:

npm install axios
src/api/client.js
import axios from "axios";

const api = axios.create({
baseURL: process.env.REACT_APP_API_URL || "/api",
timeout: 10000,
});

// Attach auth token to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});

// Handle auth errors globally
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
}
return Promise.reject(error);
}
);

export default api;

Use it with React Query:

import api from "../api/client";
import { useQuery } from "@tanstack/react-query";

function Posts() {
const { data: posts, isLoading } = useQuery({
queryKey: ["posts"],
queryFn: () => api.get("/posts").then((res) => res.data),
});

if (isLoading) return <p>Loading...</p>;
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

Which Approach Should You Use?

Learning / simple project?
→ useEffect + fetch

Growing project, multiple data fetching needs?
→ Custom useFetch hook

Production app?
→ React Query + Axios

React Query's caching alone is worth the dependency. Without it, navigating back to a page re-fetches data the user already loaded — a poor experience. React Query serves cached data instantly while refetching in the background.