Every React project I’ve ever worked on has had the same moment: you need data from an API, you wire up a useEffect, and then you spend the next hour figuring out why your component re-renders forty times or why the console says “Can’t perform a React state update on an unmounted component.”
I’ve hit both of those bugs in production. The second one, the memory leak warning, is what finally made me learn AbortController properly. Here’s everything I know about fetching data in React, from the basic pattern to the stuff I wish someone had told me earlier.
The Basic Pattern: useState + useEffect
This is the bread and butter. Three state variables, one effect, one async function:
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>
);
}
The important bits:
- Three states:
data,loading,error. You need all three. I’ve tried skipping error state before and it always comes back to bite me. - Async function inside the effect.
useEffectcan’t be async itself: that changes the return value (which it expects to be a cleanup function). So you define an async function and call it. - The dependency array.
[userId]means “re-run this effect whenuserIdchanges.” Leave it out and it runs on every render. Add something that changes every render and you get an infinite loop. Both are bad. - The
finallyblock. Ensures loading is reset no matter what. Without it: a failed request leaves the user staring at “Loading…” forever.
Different HTTP Methods
GET Requests
The simplest case. Fetch data on mount:
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>
);
}
The empty dependency array [] means “run once on mount.” I use this pattern a lot for initial data loading.
POST Requests
Submitting data is a different pattern, triggered by user action, not component mount:
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>
);
}
I like using a status string enum (idle, loading, success, error) for form submissions instead of separate booleans. It’s cleaner and prevents the impossible state of loading and error both being true.
PUT/PATCH Requests
Updating existing data. I handle these similarly to POST but with the resource URL:
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
The trickiest one, because you need to update the UI after the deletion completes:
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>
);
}
The onDelete callback is important, it lets the parent component remove the item from its state. Without it, the UI is out of sync with the server.
Building a Custom Hook for API Calls
Once you’ve written the useState + useEffect + fetch pattern for the fifth time, you start thinking about extraction. A custom useFetch hook is the natural next step.
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>;
}
The AbortController is doing important work here. Without it, if the component unmounts while a fetch is in-flight, React will try to call setData on an unmounted component. That’s the memory leak warning I mentioned at the start. The cleanup function aborts the pending request, the AbortError catch clause handles it gracefully, and everyone’s happy.
useFetch with Refetch
Sometimes you need to manually re-fetch (after a form submission, or a “Refresh” button):
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>
);
}
I use this pattern constantly. The refetch function gives you fine-grained control without having to juggle key-based re-mounting tricks.
Handling Authentication
Most real APIs need auth headers. Here’s how I handle that:
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 };
}
The 401 check is important, if the token’s expired, you don’t want to show the user a cryptic error. Redirecting to login is usually the right call. (In a real app, you’d probably want an auth context or interceptor instead of doing this in every hook, but the principle is the same.)
Error Boundaries
For unhandled errors that crash a component, React error boundaries are your safety net:
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>
);
}
Error boundaries don’t catch errors in event handlers or async code, only in the render lifecycle. Keep that in mind. They’re a last resort, not a substitute for proper try-catch in your effects.
Best Practices (From My Mistakes)
Use AbortController for Cleanup
This is the one I learned the hard way. Always abort pending requests when the component unmounts:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData);
return () => controller.abort();
}, [url]);
I had a dashboard component that fetched data for different time ranges. Switching ranges fast enough would trigger “Can’t perform a React state update on an unmounted component” warnings in the console. AbortController fixed it.
Handle Loading States Gracefully
Don’t just show “Loading…”, show something useful:
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} />;
}
Implement Retry Logic
For flaky APIs, retry before giving up:
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 };
}
Debounce Search Requests
If you’re hitting an API on every keystroke in a search box, you’re going to hammer your backend. Debounce it:
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>
);
}
The 300ms delay means you only fire the request after the user stops typing for a moment. Without this, typing “react hooks” would fire seven separate API calls.
Consider React Query for Complex Scenarios
I’ve been using React Query (TanStack Query) more and more. For anything beyond simple data fetching, it handles caching, background updates, pagination, and a dozen other things that you’d otherwise build yourself:
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>;
}
I’ll be honest, for simple projects, the useFetch custom hook above is plenty. React Query earns its weight when you’ve got multiple data sources, caching requirements, or optimistic updates. Don’t add it just because it’s popular.
Common Mistakes
Missing the Dependency Array
// Bad: Runs on every render, infinite loop
useEffect(() => {
fetchData();
});
// Good: Runs only when userId changes
useEffect(() => {
fetchData();
}, [userId]);
This is the #1 cause of infinite loops in React. If your effect makes a network call and you forget the dependency array, it’ll fire on every render, which triggers a state update, which triggers a re-render, which fires the effect again. Your browser tab will freeze and your API will send you an angry email.
Silent Failures
// Bad: No error handling, broken UI
useEffect(() => {
fetch('/api/data').then(res => res.json()).then(setData);
}, []);
// Good: Handle errors properly
useEffect(() => {
fetch('/api/data')
.then(res => {
if (!res.ok) throw new Error('Failed');
return res.json();
})
.then(setData)
.catch(setError);
}, []);
Setting State on Unmounted Components
// Bad: 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; };
}, []);
(Or just use AbortController like I showed above. Both approaches work; I prefer AbortController because it actually cancels the network request, not just ignores the result.)
What I Took Away
The useState + useEffect + fetch pattern is one of those things that looks simple but has a lot of surface area for bugs. The dependency array, the cleanup function, the error handling, each one is a place where a small mistake causes a confusing production bug.
My advice: start with the basic pattern, get it working, then add the robustness layer by layer. Don’t try to build the perfect useFetch hook on day one. Get the data flowing, handle the errors, add cleanup, then consider extracting.
And once you find yourself writing the same fetch logic for the third time, that’s when you build the custom hook. Not before. Premature abstraction is just as real in React as it is anywhere else.
Member discussion
0 commentsStart the conversation
Become a member of >hacksubset_ to start commenting.
Already a member? Sign in