useEffect: Managing Side Effects in React

Connecting Your Components to the Outside World

Overview

Welcome to the world of side effects! If components are like functions that turn data into UI, side effects are like the component's interactions with the outside world. Today, we'll master the useEffect Hook, which lets us perform side effects like data fetching, subscriptions, and DOM manipulation in our functional components.

What are Side Effects?

Side effects are operations that affect something outside the component's scope. Think of them like a chef who not only cooks (renders) but also orders ingredients (fetches data), sets timers (subscriptions), or adjusts the kitchen temperature (DOM manipulation).

graph TD A[Component] --> B[Render UI] A --> C[Side Effects] C --> D[Data Fetching] C --> E[Event Listeners] C --> F[Timers/Intervals] C --> G[Manual DOM Changes] C --> H[Subscriptions] C --> I[Browser APIs] style C fill:#ff9,stroke:#333,stroke-width:2px style A fill:#9ff,stroke:#333,stroke-width:2px

Introducing useEffect

useEffect is like a component's connection to the outside world. It runs after the render is committed to the screen.


import React, { useState, useEffect } from 'react';

function MyComponent() {
    const [data, setData] = useState(null);
    
    useEffect(() => {
        // This runs after every render
        console.log('Component rendered or updated!');
    });
    
    return <div>{/* component content */}</div>;
}
            

Basic useEffect Syntax


useEffect(() => {
    // Setup code runs after render
    // This is where you perform side effects
    
    return () => {
        // Cleanup code runs before next effect or unmount
        // This is optional
    };
}, [/* dependencies */]);
            

The Three Patterns of useEffect

1. Effect on Every Render


function LoggerComponent() {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        console.log('Component rendered! Count is:', count);
    }); // No dependency array - runs after every render
    
    return (
        <button onClick={() => setCount(count + 1)}>
            Increment: {count}
        </button>
    );
}
            

2. Effect Only on Mount


function WelcomeMessage() {
    useEffect(() => {
        console.log('Component mounted!');
        alert('Welcome to our app!');
        
        return () => {
            console.log('Component will unmount!');
        };
    }, []); // Empty dependency array - runs once on mount
    
    return <h1>Welcome!</h1>;
}
            

3. Effect with Dependencies


function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    
    useEffect(() => {
        console.log('Fetching user data for ID:', userId);
        
        fetchUser(userId).then(userData => {
            setUser(userData);
        });
    }, [userId]); // Runs when userId changes
    
    if (!user) return <div>Loading...</div>;
    return <div>{user.name}</div>;
}
            

Effect Lifecycle

sequenceDiagram participant C as Component participant E as useEffect participant R as Return (Cleanup) C->>C: Initial Render C->>E: Run Effect Note over E: Setup code executes C->>C: Re-render (state change) E->>R: Run Cleanup (if exists) C->>E: Run Effect Again C->>C: Unmount E->>R: Run Final Cleanup

Common Use Cases

1. Data Fetching


function UserList() {
    const [users, setUsers] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        async function fetchUsers() {
            try {
                const response = await fetch('https://api.example.com/users');
                const data = await response.json();
                setUsers(data);
            } catch (err) {
                setError(err.message);
            } finally {
                setLoading(false);
            }
        }
        
        fetchUsers();
    }, []); // Fetch once on mount
    
    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;
    
    return (
        <ul>
            {users.map(user => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
}
            

2. Event Listeners


function WindowSize() {
    const [windowSize, setWindowSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight
    });
    
    useEffect(() => {
        function handleResize() {
            setWindowSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        }
        
        // Add event listener
        window.addEventListener('resize', handleResize);
        
        // Cleanup function
        return () => {
            window.removeEventListener('resize', handleResize);
        };
    }, []); // Empty deps = setup once
    
    return (
        <div>
            Window size: {windowSize.width} x {windowSize.height}
        </div>
    );
}
            

3. Timers and Intervals


function Timer() {
    const [seconds, setSeconds] = useState(0);
    
    useEffect(() => {
        const interval = setInterval(() => {
            setSeconds(s => s + 1);
        }, 1000);
        
        // Cleanup function
        return () => clearInterval(interval);
    }, []); // Empty deps = setup once
    
    return <div>Seconds: {seconds}</div>;
}
            

4. Subscriptions


function ChatRoom({ roomId }) {
    const [messages, setMessages] = useState([]);
    
    useEffect(() => {
        // Subscribe to chat room
        const subscription = subscribeToChat(roomId, (message) => {
            setMessages(prev => [...prev, message]);
        });
        
        // Cleanup function
        return () => {
            subscription.unsubscribe();
        };
    }, [roomId]); // Resubscribe when roomId changes
    
    return (
        <div>
            {messages.map((msg, i) => (
                <div key={i}>{msg}</div>
            ))}
        </div>
    );
}
            

Dependencies Deep Dive

Dependencies tell React when to re-run your effect. Think of them as "watchers" - when these values change, the effect runs again.


function SearchResults({ query, filters }) {
    const [results, setResults] = useState([]);
    
    useEffect(() => {
        // This effect depends on query and filters
        async function fetchResults() {
            const data = await searchAPI(query, filters);
            setResults(data);
        }
        
        fetchResults();
    }, [query, filters]); // Re-run when query or filters change
    
    return (
        <ul>
            {results.map(result => (
                <li key={result.id}>{result.title}</li>
            ))}
        </ul>
    );
}
            

Common Dependency Pitfalls

1. Missing Dependencies


// ❌ WRONG - Missing dependency
function Counter() {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        const timer = setInterval(() => {
            setCount(count + 1); // Bug: count is stale
        }, 1000);
        
        return () => clearInterval(timer);
    }, []); // Missing count dependency
    
    return <div>{count}</div>;
}

// ✅ CORRECT - Include dependency or use functional update
function Counter() {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        const timer = setInterval(() => {
            setCount(c => c + 1); // Use functional update
        }, 1000);
        
        return () => clearInterval(timer);
    }, []); // No dependency needed
    
    return <div>{count}</div>;
}
            

2. Object Dependencies


// ❌ PROBLEM - Object created on every render
function UserSearch() {
    const [results, setResults] = useState([]);
    const filters = { active: true, role: 'admin' }; // New object every render
    
    useEffect(() => {
        fetchUsers(filters).then(setResults);
    }, [filters]); // Effect runs on every render!
    
    return <div>{/* results */}</div>;
}

// ✅ SOLUTION 1 - Move object outside component
const filters = { active: true, role: 'admin' };

function UserSearch() {
    const [results, setResults] = useState([]);
    
    useEffect(() => {
        fetchUsers(filters).then(setResults);
    }, [filters]); // Now stable
    
    return <div>{/* results */}</div>;
}

// ✅ SOLUTION 2 - Use individual values as dependencies
function UserSearch() {
    const [results, setResults] = useState([]);
    const active = true;
    const role = 'admin';
    
    useEffect(() => {
        fetchUsers({ active, role }).then(setResults);
    }, [active, role]); // Depend on primitive values
    
    return <div>{/* results */}</div>;
}
            

Cleanup Functions

Cleanup functions prevent memory leaks and unwanted behavior. They're like cleaning up your workspace after cooking.


function MouseTracker() {
    const [position, setPosition] = useState({ x: 0, y: 0 });
    
    useEffect(() => {
        function handleMouseMove(e) {
            setPosition({ x: e.clientX, y: e.clientY });
        }
        
        // Setup
        window.addEventListener('mousemove', handleMouseMove);
        
        // Cleanup
        return () => {
            window.removeEventListener('mousemove', handleMouseMove);
        };
    }, []);
    
    return (
        <div>
            Mouse position: {position.x}, {position.y}
        </div>
    );
}
            

Conditional Effects

Sometimes you need to run effects conditionally:


function DataFetcher({ shouldFetch, id }) {
    const [data, setData] = useState(null);
    
    useEffect(() => {
        if (!shouldFetch) return; // Early return if condition not met
        
        let isCancelled = false;
        
        async function fetchData() {
            try {
                const result = await fetchAPI(id);
                if (!isCancelled) {
                    setData(result);
                }
            } catch (error) {
                if (!isCancelled) {
                    console.error('Failed to fetch:', error);
                }
            }
        }
        
        fetchData();
        
        return () => {
            isCancelled = true; // Prevent setting state after unmount
        };
    }, [shouldFetch, id]);
    
    return data ? <div>{data}</div> : null;
}
            

Custom Hooks with useEffect

You can create reusable logic by combining useEffect with custom hooks:


// Custom hook for window size
function useWindowSize() {
    const [size, setSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight
    });
    
    useEffect(() => {
        function handleResize() {
            setSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        }
        
        window.addEventListener('resize', handleResize);
        return () => window.removeEventListener('resize', handleResize);
    }, []);
    
    return size;
}

// Custom hook for data fetching
function useFetch(url) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        let isCancelled = false;
        
        async function fetchData() {
            try {
                setLoading(true);
                const response = await fetch(url);
                const json = await response.json();
                if (!isCancelled) {
                    setData(json);
                }
            } catch (err) {
                if (!isCancelled) {
                    setError(err);
                }
            } finally {
                if (!isCancelled) {
                    setLoading(false);
                }
            }
        }
        
        fetchData();
        
        return () => {
            isCancelled = true;
        };
    }, [url]);
    
    return { data, loading, error };
}

// Using custom hooks
function MyComponent() {
    const windowSize = useWindowSize();
    const { data, loading, error } = useFetch('/api/data');
    
    return (
        <div>
            <p>Window width: {windowSize.width}</p>
            {loading && <p>Loading...</p>}
            {error && <p>Error: {error.message}</p>}
            {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
        </div>
    );
}
            

Common useEffect Patterns

1. Debounced Search


function SearchInput() {
    const [query, setQuery] = useState('');
    const [results, setResults] = useState([]);
    
    useEffect(() => {
        if (!query) {
            setResults([]);
            return;
        }
        
        const timeoutId = setTimeout(() => {
            searchAPI(query).then(setResults);
        }, 300); // Debounce delay
        
        return () => clearTimeout(timeoutId);
    }, [query]);
    
    return (
        <div>
            <input
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search..."
            />
            <ul>
                {results.map(result => (
                    <li key={result.id}>{result.title}</li>
                ))}
            </ul>
        </div>
    );
}
            

2. Previous Value Tracking


function usePrevious(value) {
    const ref = useRef();
    
    useEffect(() => {
        ref.current = value;
    });
    
    return ref.current;
}

function Counter() {
    const [count, setCount] = useState(0);
    const prevCount = usePrevious(count);
    
    return (
        <div>
            <p>Current: {count}</p>
            <p>Previous: {prevCount}</p>
            <button onClick={() => setCount(count + 1)}>
                Increment
            </button>
        </div>
    );
}
            

Best Practices

graph TD A[useEffect Best Practices] --> B[Keep Effects Focused] A --> C[Always Clean Up] A --> D[Avoid Race Conditions] A --> E[Use Custom Hooks] A --> F[Minimize Dependencies] B --> G[One Purpose Per Effect] C --> H[Remove Listeners/Timers] D --> I[Cancel Stale Requests] E --> J[Extract Common Logic] F --> K[Use Stable References] style A fill:#9ff,stroke:#333,stroke-width:2px

Practice Exercises

Exercise 1: Auto-save Form

Create a form that automatically saves to localStorage when inputs change:


function AutoSaveForm() {
    // Implement form with auto-save functionality
    // Save to localStorage whenever form data changes
    // Load saved data on component mount
}
            

Exercise 2: Real-time Clock

Build a clock component that updates every second:


function Clock() {
    // Display current time
    // Update every second
    // Format: HH:MM:SS
    // Clean up interval on unmount
}
            

Exercise 3: API Pagination

Create a component that fetches paginated data:


function PaginatedList() {
    // Fetch data from API with pagination
    // Show loading state
    // Handle errors
    // Update when page changes
    // Cancel pending requests
}
            

Key Takeaways

What's Next?

In our next lesson, we'll explore the component lifecycle in more detail and learn how different hooks interact with each other. We'll also see how to optimize our effects for better performance!

Homework