![]()
Preventing Infinite Loops in API Calls with useEffect
A common React performance issue occurs when API calls trigger infinite loops due to incorrect dependency arrays in useEffect. Here’s how to properly structure your API calls to avoid this problem.
The Problem: Infinite API Call Loop
// ❌ Dangerous - causes infinite loop
const [data, setData] = useState(null);
const [filter, setFilter] = useState('');
useEffect(() => {
fetch(`/api/data?filter=${filter}`)
.then(res => res.json())
.then(data => setData(data));
}, [filter, data]); // data in dependencies causes loop
Why This Happens
- State Updates Trigger Re-renders:
setDatacauses component update - Dependency Changes: The effect runs again when dependencies change
- Cyclic Dependency: API call → state update → effect runs again
Correct Implementation Patterns
1. Basic API Call (Run Once)
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => setData(data));
}, []); // Empty array = runs once on mount
2. Dependency-Controlled API Call
const [data, setData] = useState(null);
const [filter, setFilter] = useState('');
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`/api/data?filter=${filter}`);
const result = await response.json();
setData(result);
};
fetchData();
}, [filter]); // Only re-run when filter changes
3. With Debouncing (For Frequent Changes)
useEffect(() => {
const timerId = setTimeout(() => {
fetch(`/api/data?query=${query}`)
.then(res => res.json())
.then(data => setData(data));
}, 300); // Wait 300ms after last change
return () => clearTimeout(timerId); // Cleanup
}, [query]); // Re-run when query changes
Best Practices
- Minimize Dependencies: Only include what’s truly needed
- Use Functional Updates: When state depends on previous state
setCount(prevCount => prevCount + 1);
- Memoize Functions: For functions in dependencies
const fetchData = useCallback(() => {
// fetch logic
}, [dependencies]);
- Use Refs for Mutable Values: That shouldn’t trigger updates
- Consider Custom Hooks: For complex data fetching logic
Common Pitfalls
- Including State Setters in Dependencies:
useEffect(() => {
// effect logic
}, [setData]); // ❌ setData is stable but often unnecessary
- Incorrect Object/Array Dependencies:
useEffect(() => {
// effect logic
}, [config]); // ❌ New object reference each render
- Missing Dependencies:
useEffect(() => {
fetchData(filter); // ❌ Missing fetchData dependency
}, [filter]);
Advanced Solutions
1. Using useReducer for Complex State
function dataReducer(state, action) {
switch (action.type) {
case 'FETCH_SUCCESS':
return { ...state, data: action.payload, loading: false };
// other cases
}
}
function MyComponent() {
const [state, dispatch] = useReducer(dataReducer, { data: null, loading: false });
const [filter, setFilter] = useState('');
useEffect(() => {
const fetchData = async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(`/api/data?filter=${filter}`);
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
fetchData();
}, [filter]);
}
2. Using React Query (Recommended)
import { useQuery } from 'react-query';
function MyComponent() {
const [filter, setFilter] = useState('');
const { data, isLoading, error } = useQuery(
['data', filter], // Query key includes filter
() => fetch(`/api/data?filter=${filter}`).then(res => res.json())
);
// Handles loading states, errors, caching, and cleanup automatically
}
3. Custom Hook Solution
function useApi(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.message);
}
} finally {
setLoading(false);
}
};
fetchData();
return () => controller.abort();
}, [url]); // Only re-run when url changes
return { data, error, loading };
}
// Usage
function MyComponent() {
const [filter, setFilter] = useState('');
const { data, loading } = useApi(`/api/data?filter=${filter}`);
}
Key Takeaways
- Empty Dependency Array: For one-time fetches on mount
- Precise Dependencies: Only include values that should trigger refetch
- Cleanup Async Operations: Cancel pending requests on unmount
- Stable References: Memoize objects/functions in dependencies
- Consider Libraries: React Query, SWR simplify data fetching
Remember: The dependency array should contain all values that the effect depends on, but nothing more. If your effect uses a value from the component scope, it should typically be in the dependencies – but you may need to optimize how those values are created (with useMemo/useCallback) to prevent unnecessary re-runs.
