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} />;
}
[]) 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.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.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) |