Skip to content

React useEffect Hook - Complete Guide and Common Mistakes

The useEffect hook is one of the most powerful and commonly misused hooks in React. This guide covers all useEffect patterns, common mistakes, and best practices for building reliable React applications.

Understanding useEffect

useEffect lets you perform side effects in functional components, replacing class component lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount.

Basic Syntax

import { useEffect } from 'react';

function MyComponent() {
    useEffect(() => {
        // Side effect code here

        return () => {
            // Cleanup code (optional)
        };
    }, []); // Dependency array

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

Common useEffect Patterns

1. Component Mount (Similar to componentDidMount)

function UserProfile({ userId }: { userId: string }) {
    const [user, setUser] = useState<User | null>(null);
    const [loading, setLoading] = useState(true);

    // Runs once when component mounts
    useEffect(() => {
        async function fetchUser() {
            try {
                const userData = await api.getUser(userId);
                setUser(userData);
            } catch (error) {
                console.error('Failed to fetch user:', error);
            } finally {
                setLoading(false);
            }
        }

        fetchUser();
    }, []); // Empty dependency array = run once

    if (loading) return <div>Loading...</div>;
    return <div>{user?.name}</div>;
}

2. Dependency-Based Effects

function SearchResults({ searchTerm }: { searchTerm: string }) {
    const [results, setResults] = useState<SearchResult[]>([]);
    const [loading, setLoading] = useState(false);

    // Runs when searchTerm changes
    useEffect(() => {
        if (!searchTerm.trim()) {
            setResults([]);
            return;
        }

        setLoading(true);

        async function search() {
            try {
                const data = await api.search(searchTerm);
                setResults(data);
            } catch (error) {
                console.error('Search failed:', error);
            } finally {
                setLoading(false);
            }
        }

        search();
    }, [searchTerm]); // Runs when searchTerm changes

    return (
        <div>
            {loading && <div>Searching...</div>}
            {results.map(result => (
                <div key={result.id}>{result.title}</div>
            ))}
        </div>
    );
}

3. Cleanup with Subscriptions

function LiveChat({ roomId }: { roomId: string }) {
    const [messages, setMessages] = useState<Message[]>([]);

    useEffect(() => {
        const socket = new WebSocket(`ws://localhost:8000/chat/${roomId}`);

        socket.onmessage = (event) => {
            const message = JSON.parse(event.data);
            setMessages(prev => [...prev, message]);
        };

        socket.onerror = (error) => {
            console.error('WebSocket error:', error);
        };

        // Cleanup function - runs when component unmounts or roomId changes
        return () => {
            socket.close();
        };
    }, [roomId]);

    return (
        <div>
            {messages.map(msg => (
                <div key={msg.id}>{msg.text}</div>
            ))}
        </div>
    );
}

4. Timer and Intervals

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

    useEffect(() => {
        let interval: NodeJS.Timeout;

        if (isActive) {
            interval = setInterval(() => {
                setSeconds(seconds => seconds + 1);
            }, 1000);
        }

        return () => {
            if (interval) {
                clearInterval(interval);
            }
        };
    }, [isActive]); // Runs when isActive changes

    return (
        <div>
            <div>Time: {seconds}s</div>
            <button onClick={() => setIsActive(!isActive)}>
                {isActive ? 'Pause' : 'Start'}
            </button>
        </div>
    );
}

Common Mistakes and Solutions

1. Missing Dependencies

❌ Wrong - Missing dependency

function UserProfile({ userId }: { userId: string }) {
    const [user, setUser] = useState<User | null>(null);

    useEffect(() => {
        fetchUser(userId).then(setUser);
    }, []); // Missing userId dependency

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

✅ Correct - Include all dependencies

function UserProfile({ userId }: { userId: string }) {
    const [user, setUser] = useState<User | null>(null);

    useEffect(() => {
        fetchUser(userId).then(setUser);
    }, [userId]); // Include userId dependency

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

2. Infinite Re-renders

❌ Wrong - Object/function in dependency array

function DataFetcher({ config }: { config: RequestConfig }) {
    const [data, setData] = useState(null);

    useEffect(() => {
        fetchData(config).then(setData);
    }, [config]); // config is an object, causes infinite re-renders

    return <div>{data}</div>;
}

✅ Correct - Destructure or use useMemo

function DataFetcher({ config }: { config: RequestConfig }) {
    const [data, setData] = useState(null);

    // Option 1: Destructure specific properties
    useEffect(() => {
        fetchData(config).then(setData);
    }, [config.url, config.method]); // Only depend on specific values

    return <div>{data}</div>;
}

// Option 2: Use useMemo for stable reference
function DataFetcher({ config }: { config: RequestConfig }) {
    const [data, setData] = useState(null);

    const stableConfig = useMemo(() => config, [
        config.url,
        config.method,
        config.headers
    ]);

    useEffect(() => {
        fetchData(stableConfig).then(setData);
    }, [stableConfig]);

    return <div>{data}</div>;
}

3. Stale Closures

❌ Wrong - Stale closure in interval

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

    useEffect(() => {
        const interval = setInterval(() => {
            setCount(count + 1); // Always uses initial count value (0)
        }, 1000);

        return () => clearInterval(interval);
    }, []); // Empty dependencies cause stale closure

    return <div>{count}</div>;
}

✅ Correct - Use functional state update

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

    useEffect(() => {
        const interval = setInterval(() => {
            setCount(prevCount => prevCount + 1); // Uses current count
        }, 1000);

        return () => clearInterval(interval);
    }, []); // Empty dependencies are fine now

    return <div>{count}</div>;
}

4. Not Cleaning Up Event Listeners

❌ Wrong - Memory leak

function WindowSizeTracker() {
    const [windowSize, setWindowSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight
    });

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

        window.addEventListener('resize', handleResize);
        // Missing cleanup!
    }, []);

    return <div>{windowSize.width} x {windowSize.height}</div>;
}

✅ Correct - Clean up event listeners

function WindowSizeTracker() {
    const [windowSize, setWindowSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight
    });

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

        window.addEventListener('resize', handleResize);

        return () => {
            window.removeEventListener('resize', handleResize);
        };
    }, []);

    return <div>{windowSize.width} x {windowSize.height}</div>;
}

5. Race Conditions in Async Operations

❌ Wrong - Race condition

function UserProfile({ userId }: { userId: string }) {
    const [user, setUser] = useState<User | null>(null);

    useEffect(() => {
        async function fetchUser() {
            const userData = await api.getUser(userId);
            setUser(userData); // May set wrong user if userId changed
        }

        fetchUser();
    }, [userId]);

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

✅ Correct - Handle race conditions

function UserProfile({ userId }: { userId: string }) {
    const [user, setUser] = useState<User | null>(null);

    useEffect(() => {
        let cancelled = false;

        async function fetchUser() {
            try {
                const userData = await api.getUser(userId);
                if (!cancelled) {
                    setUser(userData);
                }
            } catch (error) {
                if (!cancelled) {
                    console.error('Failed to fetch user:', error);
                }
            }
        }

        fetchUser();

        return () => {
            cancelled = true;
        };
    }, [userId]);

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

Advanced useEffect Patterns

1. Custom Hook for Data Fetching

function useApi<T>(url: string) {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        let cancelled = false;

        async function fetchData() {
            try {
                setLoading(true);
                setError(null);

                const response = await fetch(url);
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }

                const result = await response.json();

                if (!cancelled) {
                    setData(result);
                }
            } catch (err) {
                if (!cancelled) {
                    setError(err instanceof Error ? err.message : 'Unknown error');
                }
            } finally {
                if (!cancelled) {
                    setLoading(false);
                }
            }
        }

        fetchData();

        return () => {
            cancelled = true;
        };
    }, [url]);

    return { data, loading, error };
}

// Usage
function UserList() {
    const { data: users, loading, error } = useApi<User[]>('/api/users');

    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. Debounced Effect

function useDebounce<T>(value: T, delay: number): T {
    const [debouncedValue, setDebouncedValue] = useState<T>(value);

    useEffect(() => {
        const handler = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        return () => {
            clearTimeout(handler);
        };
    }, [value, delay]);

    return debouncedValue;
}

function SearchComponent() {
    const [searchTerm, setSearchTerm] = useState('');
    const [results, setResults] = useState<SearchResult[]>([]);
    const debouncedSearchTerm = useDebounce(searchTerm, 300);

    useEffect(() => {
        if (debouncedSearchTerm) {
            searchApi(debouncedSearchTerm).then(setResults);
        } else {
            setResults([]);
        }
    }, [debouncedSearchTerm]);

    return (
        <div>
            <input
                type="text"
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.target.value)}
                placeholder="Search..."
            />
            {results.map(result => (
                <div key={result.id}>{result.title}</div>
            ))}
        </div>
    );
}

3. AbortController for Cancellation

function useApiWithCancellation<T>(url: string) {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        const abortController = new AbortController();

        async function fetchData() {
            try {
                setLoading(true);
                setError(null);

                const response = await fetch(url, {
                    signal: abortController.signal
                });

                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }

                const result = await response.json();
                setData(result);
            } catch (err) {
                if (err.name === 'AbortError') {
                    // Request was cancelled, don't update state
                    return;
                }
                setError(err instanceof Error ? err.message : 'Unknown error');
            } finally {
                setLoading(false);
            }
        }

        fetchData();

        return () => {
            abortController.abort();
        };
    }, [url]);

    return { data, loading, error };
}

4. Multiple Effects for Separation of Concerns

function UserDashboard({ userId }: { userId: string }) {
    const [user, setUser] = useState<User | null>(null);
    const [posts, setPosts] = useState<Post[]>([]);
    const [notifications, setNotifications] = useState<Notification[]>([]);

    // Effect 1: Fetch user data
    useEffect(() => {
        fetchUser(userId).then(setUser);
    }, [userId]);

    // Effect 2: Fetch user posts
    useEffect(() => {
        fetchUserPosts(userId).then(setPosts);
    }, [userId]);

    // Effect 3: Set up notifications
    useEffect(() => {
        const unsubscribe = subscribeToNotifications(userId, setNotifications);
        return unsubscribe;
    }, [userId]);

    // Effect 4: Update document title
    useEffect(() => {
        if (user) {
            document.title = `${user.name} - Dashboard`;
        }

        return () => {
            document.title = 'App';
        };
    }, [user]);

    return (
        <div>
            {/* Component JSX */}
        </div>
    );
}

Performance Optimization

1. Avoid Unnecessary Re-renders

// Use useCallback for stable function references
function UserList() {
    const [users, setUsers] = useState<User[]>([]);
    const [filter, setFilter] = useState('');

    const handleUserUpdate = useCallback((userId: string, newData: Partial<User>) => {
        setUsers(prev => prev.map(user => 
            user.id === userId ? { ...user, ...newData } : user
        ));
    }, []);

    useEffect(() => {
        fetchUsers().then(setUsers);
    }, []);

    const filteredUsers = useMemo(() => 
        users.filter(user => user.name.toLowerCase().includes(filter.toLowerCase()))
    , [users, filter]);

    return (
        <div>
            <input
                value={filter}
                onChange={(e) => setFilter(e.target.value)}
                placeholder="Filter users..."
            />
            {filteredUsers.map(user => (
                <UserItem
                    key={user.id}
                    user={user}
                    onUpdate={handleUserUpdate}
                />
            ))}
        </div>
    );
}

2. Conditional Effects

function ConditionalEffects({ shouldFetch, userId }: { shouldFetch: boolean; userId: string }) {
    const [data, setData] = useState(null);

    useEffect(() => {
        if (!shouldFetch) return;

        fetchUserData(userId).then(setData);
    }, [shouldFetch, userId]);

    return <div>{data && JSON.stringify(data)}</div>;
}

Testing useEffect

1. Testing with React Testing Library

import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserProfile from './UserProfile';

const server = setupServer(
    rest.get('/api/users/:userId', (req, res, ctx) => {
        const { userId } = req.params;
        return res(ctx.json({ id: userId, name: 'John Doe' }));
    })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('fetches and displays user data', async () => {
    render(<UserProfile userId="123" />);

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await waitFor(() => {
        expect(screen.getByText('John Doe')).toBeInTheDocument();
    });
});

test('handles fetch error', async () => {
    server.use(
        rest.get('/api/users/:userId', (req, res, ctx) => {
            return res(ctx.status(500), ctx.json({ error: 'Server error' }));
        })
    );

    render(<UserProfile userId="123" />);

    await waitFor(() => {
        expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
});

2. Testing Cleanup

import { render, unmountComponentAtNode } from '@testing-library/react';

test('cleans up event listeners on unmount', () => {
    const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
    const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');

    const { unmount } = render(<WindowSizeTracker />);

    expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));

    unmount();

    expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));

    addEventListenerSpy.mockRestore();
    removeEventListenerSpy.mockRestore();
});

Debugging useEffect

1. Add Logging

function DebugEffect({ userId }: { userId: string }) {
    useEffect(() => {
        console.log('Effect running for userId:', userId);

        return () => {
            console.log('Effect cleanup for userId:', userId);
        };
    }, [userId]);

    return <div>User: {userId}</div>;
}

2. Use React DevTools Profiler

The React DevTools Profiler can help identify unnecessary re-renders and effect executions.

Best Practices Summary

  1. Always include all dependencies in the dependency array
  2. Use functional state updates to avoid stale closures
  3. Clean up side effects (timers, subscriptions, event listeners)
  4. Handle race conditions in async operations
  5. Separate concerns with multiple effects
  6. Use custom hooks to encapsulate reusable effect logic
  7. Optimize performance with useCallback and useMemo
  8. Test effects thoroughly including cleanup behavior

External Resources