Making API calls is one of the most common tasks in React development. Whether you’re fetching user data, submitting forms, or loading dynamic content, understanding how to properly handle API calls is essential. In this guide, we’ll explore how to make and manage API calls in React using JSX and React hooks.
Why Handle API Calls Carefully in React?
React’s component-based architecture and declarative nature require a different approach to API calls compared to traditional JavaScript. You need to consider:
- Component lifecycle - When should the API call be made?
- State management - How do you store and update the response data?
- Loading states - How do you show users that data is being fetched?
- Error handling - What happens when the API call fails?
- Cleanup - How do you prevent memory leaks and race conditions?
Let’s dive into the practical implementation.
The Basic Pattern: useState + useEffect
The most common pattern for API calls in React combines useState for managing data and useEffect for triggering the call.
Basic Structure
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="user-profile">
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}
Key Points
- Three state variables -
data,loading, anderrorcover all possible states - Async function inside useEffect - useEffect itself can’t be async, so wrap your logic
- Dependency array - Include all values that should trigger a refetch
- Error handling - Always wrap fetch calls in try-catch
- Cleanup - The finally block ensures loading state is always reset
Handling Different HTTP Methods
GET Requests
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
const fetchProducts = async () => {
const response = await fetch('/api/products');
const data = await response.json();
setProducts(data);
};
fetchProducts();
}, []);
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
POST Requests
function CreatePost() {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [status, setStatus] = useState('idle');
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('loading');
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, content }),
});
if (!response.ok) throw new Error('Failed to create post');
const data = await response.json();
console.log('Post created:', data);
setStatus('success');
setTitle('');
setContent('');
} catch (err) {
setStatus('error');
console.error(err);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Content"
/>
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Creating...' : 'Create Post'}
</button>
{status === 'error' && <p>Failed to create post</p>}
{status === 'success' && <p>Post created successfully!</p>}
</form>
);
}
PUT/PATCH Requests
function EditUser({ userId }) {
const [name, setName] = useState('');
const [isSaving, setIsSaving] = useState(false);
const handleUpdate = async () => {
setIsSaving(true);
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const updated = await response.json();
console.log('User updated:', updated);
} catch (err) {
console.error('Update failed:', err);
} finally {
setIsSaving(false);
}
};
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={handleUpdate} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
</div>
);
}
DELETE Requests
function TodoItem({ todo, onDelete }) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
setIsDeleting(true);
try {
await fetch(`/api/todos/${todo.id}`, {
method: 'DELETE',
});
onDelete(todo.id);
} catch (err) {
console.error('Delete failed:', err);
setIsDeleting(false);
}
};
return (
<li>
{todo.text}
<button onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</li>
);
}
Creating a Custom Hook for API Calls
One of React’s greatest strengths is the ability to create custom hooks. Let’s create a reusable hook for API calls.
Basic useFetch Hook
import { useState, useEffect } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(url, {
...options,
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') {
console.log('Fetch aborted');
return;
}
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
// Cleanup function
return () => {
controller.abort();
};
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }) {
const { data, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{data.name}</div>;
}
Advanced useFetch with Refetch
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const refetch = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`Status: ${response.status}`);
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
refetch();
}, [url]);
return { data, loading, error, refetch };
}
// Usage with manual refresh
function Dashboard() {
const { data, loading, error, refetch } = useFetch('/api/dashboard');
return (
<div>
<button onClick={refetch} disabled={loading}>
Refresh
</button>
{loading ? <div>Loading...</div> : <DataDisplay data={data} />}
</div>
);
}
Handling Authentication Headers
Most APIs require authentication. Here’s how to include auth headers in your requests.
function useAuthFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
// Get token from storage or context
const token = localStorage.getItem('authToken');
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...options.headers,
},
});
if (response.status === 401) {
// Handle unauthorized - redirect to login
window.location.href = '/login';
return;
}
if (!response.ok) throw new Error(`Status: ${response.status}`);
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
Error Boundaries and Global Error Handling
For production applications, consider implementing global error handling.
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
// Wrap your app
function App() {
return (
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
);
}
Best Practices
1. Use AbortController for Cleanup
Prevent memory leaks and race conditions by aborting pending requests on unmount.
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData);
return () => controller.abort();
}, [url]);
2. Handle Loading States Gracefully
function UserList() {
const { data, loading, error } = useFetch('/api/users');
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!data) return <EmptyState />;
return <UserGrid users={data} />;
}
3. Implement Retry Logic
function useFetchWithRetry(url, options = {}, maxRetries = 3) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [retryCount, setRetryCount] = useState(0);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`Status: ${response.status}`);
const result = await response.json();
setData(result);
setError(null);
} catch (err) {
if (retryCount < maxRetries) {
setRetryCount(prev => prev + 1);
} else {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchData();
}, [url, retryCount]);
return { data, loading, error, retryCount };
}
4. Debounce Search Requests
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) return;
const delayDebounce = setTimeout(async () => {
setLoading(true);
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data);
setLoading(false);
}, 300);
return () => clearTimeout(delayDebounce);
}, [query]);
return (
<div>
{loading ? <Spinner /> : <ResultsList results={results} />}
</div>
);
}
5. Use React Query for Complex Scenarios
For production apps with complex caching, background updates, and pagination, consider libraries like React Query (TanStack Query):
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{data.name}</div>;
}
Common Mistakes to Avoid
Missing Dependency Array
// Bad: Runs on every render
useEffect(() => {
fetchData();
});
// Good: Runs only when userId changes
useEffect(() => {
fetchData();
}, [userId]);
Not Handling Errors
// Bad: Silent failures
useEffect(() => {
fetch('/api/data').then(res => res.json()).then(setData);
}, []);
// Good: Proper error handling
useEffect(() => {
fetch('/api/data')
.then(res => {
if (!res.ok) throw new Error('Failed');
return res.json();
})
.then(setData)
.catch(setError);
}, []);
Setting State on Unmounted Component
// Bad: Can cause memory leak warnings
useEffect(() => {
fetchData().then(setData);
}, []);
// Good: Check if component is still mounted
useEffect(() => {
let isMounted = true;
fetchData().then(data => {
if (isMounted) setData(data);
});
return () => { isMounted = false; };
}, []);
Conclusion
Handling API calls in React requires understanding the component lifecycle, state management, and proper cleanup. By combining useState and useEffect, you can create robust data-fetching logic that handles loading states, errors, and edge cases gracefully.
Key takeaways:
- Use
useStatefor data, loading, and error states - Wrap API calls in
useEffectwith proper dependency arrays - Always handle errors and loading states
- Use
AbortControllerfor cleanup - Consider custom hooks for reusability
- For complex apps, use libraries like React Query
Master these patterns, and you’ll be well-equipped to build production-ready React applications that communicate effectively with APIs.
Member discussion
0 commentsStart the conversation
Become a member of >hacksubset_ to start commenting.
Already a member? Sign in