Skip to main content
React Hooks

useEffect & Side Effects

Side effects are operations that reach outside a component: API calls, timers, DOM manipulation, logging, etc. The useEffect hook is how React handles these operations in functional components.

What is a Side Effect?

Pure Component (no side effects):
┌─────────────────────────────────────────────┐
│  Props + State  ──────►  JSX Output         │
│                                             │
│  Same inputs = Same output (predictable)    │
└─────────────────────────────────────────────┘

Component with Side Effects:
┌─────────────────────────────────────────────┐
│  Props + State  ──────►  JSX Output         │
│                             │               │
│                    ┌────────▼────────┐      │
│                    │  Side Effects   │      │
│                    │  • API calls    │      │
│                    │  • Timers       │      │
│                    │  • DOM updates  │      │
│                    │  • Subscriptions│      │
│                    └─────────────────┘      │
└─────────────────────────────────────────────┘

Basic useEffect Syntax

import { useEffect } from 'react';

useEffect(() => {
  // Effect code runs here
  
  return () => {
    // Optional cleanup function
  };
}, [dependencies]); // Dependency array

The Dependency Array

The dependency array controls when your effect runs:

1. No Dependency Array — Runs After Every Render

useEffect(() => {
  console.log('Runs after EVERY render');
});
This is rarely what you want! It can cause performance issues and infinite loops.

2. Empty Array — Runs Once on Mount

useEffect(() => {
  console.log('Runs only on mount (like componentDidMount)');
}, []);

3. With Dependencies — Runs When Dependencies Change

useEffect(() => {
  console.log(`Count changed to: ${count}`);
}, [count]); // Runs on mount AND when count changes

Visual Guide

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

  // ❌ Runs on EVERY render
  useEffect(() => {
    document.title = `Count: ${count}`;
  });

  // ✅ Runs once on mount
  useEffect(() => {
    fetchInitialData();
  }, []);

  // ✅ Runs when count changes
  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

  // ✅ Runs when count OR name changes
  useEffect(() => {
    console.log({ count, name });
  }, [count, name]);
}

Cleanup Functions

Some effects need cleanup: unsubscribing, clearing timers, removing listeners.
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // Effect: Start a timer
    const intervalId = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    // Cleanup: Clear the timer
    return () => {
      clearInterval(intervalId);
    };
  }, []); // Only run on mount/unmount

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

When Cleanup Runs

Component mounts:
  └── Effect runs

Dependency changes:
  ├── Previous cleanup runs
  └── Effect runs again

Component unmounts:
  └── Cleanup runs

Event Listener Example

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
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

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

Data Fetching

One of the most common use cases for useEffect.

Basic Fetch

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

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(`/api/users/${userId}`);
        
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        
        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 <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Handling Race Conditions

When fetching data based on changing props, you can have race conditions:
function SearchResults({ query }) {
  const [results, setResults] = useState([]);

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

    const search = async () => {
      const data = await fetch(`/api/search?q=${query}`);
      const json = await data.json();
      
      // Only update if this effect wasn't cancelled
      if (!isCancelled) {
        setResults(json);
      }
    };

    search();

    // Cleanup: cancel if query changes before response
    return () => {
      isCancelled = true;
    };
  }, [query]);

  return (
    <ul>
      {results.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

Using AbortController

The proper way to cancel fetch requests:
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

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

    const search = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/search?q=${query}`, {
          signal: controller.signal
        });
        const data = await response.json();
        setResults(data);
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('Search failed:', error);
        }
      } finally {
        setLoading(false);
      }
    };

    if (query) {
      search();
    }

    return () => {
      controller.abort();
    };
  }, [query]);

  return (/* ... */);
}

Common Patterns

1. Sync with External System

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
}

2. Update Document Title

function Page({ title }) {
  useEffect(() => {
    const originalTitle = document.title;
    document.title = title;
    
    return () => {
      document.title = originalTitle;
    };
  }, [title]);
}

3. Local Storage Sync

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage
function App() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
}

4. Debounced API Call

function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    const timeoutId = setTimeout(async () => {
      const data = await fetchSearch(query);
      setResults(data);
    }, 300);

    return () => clearTimeout(timeoutId);
  }, [query]);

  return (
    <div>
      <input 
        value={query} 
        onChange={(e) => setQuery(e.target.value)} 
      />
      <ul>
        {results.map(r => <li key={r.id}>{r.name}</li>)}
      </ul>
    </div>
  );
}

5. Intersection Observer (Infinite Scroll)

function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const loaderRef = useRef(null);

  useEffect(() => {
    const fetchItems = async () => {
      const data = await fetch(`/api/items?page=${page}`);
      const newItems = await data.json();
      setItems(prev => [...prev, ...newItems]);
    };
    
    fetchItems();
  }, [page]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          setPage(p => p + 1);
        }
      },
      { threshold: 1.0 }
    );

    if (loaderRef.current) {
      observer.observe(loaderRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div>
      {items.map(item => <ItemCard key={item.id} item={item} />)}
      <div ref={loaderRef}>Loading more...</div>
    </div>
  );
}

useEffect Pitfalls

1. Infinite Loops

// ❌ BAD - Object creates new reference each render
useEffect(() => {
  setData({ count: count + 1 });
}, [data]); // data always "changes"

// ❌ BAD - Missing dependency
const [count, setCount] = useState(0);
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // Stale closure!
  }, 1000);
  return () => clearInterval(id);
}, []); // count is missing

// ✅ FIXED - Use functional update
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

2. Stale Closures

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

  useEffect(() => {
    const handleClick = () => {
      // ❌ This captures the initial count value
      console.log('Count:', count);
    };
    
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, []); // ❌ count not in dependencies

  // ✅ FIXED: Add count to dependencies, or use a ref
}

3. Async Functions

// ❌ BAD - useEffect callback can't be async
useEffect(async () => {
  const data = await fetch('/api/data');
}, []);

// ✅ GOOD - Define async function inside
useEffect(() => {
  const fetchData = async () => {
    const data = await fetch('/api/data');
  };
  fetchData();
}, []);

// ✅ ALSO GOOD - IIFE
useEffect(() => {
  (async () => {
    const data = await fetch('/api/data');
  })();
}, []);

When NOT to Use useEffect

Some things don’t need useEffect:

Transforming Data

// ❌ Don't use effect for derived data
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);

useEffect(() => {
  setFilteredItems(items.filter(i => i.active));
}, [items]);

// ✅ Just calculate it during render
const [items, setItems] = useState([]);
const filteredItems = items.filter(i => i.active);

Handling Events

// ❌ Don't use effect to respond to user actions
const [submitted, setSubmitted] = useState(false);

useEffect(() => {
  if (submitted) {
    sendToServer(formData);
  }
}, [submitted]);

// ✅ Just handle in the event handler
const handleSubmit = () => {
  sendToServer(formData);
};

🎯 Practice Exercises

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(url, { signal: controller.signal });
        
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        
        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

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

  return { data, loading, error };
}

// Usage
function Users() {
  const { data, loading, error } = useFetch('/api/users');

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  
  return (
    <ul>
      {data.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}
function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const intervalId = setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => clearInterval(intervalId);
  }, []);

  return (
    <div className="clock">
      <div className="time">
        {time.toLocaleTimeString()}
      </div>
      <div className="date">
        {time.toLocaleDateString(undefined, {
          weekday: 'long',
          year: 'numeric',
          month: 'long',
          day: 'numeric'
        })}
      </div>
    </div>
  );
}
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

function App() {
  const isOnline = useOnlineStatus();

  return (
    <div>
      <StatusBadge online={isOnline} />
      {!isOnline && (
        <div className="offline-banner">
          You're offline. Some features may not work.
        </div>
      )}
    </div>
  );
}

Summary

ConceptDescription
Side EffectOperation outside React’s render flow
useEffectHook for managing side effects
No dependenciesRuns after every render (rarely wanted)
Empty []Runs once on mount
[dep1, dep2]Runs when dependencies change
Cleanup functionReturned function for cleanup
Race conditionsHandle with flags or AbortController
Stale closuresUse functional updates or add dependencies
Derived dataCalculate during render, not in effects

Next Steps

In the next chapter, you’ll learn about the Context API — managing global state without prop drilling!