Fetching Data with useEffect and Axios

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>
  );
}
Note: Always clear the error state at the start of each fetch (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.
Tip: Axios provides much better error handling than the native 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.
Warning: The “Can’t perform a React state update on an unmounted component” warning (deprecated in React 18 but still relevant in older codebases) occurs when an async callback calls 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

🧠 Test Yourself

Your PostListPage has useEffect(() => { fetchPosts(tag); }, [tag]). The user changes the tag filter quickly three times in a row. What problem might occur and how do you fix it?