Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
22 minLesson 5 of 33
React Core Concepts

useEffect: Side Effects Mastery

useEffect: Side Effects Mastery

useEffect is the most misunderstood hook in React. When used correctly, it elegantly handles data fetching, subscriptions, and DOM manipulation. When misused, it creates infinite loops, memory leaks, and bugs that are hard to diagnose.

What Are Side Effects?

A side effect is anything that happens outside the React render process:

  • Data fetching — API calls, database queries
  • Subscriptions — WebSocket connections, event listeners
  • Timers — setTimeout, setInterval
  • DOM manipulation — directly accessing the DOM
  • Logging — analytics, error tracking

These can't go inside the render function (the component body) because renders should be pure — same props/state → same output. Side effects, by definition, interact with the outside world.

Basic useEffect Syntax

import { useEffect } from 'react';

useEffect(() => {
    // Effect code runs here
    console.log('Effect ran!');
    
    return () => {
        // Cleanup function (optional) — runs before effect runs again, or on unmount
        console.log('Cleanup ran!');
    };
}, [/* dependency array */]);

The Dependency Array — The Key to Understanding useEffect

The dependency array controls when the effect runs:

// No dependency array — runs after EVERY render
useEffect(() => {
    console.log('Runs after every render');
});

// Empty array — runs ONCE after mount
useEffect(() => {
    console.log('Runs once, like componentDidMount');
}, []);

// With dependencies — runs when any dependency changes
useEffect(() => {
    console.log(`Count changed to: ${count}`);
}, [count]);

// Multiple dependencies — runs when ANY of them change
useEffect(() => {
    fetchUserData(userId, page);
}, [userId, page]);

Data Fetching Pattern

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        // Flag to prevent state updates after unmount
        let cancelled = false;

        async function fetchUser() {
            try {
                setLoading(true);
                setError(null);
                
                const response = await fetch(`/api/users/${userId}`);
                if (!response.ok) throw new Error(`HTTP ${response.status}`);
                
                const data = await response.json();
                
                if (!cancelled) {   // Only update state if still mounted
                    setUser(data);
                }
            } catch (err) {
                if (!cancelled) {
                    setError(err.message);
                }
            } finally {
                if (!cancelled) {
                    setLoading(false);
                }
            }
        }

        fetchUser();

        return () => {
            cancelled = true;   // Cleanup: cancel any pending state updates
        };
    }, [userId]);   // Re-fetch when userId changes

    if (loading) return <div>Loading...</div>;
    if (error)   return <div>Error: {error}</div>;
    if (!user)   return null;
    return <UserCard user={user} />;
}

Cleanup Functions

The cleanup function runs before:

  1. The effect runs again (when dependencies change)
  2. The component unmounts
// Clearing a timer
function AutoSave({ content }) {
    useEffect(() => {
        const timerId = setTimeout(() => {
            saveContent(content);
        }, 2000);

        return () => clearTimeout(timerId);  // Cancel pending save on re-render
    }, [content]);
}

// Removing event listeners
function MouseTracker() {
    const [position, setPosition] = useState({ x: 0, y: 0 });

    useEffect(() => {
        function handleMouseMove(e) {
            setPosition({ x: e.clientX, y: e.clientY });
        }

        window.addEventListener('mousemove', handleMouseMove);
        
        return () => {
            window.removeEventListener('mousemove', handleMouseMove);  // Clean up!
        };
    }, []);   // Empty array = add listener once, remove on unmount

    return <div>Mouse: {position.x}, {position.y}</div>;
}

// Closing WebSocket connections
function LiveChat({ roomId }) {
    useEffect(() => {
        const socket = new WebSocket(`wss://chat.example.com/${roomId}`);
        socket.onmessage = (e) => handleMessage(e.data);
        
        return () => socket.close();   // Close connection on cleanup
    }, [roomId]);
}

Common Mistakes

Mistake 1: Missing Dependencies

// BUG: count is used but not in dependencies
useEffect(() => {
    if (count > 10) {
        sendAlert(`Count reached: ${count}`);   // Uses stale count!
    }
}, []);   // Missing count

// FIX: include all used values
useEffect(() => {
    if (count > 10) {
        sendAlert(`Count reached: ${count}`);
    }
}, [count]);

ESLint's react-hooks/exhaustive-deps rule catches this automatically. Use it.

Mistake 2: Creating an Infinite Loop

// BUG: setCount causes re-render → effect runs → setCount → infinite loop
useEffect(() => {
    setCount(count + 1);   // This runs after every render, triggers another render
});   // No dependency array!

// Also a bug:
useEffect(() => {
    setUser({ ...user, lastSeen: new Date() });  // Creates new object every time
}, [user]);   // user changes → effect runs → user changes → infinite loop

// FIX: be specific about what should trigger the effect
useEffect(() => {
    // Only update lastSeen once on mount
    setUser(prev => ({ ...prev, lastSeen: new Date() }));
}, []);   // Run once

Mistake 3: async function directly in useEffect

// BUG: useEffect callback can't be async (it must return a cleanup function or nothing)
useEffect(async () => {
    const data = await fetchData();  // This "works" but with subtle issues
    setData(data);
}, []);

// CORRECT: define an async function inside and call it
useEffect(() => {
    async function fetchAndSet() {
        const data = await fetchData();
        setData(data);
    }
    fetchAndSet();
}, []);

When NOT to Use useEffect

React 19 philosophy: useEffect is for synchronizing with external systems. Don't use it for:

// ❌ Deriving state from props — just compute it
useEffect(() => {
    setFullName(`${firstName} ${lastName}`);  // Unnecessary effect
}, [firstName, lastName]);
// ✅ Compute directly
const fullName = `${firstName} ${lastName}`;

// ❌ Transforming data on load
useEffect(() => {
    setFilteredList(list.filter(item => item.active));
}, [list]);
// ✅ Compute directly
const filteredList = list.filter(item => item.active);

// ❌ Calling event handlers
useEffect(() => {
    if (submitted) {
        sendForm(formData);  // This is an event response, not a sync
    }
}, [submitted]);
// ✅ Call in the event handler directly
function handleSubmit() {
    sendForm(formData);
}

If the effect is "when X changes, do Y" and Y is a state update or user-triggered action, it probably doesn't belong in useEffect.

Next lesson: Context API & useContext — sharing state across components without prop drilling.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!