React useEffect Hook: Understanding the Empty Dependency Array

React useEffect Hook: Understanding the Empty Dependency Array

The useEffect hook runs side effects in functional components:

import { useEffect } from 'react';

function Component() {
    useEffect(() => {
        console.log('Effect ran');
    }, []);  // Empty dependency array
    
    return <div>Component</div>;
}

The empty [] means this effect runs once when the component mounts.

Three Dependency Patterns

No dependency array - runs on every render:

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

Empty array [] - runs once on mount:

useEffect(() => {
    console.log('Runs once when component mounts');
}, []);

Dependencies listed - runs when dependencies change:

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

Component Lifecycle Mapping

For class component developers, here's the mapping:

componentDidMount:

useEffect(() => {
    // Runs once after initial render
}, []);

componentDidUpdate:

useEffect(() => {
    // Runs after every render (except first)
});

// Or with specific dependencies
useEffect(() => {
    // Runs when prop changes
}, [prop]);

componentWillUnmount:

useEffect(() => {
    return () => {
        // Cleanup - runs when component unmounts
    };
}, []);

Common Use Cases

Fetching data on mount:

useEffect(() => {
    async function fetchData() {
        const response = await fetch('/api/users');
        const data = await response.json();
        setUsers(data);
    }
    
    fetchData();
}, []);

Setting up subscriptions:

useEffect(() => {
    const subscription = dataSource.subscribe(handleData);
    
    return () => {
        subscription.unsubscribe();
    };
}, []);

Adding event listeners:

useEffect(() => {
    function handleResize() {
        setWidth(window.innerWidth);
    }
    
    window.addEventListener('resize', handleResize);
    
    return () => {
        window.removeEventListener('resize', handleResize);
    };
}, []);

The Cleanup Function

Return a function to clean up:

useEffect(() => {
    const timer = setInterval(() => {
        setCount(c => c + 1);
    }, 1000);
    
    return () => {
        clearInterval(timer);  // Cleanup
    };
}, []);

Cleanup runs:

  • When the component unmounts
  • Before re-running the effect (if dependencies changed)

Dependency Array Rules

Include every value from component scope that the effect uses:

function Component({ userId }) {
    const [user, setUser] = useState(null);
    
    useEffect(() => {
        fetch(`/api/users/${userId}`)
            .then(r => r.json())
            .then(setUser);
    }, [userId]);  // userId is a dependency
    
    return <div>{user?.name}</div>;
}

Omitting dependencies causes stale closures—the effect uses old values.

The ESLint Rule

React's ESLint plugin warns about missing dependencies:

// ESLint warning: Include 'count' in the dependency array
useEffect(() => {
    console.log(count);
}, []);

Follow these warnings. They prevent bugs.

Infinite Loop Pitfall

Object/array dependencies cause infinite loops if recreated each render:

function Component() {
    const options = { refresh: true };  // New object every render
    
    useEffect(() => {
        fetchData(options);
    }, [options]);  // Infinite loop!
}

Fix with useMemo:

const options = useMemo(() => ({ refresh: true }), []);

useEffect(() => {
    fetchData(options);
}, [options]);  // Runs once

Or remove from dependencies and define inside effect:

useEffect(() => {
    const options = { refresh: true };
    fetchData(options);
}, []);

Function Dependencies

Functions recreated each render also cause loops:

function Component() {
    function handleClick() {
        console.log('clicked');
    }
    
    useEffect(() => {
        document.addEventListener('click', handleClick);
        return () => document.removeEventListener('click', handleClick);
    }, [handleClick]);  // New function each render
}

Fix with useCallback:

const handleClick = useCallback(() => {
    console.log('clicked');
}, []);

useEffect(() => {
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
}, [handleClick]);

Async Effects

useEffect can't be async directly. Use async function inside:

// Wrong
useEffect(async () => {
    const data = await fetch('/api');
}, []);

// Correct
useEffect(() => {
    async function fetchData() {
        const response = await fetch('/api');
        const data = await response.json();
        setData(data);
    }
    
    fetchData();
}, []);

Skipping Effects

Conditional execution inside the effect:

useEffect(() => {
    if (!shouldRun) return;
    
    // Effect logic
}, [shouldRun]);

Or conditionally define the effect:

if (shouldRun) {
    useEffect(() => {
        // Effect logic
    });
}
// Don't do this - breaks rules of hooks

Never conditionally call hooks. Always call them at the top level.

Multiple Effects

Separate concerns into different effects:

// One effect for window resize
useEffect(() => {
    function handleResize() {
        setWidth(window.innerWidth);
    }
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
}, []);

// Separate effect for data fetching
useEffect(() => {
    fetchData();
}, [userId]);

This is clearer than one large effect.

Stale Closure Problem

Effects capture values from when they were created:

function Counter() {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        const timer = setInterval(() => {
            setCount(count + 1);  // Always uses initial count (0)
        }, 1000);
        return () => clearInterval(timer);
    }, []);
    
    return <div>{count}</div>;
}

Fix with functional updates:

setCount(c => c + 1);  // Uses current count

Or include count in dependencies (causes timer recreation).

useLayoutEffect Alternative

For effects that need to run before paint:

import { useLayoutEffect } from 'react';

useLayoutEffect(() => {
    // Runs synchronously before browser paint
    // Use for measuring DOM, preventing flicker
}, []);

Use useEffect for most cases. useLayoutEffect blocks visual updates.

Testing Components with useEffect

Effects run after render in tests too. Wait for effects:

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

test('fetches data', async () => {
    render(<Component />);
    
    await waitFor(() => {
        expect(screen.getByText('Data loaded')).toBeInTheDocument();
    });
});

Further Reading

React's useEffect documentation covers all behaviors and examples.

Dan Abramov's Complete Guide to useEffect is the definitive deep dive.

The React docs on synchronizing with effects explain when and why to use useEffect.

useEffect is central to modern React. Understanding the dependency array prevents common bugs and enables proper side effect management.

Wear the code

Product mockup

useEffect(() => {}, []); Developer T-Shirt (React Edition — Dark Mode)

£25.00

View product
Product mockup

useEffect(() => {}, []); Developer T-Shirt (React Edition — Light Mode)

£25.00

View product

0 comments

Leave a comment

Please note, comments need to be approved before they are published.