TechLead

useEffect Hook

Side effects, data fetching, and lifecycle management

What is useEffect?

useEffect is a React Hook that lets you perform side effects in your components. Side effects are anything that affects something outside the component: fetching data, updating the DOM, timers, subscriptions, and more.

What Are Side Effects?

  • • Fetching data from an API
  • • Setting up subscriptions (WebSocket, events)
  • • Manually changing the DOM
  • • Setting timers (setTimeout, setInterval)
  • • Logging
  • • Storing data in localStorage

Basic Syntax

import { useEffect } from 'react';

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

  return <div>Hello</div>;
}

The Dependency Array

The second argument controls when the effect runs:

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

// Runs only ONCE (on mount)
useEffect(() => {
  console.log('Runs once on mount');
}, []);  // Empty dependency array

// Runs when dependencies change
useEffect(() => {
  console.log('Count changed:', count);
}, [count]);  // Runs when count changes

// Multiple dependencies
useEffect(() => {
  console.log('User or page changed');
}, [userId, page]);  // Runs when either changes

Dependency Array Rules

Dependency Array When Effect Runs
undefined (no array) After every render
[] (empty array) Once on mount only
[value] On mount + when value changes
[a, b, c] On mount + when any dependency changes

Cleanup Function

Return a function to clean up when the component unmounts or before the effect runs again:

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

  useEffect(() => {
    // Set up the interval
    const intervalId = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);

    // Cleanup function - runs on unmount
    return () => {
      clearInterval(intervalId);
      console.log('Timer cleaned up!');
    };
  }, []);  // Empty array = only run once

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

// Event listener example
function WindowSize() {
  const [width, setWidth] = useState(window.innerWidth);

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

  return <p>Window width: {width}px</p>;
}

Data Fetching

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

  useEffect(() => {
    // Reset state when userId changes
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);  // Re-fetch when userId changes

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

Async/Await in useEffect

function DataFetcher() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // Can't make the effect function async directly
    // So create an async function inside
    const fetchData = async () => {
      try {
        const response = await fetch('/api/data');
        const json = await response.json();
        setData(json);
      } catch (error) {
        console.error('Error:', error);
      }
    };

    fetchData();
  }, []);

  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}

// With AbortController for race conditions (recommended)
function SafeFetch({ id }) {
  const [data, setData] = useState(null);

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

    const fetchData = async () => {
      try {
        const response = await fetch(`/api/items/${id}`, {
          signal: controller.signal,
        });
        const json = await response.json();
        setData(json);
      } catch (err) {
        // AbortError is expected when cleanup runs
        if (err.name !== 'AbortError') {
          console.error('Fetch error:', err);
        }
      }
    };

    fetchData();

    // Cleanup: abort the fetch on unmount or id change
    return () => controller.abort();
  }, [id]);

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

Common Use Cases

// Document title
function Page({ title }) {
  useEffect(() => {
    document.title = title;
  }, [title]);

  return <h1>{title}</h1>;
}

// Local storage sync
function Counter() {
  const [count, setCount] = useState(() => {
    return Number(localStorage.getItem('count')) || 0;
  });

  useEffect(() => {
    localStorage.setItem('count', count);
  }, [count]);

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

// Focus on mount
function SearchInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} type="text" />;
}

Multiple Effects

function Dashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);

  // Effect 1: Fetch user
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);

  // Effect 2: Fetch posts (separate concern)
  useEffect(() => {
    fetch(`/api/users/${userId}/posts`)
      .then(res => res.json())
      .then(setPosts);
  }, [userId]);

  // Effect 3: Document title (separate concern)
  useEffect(() => {
    if (user) {
      document.title = `${user.name}'s Dashboard`;
    }
  }, [user]);

  return <div>...</div>;
}

Common Mistakes

// ✗ Missing dependency
function BadExample({ userId }) {
  useEffect(() => {
    fetchUser(userId);  // userId used but not in deps!
  }, []);  // Should include userId

  // ✗ Object/array as dependency (causes infinite loop)
  const options = { page: 1 };  // New object every render!
  useEffect(() => {
    fetch('/api', options);
  }, [options]);  // Triggers every render!

  // ✓ Fix: Memoize or use primitives
  useEffect(() => {
    fetch('/api', { page: 1 });
  }, []);  // Or use useMemo for complex objects
}

💡 ESLint Plugin

Use eslint-plugin-react-hooks to catch missing dependencies automatically!

⚠️ Strict Mode Double-Firing

In development with React.StrictMode (default in Vite/Next.js), React intentionally runs every effect twice (mount → unmount → mount) to help you find missing cleanup functions. This is normal and does NOT happen in production.

If your effect runs twice and causes problems, it means your cleanup is incomplete. Fix the cleanup rather than removing StrictMode.

📡 Data Fetching Recommendation

The React team recommends using purpose-built data fetching solutions instead of raw useEffect + fetch for production apps. Libraries like TanStack Query (React Query), SWR, or framework-level data fetching (Next.js Server Components, Remix loaders) handle caching, deduplication, background refetching, and error handling automatically.

🎯 useEffect Best Practices

  • ✓ Always include all dependencies used inside the effect
  • ✓ Use cleanup functions for subscriptions, timers, event listeners
  • ✓ Keep effects focused—one effect per concern
  • ✓ Use empty [] for one-time setup on mount
  • ✓ Handle race conditions in async effects
  • ✓ Consider React Query or SWR for data fetching
  • ✓ Don't update state unconditionally in effects (infinite loop!)