Fetching data from your Express API is the most common useEffect pattern in a MERN application. Every time React renders a page component โ the blog home, a post detail page, a user profile โ it needs to load data from MongoDB via Express. Getting this pattern right means handling loading states so users see a spinner, error states so users see a helpful message, and cleanup so in-flight requests that are no longer needed do not update state after the component unmounts. In this lesson you will build the complete, production-quality data fetching pattern for the MERN Blog, and extract it into a reusable hook.
The Complete Data Fetching Pattern
import { useState, useEffect } from 'react';
import axios from 'axios';
function PostListPage() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true); // true on initial load
const [error, setError] = useState(null);
useEffect(() => {
// Define an async function inside the effect
const fetchPosts = async () => {
try {
setLoading(true);
setError(null); // clear previous errors before each fetch
const res = await axios.get('/api/posts?limit=10&published=true');
setPosts(res.data.data);
} catch (err) {
setError(err.response?.data?.message || 'Failed to load posts');
} finally {
setLoading(false);
}
};
fetchPosts();
}, []); // empty deps โ fetch once on mount
if (loading) return <Spinner message="Loading posts..." />;
if (error) return <ErrorMessage message={error} />;
if (posts.length === 0) return <p className="empty">No posts yet.</p>;
return (
<div className="post-list">
{posts.map(post => <PostCard key={post._id} post={post} />)}
</div>
);
}
setError(null)). If the previous request failed and the user triggers a retry, you want the loading spinner to show โ not the old error message โ while the new request is in flight. Similarly, always set loading: true at the start of each fetch, even on re-fetches, so the UI correctly reflects that data is being loaded.fetch API for MERN applications. Axios automatically throws for non-2xx responses (fetch does not), automatically parses JSON responses, and gives you the Express error message via err.response.data.message. With native fetch you have to manually check response.ok and parse error bodies separately.setState after the component has unmounted. This happens when a user navigates away while a fetch is in progress. The fix is the cleanup function pattern using an AbortController, covered in detail in Lesson 4.Fetching with Dependencies โ Refetch on Filter Change
function BlogPage() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [tag, setTag] = useState(''); // filter
const [page, setPage] = useState(1); // pagination
useEffect(() => {
const fetchPosts = async () => {
try {
setLoading(true);
setError(null);
const params = { page, limit: 10, published: true };
if (tag) params.tag = tag;
const res = await axios.get('/api/posts', { params });
setPosts(res.data.data);
} catch (err) {
setError(err.response?.data?.message || 'Failed to load posts');
} finally {
setLoading(false);
}
};
fetchPosts();
}, [tag, page]); // re-fetch when tag or page changes
const handleTagChange = (newTag) => {
setTag(newTag);
setPage(1); // reset to page 1 when filter changes
};
return (
<div>
<FilterBar onFilterChange={handleTagChange} activeTag={tag} />
{loading && <Spinner />}
{error && <ErrorMessage message={error} />}
{!loading && !error && <PostList posts={posts} />}
</div>
);
}
Fetching a Single Resource by ID
// React Router provides the post ID from the URL params
import { useParams } from 'react-router-dom';
function PostDetailPage() {
const { id } = useParams(); // e.g. /posts/64a1f2b3c8e4d5f6a7b8c9d0
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!id) return; // guard against missing ID
const fetchPost = async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get(`/api/posts/${id}`);
setPost(res.data.data);
} catch (err) {
if (err.response?.status === 404) {
setError('Post not found');
} else {
setError(err.response?.data?.message || 'Failed to load post');
}
} finally {
setLoading(false);
}
};
fetchPost();
}, [id]); // re-fetch when ID changes (user navigates between posts)
if (loading) return <Spinner message="Loading post..." />;
if (error) return <ErrorMessage message={error} />;
if (!post) return null;
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
);
}
The useFetch Custom Hook
// src/hooks/useFetch.js
import { useState, useEffect } from 'react';
import axios from 'axios';
function useFetch(url, params = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Serialise params to a string for the dependency array
// (objects are compared by reference โ JSON.stringify gives a stable primitive)
const paramsKey = JSON.stringify(params);
useEffect(() => {
if (!url) return;
const controller = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get(url, {
params,
signal: controller.signal, // cancellation support
});
setData(res.data);
} catch (err) {
if (axios.isCancel(err)) return; // ignore cancellation errors
setError(err.response?.data?.message || err.message || 'Request failed');
} finally {
setLoading(false);
}
};
fetchData();
return () => controller.abort(); // cleanup cancels in-flight request
}, [url, paramsKey]); // re-fetch when url or params change
return { data, loading, error };
}
export default useFetch;
// โโ Usage: clean component with no fetching boilerplate โโโโโโโโโโโโโโโโโโโโโโโ
function PostListPage() {
const { data, loading, error } = useFetch('/api/posts', { limit: 10, published: true });
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <PostList posts={data?.data || []} />;
}
Common Mistakes
Mistake 1 โ Not handling the error state
โ Wrong โ if the API returns an error, the component just shows nothing:
useEffect(() => {
axios.get('/api/posts').then(res => setPosts(res.data.data));
// If axios throws โ unhandled rejection, UI shows initial empty state forever
}, []);
โ Correct โ always catch errors and update error state:
useEffect(() => {
axios.get('/api/posts')
.then(res => setPosts(res.data.data))
.catch(err => setError(err.response?.data?.message || 'Failed to load'));
}, []);
Mistake 2 โ Not setting loading: false in the finally block
โ Wrong โ if an error occurs, loading spinner shows forever:
try {
const res = await axios.get('/api/posts');
setPosts(res.data.data);
setLoading(false); // only set on success โ not called if an error is thrown
} catch (err) {
setError(err.message);
// loading stays true โ spinner shows forever!
}
โ Correct โ use finally to always clear loading:
try {
const res = await axios.get('/api/posts');
setPosts(res.data.data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false); // always runs โ spinner always cleared โ
}
Mistake 3 โ Not resetting page when filter changes
โ Wrong โ user on page 5 changes tag filter, still sees page 5 of new results:
const handleTagChange = (newTag) => setTag(newTag); // forgets to reset page
โ Correct:
const handleTagChange = (newTag) => { setTag(newTag); setPage(1); }; // โ
Quick Reference
| Task | Code |
|---|---|
| Fetch on mount | useEffect(() => { fetchData(); }, []) |
| Fetch when ID changes | useEffect(() => { fetchPost(id); }, [id]) |
| Async inside effect | Define inner async fn and call it |
| Clear error before fetch | setError(null) at start |
| Always clear loading | Use finally { setLoading(false) } |
| Express error message | err.response?.data?.message |
| Cancel on unmount | AbortController + signal |