What is useEffect? Side Effects in React

React component functions are supposed to be pure โ€” given the same props and state, they should return the same JSX, with no observable side effects during rendering. But real applications cannot be purely functional: they need to fetch data from servers, update the browser tab title, set up WebSocket connections, and read from localStorage. These are side effects โ€” operations that interact with the world outside React’s rendering system. The useEffect hook is how React lets you run side effects safely, after rendering, in a controlled way. Understanding this โ€” what a side effect is, why it cannot run during render, and what useEffect provides โ€” is the foundation for every data-fetching, event-listening, and timer-based feature in your MERN Blog.

What Is a Side Effect?

Not a Side Effect (safe in render) Side Effect (needs useEffect)
Computing a filtered list from state Fetching data from an API
Formatting a date string Setting document.title
Returning JSX Reading or writing to localStorage
Calling a pure utility function Setting up a timer (setTimeout, setInterval)
Performing arithmetic Adding/removing DOM event listeners
Deriving state from props Opening a WebSocket connection
Note: React may call your component function multiple times in certain situations โ€” in StrictMode (development only), during concurrent rendering, and when it needs to discard a partial render. If your component function contains side effects (like an API call directly in the function body), those side effects would run on every call, causing duplicate requests, incorrect subscriptions, or race conditions. useEffect solves this by deferring side effects until after the render is committed to the DOM.
Tip: Think of useEffect as a hook that says “after this render is done and painted to the screen, run this code.” This is distinct from rendering itself. Your component function describes what the UI should look like. useEffect describes what to do after the UI is shown โ€” fetch data, update the title, set up a listener.
Warning: In React 18 with StrictMode enabled (the default in development with Vite), every effect runs twice on mount โ€” React mounts the component, runs effects, then immediately unmounts and remounts it to simulate what would happen if the component was removed and re-added. This helps detect bugs caused by effects that do not clean up properly. Your effect code should be idempotent or have a cleanup function. This double-run only happens in development, not in production.

useEffect Syntax

import { useEffect } from 'react';

// Basic form
useEffect(
  () => {
    // Side effect code runs here โ€” after render
    // ...
    return () => {
      // Optional cleanup function โ€” runs before the next effect or on unmount
    };
  },
  [/* dependency array */]
);

The Three Dependency Array Forms

// 1. No dependency array โ€” runs after EVERY render
useEffect(() => {
  console.log('Rendered!'); // runs on every render โ€” rarely what you want
});

// 2. Empty array โ€” runs ONCE after the initial render (mount)
useEffect(() => {
  console.log('Component mounted!'); // runs once โ€” fetch initial data here
}, []);

// 3. Array with values โ€” runs when those values change
useEffect(() => {
  console.log(`Page changed to: ${page}`); // runs on mount and when page changes
}, [page]);

The React Component Lifecycle โ€” with useEffect

Component Lifecycle (Function Component)

Mount:
  1. Component function runs โ†’ JSX returned โ†’ DOM updated
  2. useEffect(fn, []) runs โ†’ initial data fetch, event listeners, etc.

Update (state or prop changes):
  1. Component function runs again โ†’ new JSX โ†’ DOM updated
  2. useEffect cleanup runs for effects whose dependencies changed
  3. useEffect(fn, [dep]) runs again if dep changed

Unmount (component removed from tree):
  1. useEffect cleanup functions run โ†’ cancel requests, clear timers, remove listeners
  2. Component is removed from DOM

Simple useEffect Examples

// โ”€โ”€ Update document title โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function PostPage({ post }) {
  useEffect(() => {
    if (post) {
      document.title = `${post.title} | MERN Blog`; // side effect
    }
    return () => {
      document.title = 'MERN Blog'; // cleanup: reset on unmount
    };
  }, [post]); // re-run when post changes

  return <article><h1>{post?.title}</h1></article>;
}

// โ”€โ”€ Simple mount-only effect โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function Analytics() {
  useEffect(() => {
    // Track page view โ€” runs once on mount
    analytics.track('page_view', { page: window.location.pathname });
  }, []); // empty deps โ€” runs once only

  return null;
}

// โ”€โ”€ Sync state to localStorage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function ThemeToggle() {
  const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');

  useEffect(() => {
    localStorage.setItem('theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]); // sync whenever theme changes

  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      {theme === 'light' ? '๐ŸŒ™ Dark' : 'โ˜€๏ธ Light'}
    </button>
  );
}

Common Mistakes

Mistake 1 โ€” Running a side effect directly in the component body

โŒ Wrong โ€” API call during render causes infinite loops or duplicated requests:

function PostList() {
  const [posts, setPosts] = useState([]);
  // This runs on EVERY render โ€” including the render caused by setPosts!
  fetch('/api/posts').then(r => r.json()).then(d => setPosts(d.data));
  return <div>...</div>;
}

โœ… Correct โ€” put the fetch inside useEffect:

function PostList() {
  const [posts, setPosts] = useState([]);
  useEffect(() => {
    fetch('/api/posts').then(r => r.json()).then(d => setPosts(d.data));
  }, []); // โœ“ runs once after mount
  return <div>...</div>;
}

Mistake 2 โ€” Using async directly as the useEffect callback

โŒ Wrong โ€” useEffect callback cannot be async:

useEffect(async () => { // โ† async function returns a Promise, not a cleanup function
  const data = await fetch('/api/posts');
}, []);

โœ… Correct โ€” define an async function inside and call it:

useEffect(() => {
  const fetchPosts = async () => {
    const res  = await fetch('/api/posts');
    const data = await res.json();
    setPosts(data.data);
  };
  fetchPosts(); // call the async function
}, []); // โœ“ outer callback is synchronous

Mistake 3 โ€” Forgetting that StrictMode double-invokes effects

โŒ Wrong โ€” effect creates a resource but cleanup does not release it:

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com');
  // No cleanup โ€” StrictMode creates two WebSocket connections in development!
}, []);

โœ… Correct โ€” always clean up resources the effect creates:

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com');
  return () => ws.close(); // โœ“ cleanup closes the connection
}, []);

Quick Reference

Form When It Runs Use For
useEffect(fn) After every render Rarely โ€” logging, debugging
useEffect(fn, []) Once after mount Initial data fetch, event listeners
useEffect(fn, [x]) After mount + when x changes Refetch when ID changes, sync to storage
Return cleanup function Before next effect + on unmount Cancel requests, clear timers, remove listeners

🧠 Test Yourself

You write useEffect(async () => { const data = await fetchPosts(); setPosts(data); }, []). React logs a warning. Why?