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.
| Approach | When to use |
|---|---|
useEffect + fetch | Simple apps, learning, one-off fetches |
Custom useFetch hook | When you want reusability without a library |
| React Query / SWR | Production 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:
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
| Feature | Without React Query | With React Query |
|---|---|---|
| Loading state | Manual useState | isLoading |
| Error state | Manual useState | error, isError |
| Caching | None | Automatic, configurable |
| Background refetch | None | On window focus, reconnect |
| Deduplication | None | Identical requests merged |
| Retry on failure | None | Automatic (configurable) |
| Optimistic updates | Complex | Built-in pattern |
| Pagination | Complex | useInfiniteQuery |
Axios Instead of Fetch
Many teams prefer Axios over the native fetch for its cleaner API and automatic JSON parsing:
npm install axios
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.