Skip to main content

Fetching Data with useEffect: Complete Production Pattern

Data fetching is one of the most common tasks in React applications. This article provides a production-ready pattern combining useState, useEffect, AbortController, and a cleanup function to safely fetch external data while preventing race conditions and memory leaks. This pattern handles loading states, errors, and cleanup—everything needed for robust applications.

The Complete Data-Fetching Pattern

Fetching data in React requires coordinating several moving parts: state management (loading, success, error), lifecycle synchronization (useEffect), and safety (cleanup to abort requests if the component unmounts). Here's a complete, runnable example:

import React, { useState, useEffect } from 'react';

function PostsFetcher() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
// Create an AbortController to cancel the fetch if the component unmounts.
const controller = new AbortController();
const signal = controller.signal;

const fetchPosts = async () => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', { signal });
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
setPosts(data);
} catch (e) {
// Don't update state if the fetch was aborted
if (e.name !== 'AbortError') {
setError(e);
}
} finally {
// Don't update state if the fetch was aborted
if (signal.aborted) return;
setLoading(false);
}
};

fetchPosts();

// The cleanup function
return () => {
console.log('Cleanup: Aborting fetch.');
controller.abort();
};
}, []); // Empty array ensures this runs only on mount and unmount.

if (loading) {
return <div className="loading">Loading posts...</div>;
}

if (error) {
return <div className="error">Error: {error.message}</div>;
}

return (
<div className="posts-list">
<h1>Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}

export default PostsFetcher;

This single component demonstrates all the critical patterns: state management, error handling, loading UI, and cleanup. It's a template you can adapt for any API endpoint.

Breaking Down the Pattern: Four Core Parts

Part 1: State Management — Three Variables

const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

Manage the complete lifecycle of a fetch request with three state variables:

  • posts — stores the fetched data (initially empty array)
  • loading — tracks whether a request is in flight (initially true because fetch starts on mount)
  • error — captures any failure (initially null; set when the request fails)

This separation of concerns makes conditional rendering straightforward: check loading first, then error, then render the success UI.

Part 2: The Effect and Async Fetching Logic

useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;

const fetchPosts = async () => {
try {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
setPosts(data);
} catch (e) {
if (e.name !== 'AbortError') {
setError(e);
}
} finally {
if (signal.aborted) return;
setLoading(false);
}
};

fetchPosts();
return () => controller.abort();
}, []);

Why this structure matters:

  • AbortController + signal — Let you cancel the fetch if the component unmounts. Pass signal to the fetch() options.
  • async function inside the effectuseEffect callbacks cannot be async directly. Define an async function inside, then call it.
  • try...catch...finally — Ensures state updates happen correctly: success in try, error in catch, and cleanup in finally.
  • Check e.name !== 'AbortError' — Distinguish intentional aborts (component unmounted) from real errors. Only call setError() for real failures.
  • Check signal.aborted in finally — Prevent setting loading = false after the component has unmounted. Calling setState on an unmounted component causes memory leaks and warnings.
  • Empty dependency array [] — Run this effect only on mount and unmount. If you include dependencies (like a URL), the effect re-runs whenever the dependency changes, refetching data.

Part 3: The Cleanup Function

return () => {
console.log('Cleanup: Aborting fetch.');
controller.abort();
};

The cleanup function runs when the component unmounts or before the effect re-runs. Here, controller.abort() immediately cancels the pending fetch. This prevents two critical bugs:

  1. Memory leaks — State updates after unmount cause warning: "Can't perform a React state update on an unmounted component."
  2. Race conditions — If a component remounts quickly, the old request might complete after the new one starts, overwriting fresh data with stale data.

By aborting, the fetch promise rejects with an AbortError, caught in the catch block, which is safe (we check e.name !== 'AbortError' before setting error state).

Part 4: Conditional Rendering Based on State

if (loading) {
return <div className="loading">Loading posts...</div>;
}

if (error) {
return <div className="error">Error: {error.message}</div>;
}

return (
<div className="posts-list">
<h1>Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);

The UI reflects the current state:

  1. If loading is true, show "Loading posts..."
  2. Else if error is not null, show the error message
  3. Else render the success state (the posts list)

This pattern ensures users always see the correct UI for the current fetch state.

Why AbortController Matters

Without AbortController, if the component unmounts while a fetch is pending, React tries to update the unmounted component's state when the response arrives. This causes a performance warning and potential bugs. AbortController prevents this by canceling the network request, so the promise never resolves.

Modern browsers (and Node.js 15+) support AbortController natively. It's the recommended approach by the React team and web standards bodies.

Key Takeaways

  • Three-State Pattern: Separate state for data, loading, and errors makes conditional rendering simple and predictable.
  • useEffect + async: Define an async function inside useEffect, then call it—you can't make the effect callback itself async.
  • AbortController for Safety: Always provide a cleanup function that aborts the request on unmount. Check for AbortError before setting error state.
  • Check signal.aborted in finally: Prevent state updates after unmount by returning early in finally if the signal was aborted.
  • Empty Dependency Array: [] ensures the fetch runs once on mount and cleans up on unmount. Add dependencies if you want to refetch when props change.

Frequently Asked Questions

What if I need to refetch when a prop changes?

Add the prop to the dependency array:

useEffect(() => {
// ... fetch code ...
fetchPosts();
}, [userId]); // Refetch when userId changes

Each time userId changes, the previous effect's cleanup function runs (aborting the old request), and the new effect runs.

Can I use async/await directly in useEffect?

No. The effect callback cannot be async because it must return a cleanup function (not a Promise). Always define an async function inside the effect and call it:

useEffect(() => {
const fetchData = async () => { /* ... */ };
fetchData();
}, []);

How do I handle race conditions if the user clicks a button multiple times?

Race conditions occur when older requests complete after newer ones. The AbortController in the cleanup function handles this: if the user clicks and remounts the component, the previous fetch is aborted, and only the latest request updates state.

What if the API doesn't support AbortController?

For older APIs or custom fetch wrappers, you can manually track whether the component is mounted:

let isMounted = true;
fetchData().then(data => {
if (isMounted) setPosts(data);
});
return () => { isMounted = false; };

But AbortController is more efficient (cancels the network request, not just state updates).

Should I always use this pattern or are there libraries?

For simple fetches, this pattern is solid and dependency-free. For complex scenarios (caching, retries, pagination), consider libraries like react-query, SWR, or apollo-client. But understanding this core pattern first is essential.

Further Reading