Table of Contents
1. What is API Integration
API (Application Programming Interface) integration is how your frontend React app communicates with backend servers, databases, and third-party services. Almost every modern web application needs to fetch, create, update, or delete data from remote servers.
REST vs GraphQL
- REST (Representational State Transfer): Uses multiple endpoints with HTTP methods (GET, POST, PUT, DELETE). Each endpoint returns a fixed data structure. Example:
/api/users,/api/posts/123 - GraphQL: A single endpoint where the client specifies exactly what data it needs. Reduces over-fetching and under-fetching. Example:
/graphqlwith a query body
HTTP Methods
GET /api/users — Fetch all users
GET /api/users/123 — Fetch single user
POST /api/users — Create a new user
PUT /api/users/123 — Update user 123
PATCH /api/users/123 — Partial update user 123
DELETE /api/users/123 — Delete user 123
This guide focuses on REST APIs since they're the most common and a great starting point. Once you're comfortable with REST, exploring GraphQL with Apollo Client or urql is a natural next step.
2. Setting Up a React Project
We'll use Vite for a fast, modern development setup.
npm create vite@latest api-demo -- --template react
cd api-demo
npm install
npm install axios
Project Structure
api-demo/
├── src/
│ ├── components/
│ │ ├── UserList.jsx
│ │ ├── UserForm.jsx
│ │ ├── PostList.jsx
│ │ ├── Pagination.jsx
│ │ ├── Spinner.jsx
│ │ └── ErrorMessage.jsx
│ ├── services/
│ │ └── api.js
│ ├── context/
│ │ └── AuthContext.jsx
│ ├── hooks/
│ │ └── useFetch.js
│ ├── App.jsx
│ └── main.jsx
├── .env
└── package.json
Environment Variables
# .env
VITE_API_BASE_URL=https://jsonplaceholder.typicode.com
VITE_API_KEY=your_api_key_here
Never expose API keys in frontend code. For sensitive keys, always use a backend proxy. The .env variables are embedded in the JavaScript bundle and visible to anyone who inspects it.
3. Fetching Data with useEffect
The most fundamental pattern for API calls in React is using useEffect combined with async/await.
Custom useFetch Hook
// src/hooks/useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchData();
// Cleanup: abort request if component unmounts
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
Using the Hook
// src/components/UserList.jsx
import { useFetch } from '../hooks/useFetch';
export default function UserList() {
const { data: users, loading, error } = useFetch(
'https://jsonplaceholder.typicode.com/users'
);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return (
<div className="user-list">
<h2>Users ({users.length})</h2>
{users.map((user) => (
<div key={user.id} className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
<p>{user.company.name}</p>
</div>
))}
</div>
);
}
Cleanup is critical: The AbortController cancels the fetch request if the component unmounts before it completes. Without this, you'll get "Can't perform a React state update on an unmounted component" warnings and potential memory leaks.
4. Using Axios
Axios is a popular HTTP client that provides automatic JSON parsing, request/response interceptors, and better error handling than the native fetch API.
API Service Setup
// src/services/api.js
import axios from 'axios';
const API_BASE = import.meta.env.VITE_API_BASE_URL;
const api = axios.create({
baseURL: API_BASE,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor — runs before every request
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor — runs after every response
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response) {
// Server responded with error status
const message = error.response.data?.message || error.message;
return Promise.reject(new Error(message));
}
if (error.request) {
// Request made but no response received
return Promise.reject(new Error('Network error — please check your connection'));
}
return Promise.reject(error);
}
);
// API methods
export const getUsers = () => api.get('/users');
export const getUserById = (id) => api.get(`/users/${id}`);
export const createUser = (data) => api.post('/users', data);
export const updateUser = (id, data) => api.put(`/users/${id}`, data);
export const deleteUser = (id) => api.delete(`/users/${id}`);
export default api;
Advantages Over Fetch
- Automatic JSON parsing (no
.json()call needed) - Built-in request timeout support
- Interceptors for auth tokens and error handling
- Better error objects with response data
- Request cancellation via AbortController
Tip: The response interceptor automatically unwraps response.data, so your components receive the data directly without needing to call .json() or access .data.
5. POST Requests
Sending data to a server is essential for creating new resources. Here's how to handle form submissions.
// src/components/UserForm.jsx
import { useState } from 'react';
import { createUser } from '../services/api';
export default function UserForm({ onUserCreated }) {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
setError(null);
setSuccess(false);
try {
const newUser = await createUser(formData);
setSuccess(true);
setFormData({ name: '', email: '', phone: '' });
onUserCreated?.(newUser);
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
};
return (
<form className="user-form" onSubmit={handleSubmit}>
<h3>Create New User</h3>
{error && <div className="form-error">{error}</div>}
{success && <div className="form-success">User created successfully!</div>}
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="phone">Phone</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
/>
</div>
<button type="submit" disabled={submitting}>
{submitting ? 'Creating...' : 'Create User'}
</button>
</form>
);
}
Always validate on the server too. Client-side validation improves UX but can be bypassed. Your backend must validate all incoming data regardless of frontend checks.
6. Loading & Error States
Proper loading and error handling is what separates a polished app from a frustrating one.
Spinner Component
// src/components/Spinner.jsx
export default function Spinner({ size = 'medium', message = 'Loading...' }) {
return (
<div className={`spinner-wrapper spinner-${size}`}>
<div className="spinner"></div>
{message && <p className="spinner-message">{message}</p>}
</div>
);
}
Error Boundary Component
// src/components/ErrorBoundary.jsx
import { Component } from 'react';
export default class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
Retry Logic Hook
// src/hooks/useFetchWithRetry.js
import { useState, useEffect, useCallback } from 'react';
export function useFetchWithRetry(url, maxRetries = 3) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [retryCount, setRetryCount] = useState(0);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
setData(result);
setLoading(false);
return;
} catch (err) {
if (attempt === maxRetries) {
setError(err.message);
setLoading(false);
return;
}
// Wait before retrying (exponential backoff)
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
}, [url, maxRetries]);
useEffect(() => {
fetchData();
}, [fetchData, retryCount]);
const retry = () => setRetryCount((c) => c + 1);
return { data, loading, error, retry };
}
Exponential backoff: Delay each retry attempt exponentially (1s, 2s, 4s). This prevents hammering a struggling server and gives it time to recover.
7. Pagination
For large datasets, pagination is essential. We'll implement both page-number and infinite-scroll approaches.
Page Number Pagination
// src/components/Pagination.jsx
export default function Pagination({ currentPage, totalPages, onPageChange }) {
const getPageNumbers = () => {
const pages = [];
const maxVisible = 5;
let start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
let end = Math.min(totalPages, start + maxVisible - 1);
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
};
return (
<div className="pagination">
<button
className="page-btn"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<i className="fas fa-chevron-left"></i>
</button>
{getPageNumbers().map((page) => (
<button
key={page}
className={`page-btn ${page === currentPage ? 'active' : ''}`}
onClick={() => onPageChange(page)}
>
{page}
</button>
))}
<button
className="page-btn"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<i className="fas fa-chevron-right"></i>
</button>
</div>
);
}
Using Pagination in a Component
// src/components/PostList.jsx
import { useState, useEffect } from 'react';
import Pagination from './Pagination';
import Spinner from './Spinner';
const POSTS_PER_PAGE = 10;
export default function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPosts, setTotalPosts] = useState(0);
useEffect(() => {
const fetchPosts = async () => {
setLoading(true);
try {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${currentPage}&_limit=${POSTS_PER_PAGE}`
);
const data = await res.json();
const total = parseInt(res.headers.get('X-Total-Count') || '100');
setPosts(data);
setTotalPosts(total);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
fetchPosts();
}, [currentPage]);
const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE);
if (loading) return <Spinner />;
return (
<div>
<div className="posts-grid">
{posts.map((post) => (
<article key={post.id} className="post-card">
<h3>{post.title}</h3>
<p>{post.body.substring(0, 100)}...</p>
</article>
))}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
);
}
When to use infinite scroll vs pagination: Use pagination for content that users might want to jump to (search results, admin dashboards). Use infinite scroll for feeds and timelines where linear browsing is natural (social media, product listings).
8. Authentication with APIs
Most real-world APIs require authentication. We'll implement token-based auth using React Context.
// src/context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import api from '../services/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(() => localStorage.getItem('auth-token'));
const [loading, setLoading] = useState(true);
useEffect(() => {
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
fetchCurrentUser();
} else {
setLoading(false);
}
}, [token]);
const fetchCurrentUser = async () => {
try {
const userData = await api.get('/auth/me');
setUser(userData);
} catch {
logout();
} finally {
setLoading(false);
}
};
const login = async (email, password) => {
const response = await api.post('/auth/login', { email, password });
const { token: newToken, user: userData } = response;
localStorage.setItem('auth-token', newToken);
setToken(newToken);
setUser(userData);
};
const logout = () => {
localStorage.removeItem('auth-token');
delete api.defaults.headers.common['Authorization'];
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
Protected Route Component
// src/components/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Spinner from './Spinner';
export default function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
return children;
}
Token security: Store tokens in httpOnly cookies for production apps, not localStorage. localStorage is vulnerable to XSS attacks. For this tutorial, localStorage demonstrates the pattern, but always consult security best practices for production.
9. Caching API Responses
React Query (TanStack Query) is the industry standard for server state management. It handles caching, background refetching, and stale data automatically.
Setup
npm install @tanstack/react-query
// src/main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
refetchOnWindowFocus: false,
retry: 2,
},
},
});
ReactDOM.createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
Using useQuery
// src/components/UserList.jsx
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../services/api';
import Spinner from './Spinner';
import ErrorMessage from './ErrorMessage';
export default function UserList() {
const {
data: users,
isLoading,
isError,
error,
refetch,
isFetching,
} = useQuery({
queryKey: ['users'],
queryFn: getUsers,
staleTime: 5 * 60 * 1000,
});
if (isLoading) return <Spinner message="Loading users..." />;
if (isError) return <ErrorMessage message={error.message} onRetry={refetch} />;
return (
<div className="user-list">
{isFetching && <div className="refetch-indicator">Updating...</div>}
<h2>Users ({users.length})</h2>
{users.map((user) => (
<div key={user.id} className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
);
}
Mutations with useMutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createUser } from '../services/api';
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onSuccess: () => {
// Invalidate and refetch the users list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error) => {
console.error('Create user failed:', error);
},
});
}
// Usage in component
const createUserMutation = useCreateUser();
const handleSubmit = (formData) => {
createUserMutation.mutate(formData);
};
// Check state
createUserMutation.isPending // Loading
createUserMutation.isError // Error occurred
createUserMutation.isSuccess // Completed successfully
Why React Query? It eliminates the need for manual loading states, error handling, caching, and refetching logic. The average React app saves 50-70% of its data-fetching code by switching to React Query.
10. Best Practices
Follow these patterns to build robust, maintainable API integrations.
Error Handling
// Centralized error handler
export const handleApiError = (error) => {
if (error.response) {
switch (error.response.status) {
case 401:
// Redirect to login
window.location.href = '/login';
break;
case 403:
return 'You do not have permission for this action';
case 404:
return 'Resource not found';
case 422:
return error.response.data?.errors || 'Validation failed';
case 500:
return 'Server error — please try again later';
default:
return error.response.data?.message || 'An error occurred';
}
}
if (error.request) {
return 'Network error — please check your connection';
}
return error.message;
};
Environment Variables
# .env.development
VITE_API_BASE_URL=http://localhost:3001/api
# .env.production
VITE_API_BASE_URL=https://api.yourapp.com/api
# .env.local (not committed to git)
VITE_API_KEY=secret_key_here
Organized API Layer
// src/services/
// api.js — axios instance and interceptors
// users.js — user-related API calls
// posts.js — post-related API calls
// auth.js — authentication API calls
// src/services/users.js
import api from './api';
export const userApi = {
getAll: (params) => api.get('/users', { params }),
getById: (id) => api.get(`/users/${id}`),
create: (data) => api.post('/users', data),
update: (id, data) => api.put(`/users/${id}`, data),
delete: (id) => api.delete(`/users/${id}`),
};
Checklist
- Always handle loading, success, and error states
- Use AbortController to cancel requests on unmount
- Store API keys in environment variables, never in code
- Implement retry logic with exponential backoff
- Cache responses to reduce redundant network requests
- Validate all data on the server — never trust the client
- Use TypeScript for type safety with API responses
- Log errors to a monitoring service (Sentry, LogRocket)
Next steps: Once you're comfortable with these patterns, explore TypeScript for API type safety, GraphQL with Apollo Client, and server-side rendering with Next.js for SEO-friendly data fetching.
API integration is the backbone of modern web applications. Master these patterns — useEffect with cleanup, axios interceptors, loading/error states, pagination, authentication, and caching — and you'll be able to connect React to any backend or third-party service with confidence. Start with the basics, handle errors gracefully, and progressively adopt tools like React Query as your apps grow in complexity.