Chapter 2: useEffect

This chapter is crucial. useEffect is the most complex hook to master, but also the most used after useState.

What is a Side Effect?

A side effect is any operation that affects something outside the component:

  • Calling an API
  • Modifying the page title
  • Subscribing to an event (WebSocket, resize, scroll)
  • Direct DOM manipulation
  • Using a timer (setTimeout, setInterval)

These operations cannot be done directly in the component body, as it runs on every render.

Basic Syntax

import { useEffect } from 'react';

function MyComponent() {
    useEffect(() => {
        // Code runs after render
        console.log('Component rendered!');
    });

    return <div>My component</div>;
}

The Dependency Array

The second argument of useEffect controls when the effect runs:

No array: on every render

useEffect(() => {
    console.log('Runs after EVERY render');
});

Rarely useful - can cause performance issues.

Empty array: once only

useEffect(() => {
    console.log('Runs ONCE after first render');
}, []);

Equivalent to componentDidMount in classes.

With dependencies: when they change

useEffect(() => {
    console.log('Runs when userId changes');
    fetchUser(userId);
}, [userId]);

The effect runs:

  1. After the first render
  2. After every render where userId has changed

Concrete Example: API Call

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

    useEffect(() => {
        // Async function in useEffect
        const fetchUser = async () => {
            setLoading(true);
            setError(null);

            try {
                const response = await fetch(`/api/users/${userId}`);
                const data = await response.json();
                setUser(data);
            } catch (err) {
                setError(err.message);
            } finally {
                setLoading(false);
            }
        };

        fetchUser();
    }, [userId]);  // Re-fetch when userId changes

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

    return <div>{user.name}</div>;
}

Cleanup Function

useEffect can return a function that will be called:

  • Before the next effect execution
  • When the component unmounts
useEffect(() => {
    // Setup
    const subscription = api.subscribe(data => {
        setData(data);
    });

    // Cleanup
    return () => {
        subscription.unsubscribe();
    };
}, []);

Example: Resize Event

function WindowSize() {
    const [size, setSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight
    });

    useEffect(() => {
        const handleResize = () => {
            setSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        };

        window.addEventListener('resize', handleResize);

        // Cleanup: remove listener
        return () => {
            window.removeEventListener('resize', handleResize);
        };
    }, []);  // [] because we want to subscribe only once

    return <p>{size.width} x {size.height}</p>;
}

Example: Timer

function Timer() {
    const [seconds, setSeconds] = useState(0);

    useEffect(() => {
        const interval = setInterval(() => {
            setSeconds(s => s + 1);
        }, 1000);

        // Cleanup: stop the timer
        return () => {
            clearInterval(interval);
        };
    }, []);

    return <p>{seconds} seconds</p>;
}

Common Pitfalls

1. Infinite Loop

// DANGER! Infinite loop
useEffect(() => {
    setCount(count + 1);  // Modifies state
});  // No array = runs on every render
// Modifies state -> re-render -> useEffect -> modifies state -> ...

Solution: always add a dependency array.

2. Missing Dependencies

// PROBLEM: userId is missing from dependencies
useEffect(() => {
    fetchUser(userId);
}, []);  // Fetches only on mount, ignores userId changes

Solution: add all used values to the array.

useEffect(() => {
    fetchUser(userId);
}, [userId]);  // Correct

3. Object or Function in Dependencies

// PROBLEM: object is recreated on every render
function MyComponent({ user }) {
    const options = { limit: 10 };  // New object on every render

    useEffect(() => {
        fetchData(options);
    }, [options]);  // options changes on every render!
}

Solution: move outside the component or use useMemo (next module).

4. Async in useEffect

// INCORRECT - useEffect cannot be async
useEffect(async () => {  // Error!
    const data = await fetchData();
}, []);

// CORRECT - define async function inside
useEffect(() => {
    const loadData = async () => {
        const data = await fetchData();
        setData(data);
    };
    loadData();
}, []);

Execution Order

function MyComponent() {
    console.log('1. Render');

    useEffect(() => {
        console.log('3. Effect');
        return () => console.log('2. Cleanup (if re-render)');
    });

    return <div>Test</div>;
}

// First render:
// 1. Render
// 3. Effect

// Re-render:
// 1. Render
// 2. Cleanup
// 3. Effect

Comparison with PHP Lifecycle

Event PHP React useEffect
Initialization constructor useEffect(() => {}, [])
Every request controller code useEffect(() => {})
Cleanup destructor function returned by useEffect

When to Use useEffect?

YES:

  • API calls
  • Subscriptions (WebSocket, events)
  • Timers
  • Direct DOM manipulation
  • Synchronizing with external systems

NO:

  • Computing a derived value from state -> useMemo
  • Transforming data for rendering -> do it in render
  • Reacting to user action -> event handler

Exercises

Exercise 1: Dynamic Page Title

Create a component that updates the page title with the counter.

Solution:

function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        document.title = `Counter: ${count}`;
    }, [count]);

    return (
        <div>
            <p>{count}</p>
            <button onClick={() => setCount(c => c + 1)}>+1</button>
        </div>
    );
}

Exercise 2: Fetch with Error Handling

Create a component that loads a list of posts from an API.

Solution:

function PostList() {
    const [posts, setPosts] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        const fetchPosts = async () => {
            try {
                const response = await fetch('https://jsonplaceholder.typicode.com/posts');
                if (!response.ok) throw new Error('Network error');
                const data = await response.json();
                setPosts(data.slice(0, 10));
            } catch (err) {
                setError(err.message);
            } finally {
                setLoading(false);
            }
        };

        fetchPosts();
    }, []);

    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error: {error}</p>;

    return (
        <ul>
            {posts.map(post => (
                <li key={post.id}>{post.title}</li>
            ))}
        </ul>
    );
}

Key Points to Remember

  1. useEffect runs code after rendering
  2. Empty array [] = once on mount
  3. With dependencies = when these values change
  4. Return function = cleanup
  5. Always list dependencies used in the effect
  6. Async: define async function inside, not on useEffect

<- Previous chapter | Back to module | Next chapter: useRef ->