Preventing Multiple API Requests in React Components
A common performance issue in React applications occurs when data fetching is incorrectly placed in the render method or directly in the component body, causing multiple unnecessary requests.
The Problem: Uncontrolled Data Fetching
// ❌ Wrong approach - causes infinite re-renders
function UserProfile() {
const [user, setUser] = useState(null);
// Executes on every render
fetch('/api/user')
.then(res => res.json())
.then(data => setUser(data));
return <div>{user?.name}</div>;
}
Why This Happens
- Render Cycle: Fetch triggers state update → re-render → fetch again
- No Dependency Control: Runs on every component update
- Memory Leaks: Potential for unresolved promises after unmount
- Performance Impact: Multiple identical API calls
Correct Implementation Patterns
1. Using useEffect (Basic)
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => setUser(data));
}, []); // Empty dependency array = runs once on mount
return <div>{user?.name}</div>;
}
2. With Loading and Error States
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
const response = await fetch('/api/user');
if (!response.ok) throw new Error('Request failed');
const data = await response.json();
if (isMounted) setUser(data);
} catch (err) {
if (isMounted) setError(err.message);
} finally {
if (isMounted) setLoading(false);
}
};
fetchData();
return () => {
isMounted = false; // Cleanup function
};
}, []);
if (loading) return <Loader />;
if (error) return <Error message={error} />;
return <div>{user.name}</div>;
}
3. With Dependency-Based Fetching
function UserPosts({ userId }) {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch(`/api/users/${userId}/posts`)
.then(res => res.json())
.then(data => setPosts(data));
}, [userId]); // Re-fetches when userId changes
return <PostList posts={posts} />;
}
4. Using AbortController
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch('/api/data', {
signal: controller.signal
});
const data = await response.json();
setData(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
}
};
fetchData();
return () => {
controller.abort(); // Cancel request on unmount
};
}, []);
Best Practices
- Always Use useEffect: For side effects like data fetching
- Include Dependencies: List all dependencies in the array
- Clean Up Resources: Cancel requests on unmount
- Error Handling: Always catch potential errors
- Loading States: Provide visual feedback during requests
Common Pitfalls
- Missing Dependency Array:
useEffect(() => {
// Runs after every render
}); // ❌ Missing array
- Incorrect Dependencies:
useEffect(() => {
fetchData(userId);
}, []); // ❌ Missing userId dependency
- Stale Closures:
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, []); // ❌ Uses stale userId if it changes
- Race Conditions:
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // ❌ Earlier requests may resolve after later ones
Advanced Solutions
1. Custom Fetch Hook
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
const json = await response.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
} finally {
setLoading(false);
}
};
fetchData();
return () => controller.abort();
}, [url]);
return { data, error, loading };
}
// Usage
function UserProfile() {
const { data: user, loading, error } = useFetch('/api/user');
// ...
}
2. Using React Query (Recommended)
import { useQuery } from 'react-query';
function UserProfile() {
const { data: user, isLoading, error } = useQuery('user', () =>
fetch('/api/user').then(res => res.json())
);
if (isLoading) return <Loader />;
if (error) return <Error message={error.message} />;
return <div>{user.name}</div>;
}
3. Debouncing Rapid Changes
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const timer = setTimeout(() => {
if (query) {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}
}, 300); // Wait 300ms after last change
return () => clearTimeout(timer);
}, [query]);
return <ResultsList items={results} />;
}
Remember: Data fetching should always be treated as a side effect in React components. By properly structuring your API calls within useEffect
with appropriate dependencies and cleanup, you ensure efficient, controlled data fetching without unnecessary requests.