Difficulty: Advanced
The most fundamental data fetching pattern in React uses the useEffect hook to trigger a fetch call when a component mounts or when dependencies change. Inside useEffect, you call fetch (or any async operation), parse the response, and store the result in state. Alongside the data, you maintain loading and error states to give users feedback while the request is in flight.
The pattern follows a clear structure: (1) declare state for data, loading, and error; (2) inside useEffect, set loading to true, perform the fetch, update data on success or error on failure, and set loading to false in both cases; (3) render conditionally based on these states. This gives you a complete lifecycle for any async operation.
A critical detail is the cleanup function. When the component unmounts before the fetch completes, or when the dependency changes mid-flight, you need to ignore the stale response. The classic approach is to use a boolean flag (let ignore = false) and set it to true in the cleanup. Before updating state, check if ignore is true. The AbortController API is even better - it actually cancels the HTTP request, saving bandwidth.
Race conditions are a common bug in this pattern. If a user rapidly switches between pages or filters, multiple fetches launch in parallel and responses can arrive out of order. The last-dispatched fetch might resolve before an earlier one, causing stale data to overwrite fresh data. Using an AbortController or the ignore-flag pattern prevents this.
While this pattern works and is important to understand, modern React apps prefer React Query or SWR for production data fetching. These libraries handle caching, deduplication, retries, and race conditions automatically. However, understanding the manual useEffect pattern is essential for interviews and for understanding what those libraries abstract away.
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
React.useEffect(() => {
let ignore = false;
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then(data => {
if (!ignore) {
setUser(data);
setLoading(false);
}
})
.catch(err => {
if (!ignore) {
setError(err.message);
setLoading(false);
}
});
return () => { ignore = true; };
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h1>{user.name}</h1>;
}
The ignore flag prevents state updates after unmount or when userId changes. Without it, a stale response could update state for the wrong userId.
function SearchResults({ query }) {
const [results, setResults] = React.useState([]);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (!query) return;
const controller = new AbortController();
setLoading(true);
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
setResults(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
setLoading(false);
}
});
return () => controller.abort();
}, [query]);
return (
<div>
{loading && <p>Searching...</p>}
{results.map(r => <p key={r.id}>{r.title}</p>)}
</div>
);
}
AbortController.abort() cancels the in-flight request entirely. This saves bandwidth and prevents race conditions. The AbortError is caught and ignored since it is expected.
function useFetch(url) {
const [state, setState] = React.useState({
data: null,
loading: true,
error: null,
});
React.useEffect(() => {
const controller = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => setState({ data, loading: false, error: null }))
.catch(err => {
if (err.name !== 'AbortError') {
setState({ data: null, loading: false, error: err.message });
}
});
return () => controller.abort();
}, [url]);
return state;
}
// Usage
function Posts() {
const { data, loading, error } = useFetch('/api/posts');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return data.map(p => <p key={p.id}>{p.title}</p>);
}
Extracting the pattern into a custom hook reduces duplication. However, this is essentially a simplified version of what React Query provides with far more features.
useEffect, fetch API, loading state, error state, cleanup, race conditions