React API Integration — Fetching Data with React Query & SWR
In this tutorial, you'll learn about React API Integration. We cover key concepts, practical examples, and best practices.
What You'll Learn
Integrate external APIs in React using TanStack React Query and SWR — data fetching, caching, background refetching, optimistic mutations, and infinite scrolling with proper loading and error states.
Why It Matters
Every real-world React app talks to a server. Without a structured approach to data fetching, you end up with scattered useEffect calls, inconsistent loading states, no caching, and race conditions. Libraries like React Query and SWR solve these problems out of the box.
Real-World Use
A dashboard that polls for live metrics every 30 seconds, a search bar that debounces queries and caches recent results, or a social feed with infinite scroll — all need reliable API integration with smooth UX.
The Problem: Fetching in useEffect
Before data-fetching libraries, every React developer wrote some version of this:
import { useState, useEffect } from "react";
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchUsers() {
try {
setLoading(true);
setError(null);
const response = await fetch("https://api.example.com/users");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (!cancelled) setUsers(data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUsers();
return () => { cancelled = true; };
}, []);
if (loading) return <p>Loading users...</p>;
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Problems with this approach:
- No caching — remounting the component re-fetches
- No background refetching — stale data stays stale
- No deduplication — two components fetching the same URL fire two requests
- Manual loading/error state management — repetitive and error-prone
- Race conditions — a slow response can overwrite a newer one
TanStack React Query (React Query v5)
React Query (now TanStack Query) is the most popular data-fetching library for React. It manages server state with automatic caching, background refetching, and powerful devtools.
Setup
npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
);
}
useQuery — Fetching Data
import { useQuery } from "@tanstack/react-query";
async function fetchUsers() {
const response = await fetch("https://api.example.com/users");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
function UserList() {
const {
data: users,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000, // 5 minutes before data is stale
cacheTime: 30 * 60 * 1000, // 30 minutes in cache
retry: 2, // Retry twice on failure
});
if (isLoading) return <p>Loading users...</p>;
if (isError) return (
<div style={{ color: "red" }}>
<p>Error: {error.message}</p>
<button onClick={refetch} style={{ cursor: "pointer" }}>Retry</button>
</div>
);
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Expected output: On first render, "Loading users..." appears. Once the API responds, the user list renders. If the API fails, the error message and a "Retry" button appear. Subsequent visits use the cached data.
useMutation — Writing Data
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddUserForm() {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const mutation = useMutation({
mutationFn: (newUser) =>
fetch("https://api.example.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newUser),
}).then(res => {
if (!res.ok) throw new Error("Failed to create user");
return res.json();
}),
onSuccess: () => {
// Invalidate and refetch the users list
queryClient.invalidateQueries({ queryKey: ["users"] });
setName("");
},
onError: (error) => {
console.error("Mutation failed:", error);
},
});
function handleSubmit(e) {
e.preventDefault();
mutation.mutate({ name });
}
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter name"
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Adding..." : "Add User"}
</button>
{mutation.isError && (
<p style={{ color: "red" }}>
Error: {mutation.error.message}
</p>
)}
</form>
);
}
Expected output: Submitting the form calls the API. The button shows "Adding..." during the request. On success, the input clears and the user list automatically refreshes. On failure, an error message appears.
SWR (Stale-While-Revalidate)
SWR by Vercel follows the same stale-while-revalidate caching strategy. Data is served from cache immediately (stale), then re-fetched in the background (revalidate).
npm install swr
import useSWR from "swr";
const fetcher = (url) => fetch(url).then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});
function UserProfile({ userId }) {
const { data: user, error, isLoading, mutate } = useSWR(
userId ? `https://api.example.com/users/${userId}` : null,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 2000,
errorRetryCount: 3,
}
);
if (isLoading) return <p>Loading...</p>;
if (error) return <p style={{ color: "red" }}>Failed to load user</p>;
if (!user) return <p>Select a user</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={() => mutate()} style={{ cursor: "pointer" }}>
Refresh
</button>
</div>
);
}
Expected output: The user data appears immediately from cache if available, then updates in the background. The "Refresh" button triggers a manual re-fetch via mutate().
Caching Strategies
| Strategy | React Query | SWR | Behavior |
|---|---|---|---|
| Stale-while-revalidate | staleTime default 0 |
Default | Show cached data, refetch in background |
| Cache-first | staleTime: Infinity |
revalidateIfStale: false |
Never refetch unless told |
| Network-only | staleTime: 0, cacheTime: 0 |
revalidateOnMount: true |
Always fetch fresh |
| Polling | refetchInterval: 30000 |
refreshInterval: 30000 |
Refetch every N ms |
| Focus refetch | refetchOnWindowFocus: true |
revalidateOnFocus: true |
Refetch on tab focus |
Optimistic Updates
Optimistic updates show the expected result immediately before the server confirms, then roll back on failure.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, completed }) =>
fetch(`https://api.example.com/todos/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !completed }),
}).then(res => {
if (!res.ok) throw new Error("Update failed");
return res.json();
}),
onMutate: async ({ id, completed }) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ["todos"] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(["todos"]);
// Optimistically update
queryClient.setQueryData(["todos"], (old) =>
old.map(todo =>
todo.id === id ? { ...todo, completed: !completed } : todo
)
);
return { previousTodos };
},
onError: (err, vars, context) => {
// Rollback on error
queryClient.setQueryData(["todos"], context.previousTodos);
},
onSettled: () => {
// Refetch to ensure server sync
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
Expected output: Toggling a todo instantly updates the UI. If the server request fails, the UI reverts to the previous state automatically.
Infinite Queries / Pagination
import { useInfiniteQuery } from "@tanstack/react-query";
async function fetchPosts({ pageParam = 1 }) {
const response = await fetch(
`https://api.example.com/posts?page=${pageParam}&limit=10`
);
return response.json();
}
function PostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useInfiniteQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
initialPageParam: 1,
});
if (isLoading) return <p>Loading posts...</p>;
if (isError) return <p style={{ color: "red" }}>Failed to load posts</p>;
return (
<div>
{data.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<div key={post.id} style={{ padding: "12px", borderBottom: "1px solid #eee" }}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</div>
))}
</div>
))}
<div style={{ textAlign: "center", padding: "16px" }}>
{isFetchingNextPage && <p>Loading more...</p>}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
style={{
padding: "8px 16px",
background: "#007bff",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Load More
</button>
)}
</div>
</div>
);
}
Expected output: The first page of posts renders. A "Load More" button fetches the next page and appends it. When no more pages exist, the button disappears.
Data Flow Architecture
flowchart TD
A[Component] -->|useQuery / useSWR| B[Cache]
B -->|Cache hit & fresh| C[Render data]
B -->|Cache hit & stale| D[Show cached data]
D --> E[Background refetch]
E --> F{Success?}
F -->|Yes| G[Update cache]
G --> C
F -->|No| H[Keep stale data]
H --> I[Show error toast]
B -->|No cache| J[Fetch from API]
J --> F
Learning Path
flowchart LR
A[React Basics] --> B[Hooks Guide]
B --> C[Context API]
C --> D[Custom Hooks]
D --> E[Design Patterns]
E --> F[Error Boundaries]
F --> G[API Integration]
G --> H[Final Project]
G -->|You are here| G
Common Errors
1. Missing Query Key Consistency
If two components fetch the same data but use different query keys, they create separate caches. Always use the same key structure for the same resource.
2. Forgetting to Handle isPending vs isLoading
React Query v5 uses isPending for the initial loading state and isLoading for any loading state including background refetches. Check both or use isFetching for refetch states.
3. Mutations Without Invalidation
Calling mutate without invalidateQueries afterward means the UI shows stale data. Always invalidate the relevant query key on mutation success.
4. Not Providing an Error Boundary
If useQuery throws because of a network failure and you do not handle isError, the error propagates up. Wrap API-dependent components in error boundaries.
5. Over-fetching with Short Stale Times
Setting staleTime: 0 on every query causes constant background refetches. Match the stale time to how frequently the data actually changes on the server.
6. Race Conditions with Multiple Mutations
If two mutations target the same resource simultaneously, the onMutate callback can overwrite each other's optimistic updates. Use the returned context object to roll back the correct snapshot.
7. Memory Leaks from Unbounded Infinite Queries
Without a maximum page limit, infinite queries grow memory indefinitely. Cap the number of stored pages with maxPages or prune old pages manually.
Security Considerations
When integrating APIs in React, always validate and sanitize data from external sources. A REST API response could contain malicious content. Never use dangerouslySetInnerHTML with API data. Use a library like DOMPurify if you must render HTML from the server. This is the same principle used in Doda Browser to sanitize untrusted web content.
Practice Questions
Challenge
Build a useInfiniteSWR implementation using SWR's useSWRInfinite hook for a paginated product listing. Include a "Load More" button, optimistic prefetching of the next page, and a loading skeleton for each page load.
Real-World Task
Take an existing React component that uses raw useEffect for data fetching and refactor it to use TanStack React Query. Measure the reduction in lines of code, the elimination of loading/error boilerplate, and verify that cached data persists across component remounts.
This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — applications that fetch real-time security data from APIs and display it with minimal latency and maximum reliability.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro