useEffect Hook 🔄

1. Introduction 👋

useEffect is React's primary tool for synchronizing components with external systems — APIs, timers, subscriptions, and the DOM itself. This tutorial provides a thorough exploration of how useEffect works, when to use it, and the common pitfalls that trip up even experienced developers.

Information

This tutorial assumes familiarity with Components and the useState Hook. Understanding state first will make effects much easier to reason about.

2. What is useEffect? 🤔

useEffect is a Hook that lets a component run code after rendering, typically to synchronize with something outside of React's control — like fetching data, subscribing to an event, or manually manipulating the DOM.

Code Snippet

import { useEffect, useState } from 'react';

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

  useEffect(() => {
    const interval = setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => clearInterval(interval);
  }, []);

  return <p>Elapsed: {seconds}s</p>;
}
>>"Effects let you specify side effects that are caused by rendering itself, rather than by a particular event." — React Documentation

3. Why Use useEffect? 💡

  • External Synchronization: Keep a component in sync with APIs, browser storage, or third-party libraries.
  • Lifecycle Replication: Handle mount, update, and unmount behavior in function components.
  • Cleanup Management: Safely tear down subscriptions and listeners when no longer needed.
  • Decoupling: Keeps side-effect logic separate from the pure rendering logic of a component.

4. Understanding Side Effects ⚡

A side effect is any operation that reaches outside of a component's rendering process — network requests, subscriptions, manual DOM manipulation, logging, and timers are all common examples.

Not a Side Effect (Pure Render Logic)Side Effect (Belongs in useEffect)
Computing a derived value from props/stateFetching data from an API
Formatting a string for displaySubscribing to a WebSocket
Filtering or sorting an arrayManually setting document.title

Important

If a piece of logic doesn't reach outside the component, it likely doesn't belong in useEffect — compute it directly during render instead.

5. Effect Lifecycle ⏳

Every effect follows the same lifecycle: it runs after render, optionally returns a cleanup function, and that cleanup runs before the effect re-runs or when the component unmounts.

6. Basic useEffect Usage 🎬

Code Snippet

import { useEffect } from 'react';

function PageTitle({ title }) {
  useEffect(() => {
    document.title = title;
  }, [title]);

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

Tip

useEffect takes two arguments: a function containing the effect logic, and an optional dependency array controlling when it re-runs.

7. Dependency Array 📋

The dependency array is the second argument to useEffect. It tells React when to re-run the effect — only when one of the listed values has changed since the last render.

Code Snippet

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

Important

Every value from component scope that's used inside the effect (props, state, or derived values) should generally be included in the dependency array.

8. No Dependency Array 🔁

Omitting the dependency array entirely causes the effect to run after every single render — this is rarely what you want.

Code Snippet

useEffect(() => {
  console.log("Runs after every render, no matter what changed");
}); // no dependency array

Warning

Without a dependency array, effects that update state can easily cause an infinite render loop. Use this form deliberately, and rarely.

9. Empty Dependency Array 🌱

Passing an empty array [] tells React the effect depends on nothing, so it runs only once, after the initial mount.

Code Snippet

useEffect(() => {
  console.log("Runs only once, on mount");
}, []);

Note

This is the closest equivalent to a class component's componentDidMount lifecycle method.

10. Multiple Dependencies 🔢

Code Snippet

useEffect(() => {
  console.log(`Fetching results for "${query}" on page ${page}`);
  fetchResults(query, page);
}, [query, page]); // re-runs when EITHER query or page changes

Tip

React compares each dependency using Object.is() — the effect re-runs if any listed value differs from its previous render.

11. Cleanup Function 🧹

An effect can return a function, which React calls before the effect re-runs and again when the component unmounts — this is where subscriptions, timers, and listeners get torn down.

Code Snippet

useEffect(() => {
  const interval = setInterval(() => console.log("tick"), 1000);

  return () => {
    clearInterval(interval); // cleanup
  };
}, []);

Important

Forgetting a cleanup function for subscriptions, timers, or event listeners is one of the most common sources of memory leaks in React applications.

12. Mounting Effects 🌱

Code Snippet

function AnalyticsTracker({ pageName }) {
  useEffect(() => {
    trackPageView(pageName);
  }, []); // runs once, when the component first mounts

  return null;
}

Caution

If pageName could change while the component stays mounted, omitting it from the dependency array creates a stale closure — the effect keeps referencing the original value.

13. Updating Effects 🔄

Code Snippet

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchSearchResults(query).then(setResults);
  }, [query]); // re-runs every time 'query' changes

  return <ResultsList results={results} />;
}

14. Unmounting Effects 🍂

Code Snippet

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = connectToRoom(roomId);

    return () => {
      connection.disconnect(); // runs when the component unmounts
    };
  }, [roomId]);
}

Note

The cleanup function also runs before every re-execution of the effect, not just on unmount — this ensures the previous connection is closed before a new one opens.

15. Fetching Data 🌐

Code Snippet

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

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

    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) setUser(data);
      });

    return () => {
      ignore = true; // prevents setting state from a stale request
    };
  }, [userId]);

  if (!user) return <p>Loading...</p>;
  return <h1>{user.name}</h1>;
}

Best Practice

Use an ignore flag (or AbortController) to prevent race conditions where an outdated request resolves after a newer one and overwrites fresher data.

16. Event Listeners 🖱️

Code Snippet

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

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

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

17. Timers and Intervals ⏱️

Code Snippet

function Countdown({ seconds }) {
  const [remaining, setRemaining] = useState(seconds);

  useEffect(() => {
    if (remaining <= 0) return;

    const timeout = setTimeout(() => setRemaining((r) => r - 1), 1000);
    return () => clearTimeout(timeout);
  }, [remaining]);

  return <p>{remaining}s remaining</p>;
}

Tip

Cleaning up setTimeout calls is just as important as setInterval — an uncleared timeout can still fire after the component unmounts.

18. Subscriptions 📡

Code Snippet

function OnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

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

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

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

  return <p>{isOnline ? "🟢 Online" : "🔴 Offline"}</p>;
}

19. Synchronizing with External Systems 🔗

The React team frames useEffect's core purpose as synchronizing a component with a system outside of React — not as a general-purpose "run this code" hook.

  • Third-party UI libraries (maps, charts, editors) that manage their own DOM.
  • Browser APIs like localStorage, geolocation, or media devices.
  • Network connections such as WebSockets or Server-Sent Events.

Reference

The official React documentation's guide on synchronizing with Effects covers this mental model in more depth.

20. Preventing Infinite Loops ♾️

An infinite loop occurs when an effect updates a piece of state that's also listed in its own dependency array (or omitted incorrectly), causing it to re-run indefinitely.

❌ Infinite Loop

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

  useEffect(() => {
    setCount(count + 1); // triggers a re-render, which re-runs the effect again
  }, [count]); // 🔥 infinite loop
}

✅ Fixed with a Functional Update and Correct Trigger

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

  useEffect(() => {
    const interval = setInterval(() => setCount((c) => c + 1), 1000);
    return () => clearInterval(interval);
  }, []); // runs once, updates safely on a timer
}

Danger

An effect that unconditionally updates a state value listed in its own dependencies will crash the browser tab with continuous re-renders.

21. Dependency Management 📐

  • Include every value from component scope that the effect actually reads.
  • Use the eslint-plugin-react-hooks exhaustive-deps rule to catch missing dependencies automatically.
  • If a function is recreated every render and used in an effect, wrap it in useCallback or move it inside the effect.

Code Snippet

function Search({ query, onResults }) {
  useEffect(() => {
    fetchResults(query).then(onResults);
  }, [query, onResults]); // 'onResults' must be memoized in the parent to avoid re-running unnecessarily
}

22. Stale Closures 🕰️

A stale closure happens when an effect captures an old value from a previous render and continues using it, usually because that value was missing from the dependency array.

❌ Stale Closure

function Chat({ message }) {
  useEffect(() => {
    const interval = setInterval(() => {
      console.log(message); // always logs the INITIAL message
    }, 5000);
    return () => clearInterval(interval);
  }, []); // ❌ missing 'message' dependency
}

✅ Fixed

function Chat({ message }) {
  useEffect(() => {
    const interval = setInterval(() => {
      console.log(message); // always logs the LATEST message
    }, 5000);
    return () => clearInterval(interval);
  }, [message]); // ✅ effect re-subscribes with the current message
}

23. Splitting Effects ✂️

When a component has multiple unrelated side effects, split them into separate useEffect calls rather than combining everything into one large effect.

Code Snippet

function Profile({ userId, roomId }) {
  useEffect(() => {
    document.title = `User ${userId}`;
  }, [userId]);

  useEffect(() => {
    const connection = connectToRoom(roomId);
    return () => connection.disconnect();
  }, [roomId]);
}

Best Practice

Each effect should represent one independent synchronization concern — this keeps dependency arrays simpler and cleanup logic easier to reason about.

24. Custom Hooks with useEffect 🛠️

Wrapping useEffect-based logic in a custom Hook makes common synchronization patterns reusable across many components.

useWindowSize.js

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;
}

// Usage
function App() {
  const { width, height } = useWindowSize();
  return <p>{width} x {height}</p>;
}

25. Performance Considerations ⚡

  • Avoid unnecessary effects — many operations can be handled directly during render or in event handlers instead.
  • Keep dependency arrays as minimal and accurate as possible to avoid unnecessary re-runs.
  • Use useMemo/useCallback to stabilize object/function dependencies passed into effects.
  • Prefer useLayoutEffect only when you must measure or mutate the DOM before the browser paints — it blocks visual updates.

Caution

Not every "run this after render" scenario needs useEffect — logic triggered directly by a user action usually belongs in an event handler instead.

26. TypeScript with useEffect 🔷

Code Snippet

function useDocumentTitle(title: string): void {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

function useInterval(callback: () => void, delay: number | null): void {
  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(callback, delay);
    return () => clearInterval(id);
  }, [callback, delay]);
}

Tip

useEffect's callback must return either undefined or a cleanup function — TypeScript will flag accidental returns of other types (like a Promise).

27. Best Practices 🌟

  1. Use useEffect only for genuine side effects, not for computing derived values.
  2. Always include every value the effect reads in its dependency array.
  3. Return a cleanup function for any subscription, timer, or listener.
  4. Split unrelated concerns into separate effects.
  5. Extract reusable effect logic into custom Hooks.
  6. Enable eslint-plugin-react-hooks to catch dependency mistakes automatically.

28. Common Mistakes 🚫

  • Omitting values from the dependency array, causing stale closures.
  • Forgetting a cleanup function, leading to memory leaks and duplicate subscriptions.
  • Using useEffect for logic that could be computed directly during render.
  • Updating state unconditionally inside an effect that depends on that same state, causing an infinite loop.
  • Not guarding against race conditions in data-fetching effects.

Danger

Fetching data in useEffect without an ignore flag or AbortController can cause a slower, outdated request to overwrite the results of a newer one if responses arrive out of order.

29. Frequently Asked Questions ❓

Question

Why does my effect run twice in development?

Answer

StrictMode intentionally double-invokes effects (mount → cleanup → mount) in development to help surface missing cleanup logic. This does not happen in production.

Question

Should I fetch data with useEffect or a library like TanStack Query?

Answer

For production applications, a dedicated data-fetching library is generally recommended — it handles caching, race conditions, and retries that manual useEffect fetching requires reimplementing.

Question

Can I use async directly on the effect function?

Answer

No — the effect function must return undefined or a cleanup function, not a Promise. Define an async function inside the effect and call it instead.

30. Summary 📝

useEffect lets components synchronize with the outside world — fetching data, managing subscriptions, and interacting with browser APIs. Understanding its dependency array, cleanup function, and common pitfalls like stale closures and infinite loops is essential for writing robust, leak-free React applications.

Summary

With useEffect covered thoroughly, strong next steps include exploring useReducer for complex state logic, and Custom Hooks for packaging reusable side-effect logic into shareable utilities.