Common useEffect Patterns for MERN Applications

The final step in mastering useEffect is recognising the patterns that appear repeatedly in MERN applications and knowing how to implement each one cleanly. In this lesson you will build five essential patterns: running code only once on mount, reacting to URL param changes with React Router, debouncing a search input before making API calls, syncing state to localStorage for persistence, and updating the document title from any component. Each pattern solves a real problem in the MERN Blog and can be extracted into a reusable custom hook.

Pattern 1 โ€” Run Once on Mount

// Fetch initial data, track page view, initialise a third-party library
function HomePage() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    // Fetch is triggered once when the component mounts
    const fetchInitial = async () => {
      try {
        const res = await axios.get('/api/posts?page=1&limit=10&published=true');
        setPosts(res.data.data);
      } catch (err) {
        console.error(err);
      }
    };
    fetchInitial();
  }, []); // โ† empty array: mount only

  return <PostList posts={posts} />;
}
Note: “Run once on mount” with an empty dependency array ([]) is the most common useEffect form in MERN Blog pages. It replaces what would be a componentDidMount lifecycle method in a class component. However, remember that in React 18 StrictMode (development), it actually runs twice โ€” the cleanup runs between the two invocations. Your fetch code should handle this gracefully with an AbortController.
Tip: Custom hooks are the right abstraction when you find yourself writing the same useEffect + useState combination in multiple components. Extract the logic into a usePageTitle, useLocalStorage, or useDebounce hook and import it wherever needed. The hook encapsulates the useEffect logic, keeps component code clean, and makes the pattern testable in isolation.
Warning: Be careful with the debounce pattern and the dependency array. The onSearch callback passed as a prop is typically a new function reference on every render. Including it in the dependency array without useCallback in the parent will restart the debounce timer on every render, defeating the purpose. Either stabilise the callback with useCallback in the parent or use a ref to store the latest callback value without including it in the deps.

Pattern 2 โ€” React to URL Param Changes

// React Router's useParams gives the current URL params
// useEffect with the param as a dependency re-fetches when the URL changes
import { useParams } from 'react-router-dom';

function PostDetailPage() {
  const { id } = useParams();           // /posts/:id
  const [post,    setPost]    = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);

  useEffect(() => {
    if (!id) return;

    const controller = new AbortController();

    const fetchPost = async () => {
      try {
        setPost(null);       // clear previous post immediately
        setLoading(true);
        setError(null);
        const res = await axios.get(`/api/posts/${id}`, { signal: controller.signal });
        setPost(res.data.data);
      } catch (err) {
        if (!axios.isCancel(err)) {
          setError(err.response?.status === 404 ? 'Post not found' : 'Failed to load post');
        }
      } finally {
        setLoading(false);
      }
    };

    fetchPost();
    return () => controller.abort();
  }, [id]); // โ† re-runs whenever the URL id changes

  if (loading) return <Spinner />;
  if (error)   return <ErrorMessage message={error} />;
  if (!post)   return null;
  return <article><h1>{post.title}</h1></article>;
}

Pattern 3 โ€” Debounced Search Input

// useDebounce custom hook
function useDebounce(value, delay = 400) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timeoutId = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timeoutId); // cancel if value changes before delay
  }, [value, delay]);

  return debouncedValue;
}

// โ”€โ”€ Using the hook in the search component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function SearchPage() {
  const [query,   setQuery]   = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  const debouncedQuery = useDebounce(query, 400);

  // Only fires 400ms after the user stops typing
  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setResults([]);
      return;
    }

    const controller = new AbortController();

    const search = async () => {
      setLoading(true);
      try {
        const res = await axios.get('/api/posts', {
          params: { search: debouncedQuery, limit: 10 },
          signal: controller.signal,
        });
        setResults(res.data.data);
      } catch (err) {
        if (!axios.isCancel(err)) setResults([]);
      } finally {
        setLoading(false);
      }
    };
    search();
    return () => controller.abort();
  }, [debouncedQuery]);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." />
      {loading && <Spinner size="small" />}
      <PostList posts={results} />
    </div>
  );
}

Pattern 4 โ€” Sync State to localStorage

// useLocalStorage custom hook
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  }); // lazy initialisation reads from storage once on mount

  // Sync to localStorage whenever the value changes
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (err) {
      console.error('localStorage write failed:', err);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

// โ”€โ”€ Usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function BlogPage() {
  // Persists the selected tag across page refreshes
  const [selectedTag, setSelectedTag] = useLocalStorage('blog_selected_tag', '');
  const [pageSize,    setPageSize]    = useLocalStorage('blog_page_size',    10);

  return (
    <div>
      <FilterBar activeTag={selectedTag} onTagChange={setSelectedTag} />
    </div>
  );
}

Pattern 5 โ€” Document Title Hook

// useDocumentTitle custom hook
function useDocumentTitle(title) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = title ? `${title} | MERN Blog` : 'MERN Blog';

    return () => {
      document.title = previousTitle; // restore on unmount
    };
  }, [title]);
}

// โ”€โ”€ Usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function PostDetailPage({ post }) {
  useDocumentTitle(post?.title); // sets tab title to "Post Title | MERN Blog"
  return <article>...</article>;
}

function HomePage() {
  useDocumentTitle('Home'); // "Home | MERN Blog"
  return <div>...</div>;
}

Common Mistakes

Mistake 1 โ€” Debouncing without cleanup (timer keeps running)

โŒ Wrong โ€” timer fires even if query changed before delay elapsed:

useEffect(() => {
  setTimeout(() => onSearch(query), 400);
  // No cleanup โ€” if user types 5 characters in 400ms, 5 API calls still fire
}, [query]);

โœ… Correct โ€” cancel the pending timer when query changes:

useEffect(() => {
  const id = setTimeout(() => onSearch(query), 400);
  return () => clearTimeout(id); // โœ“ previous timer cancelled before new one starts
}, [query]);

Mistake 2 โ€” Not clearing post state when ID changes (shows stale content)

โŒ Wrong โ€” old post shows briefly before new one loads:

useEffect(() => {
  fetchPost(id).then(setPost);
}, [id]); // previous post still visible while new one loads

โœ… Correct โ€” clear the post at the start of each fetch:

useEffect(() => {
  setPost(null); // clear immediately โ†’ spinner shows instead of stale content
  fetchPost(id).then(setPost);
}, [id]); // โœ“

Mistake 3 โ€” Parsing localStorage in the render function instead of lazy init

โŒ Wrong โ€” JSON.parse runs on every render:

const [theme, setTheme] = useState(JSON.parse(localStorage.getItem('theme') || '"light"'));
// JSON.parse called on every render โ€” slow, and fails if storage is unavailable

โœ… Correct โ€” use lazy initialisation (function passed to useState):

const [theme, setTheme] = useState(() => {
  try { return JSON.parse(localStorage.getItem('theme')) ?? 'light'; }
  catch { return 'light'; }
}); // runs only once on mount โœ“

Custom Hooks Summary

Hook What It Encapsulates Returns
useFetch(url, params) Axios GET + loading/error/data state + AbortController { data, loading, error }
useDebounce(value, delay) setTimeout + clearTimeout cleanup debounced value
useLocalStorage(key, init) localStorage read (lazy) + write on change [value, setValue]
useDocumentTitle(title) document.title update + restore on unmount nothing (side effect only)

🧠 Test Yourself

You build a search box that fires an API call in a useEffect([query]). With no debounce, a user typing “mern” fires 4 API requests (m, me, mer, mern). After adding a 400ms debounce with a cleanup that calls clearTimeout, how many API calls are made if the user types all four characters within 200ms?