Quick Answer

useEffect lets you run side effects — like fetching data, setting timers, or subscribing to events — after React renders your component. The dependency array controls when the effect re-runs: omit it and it runs after every render, pass an empty array and it runs once, or list values and it runs whenever those change. Return a cleanup function to cancel timers and remove subscriptions before the effect runs again or the component unmounts.

What Are Side Effects in React?

A React component's main job is simple: take some props and state, and return JSX to describe what the screen should look like. That calculation should be pure — given the same inputs, it returns the same output and touches nothing else.

But real apps need to do more than render. They fetch data from an API, start a timer, save to localStorage, listen for keyboard events, or update the browser tab title. These actions reach outside the component and affect the world around it. Anything like that is called a side effect.

The problem is timing. If you run a fetch or start a timer directly in the body of your component, it fires on every single render — and can even break the render itself. The react useEffect hook exists to solve exactly this. It gives your side effects a safe, predictable place to live: React runs them after the render is done and the screen has been updated.

Rule of thumb: if the code paints the UI, it belongs in the return. If it talks to the outside world, it belongs in useEffect.

The Basic Syntax of useEffect

You import useEffect from React and call it inside your component. It takes two arguments: a function (the "effect") and an optional dependency array.

import { useEffect, useState } from "react";

function Greeting() {
  const [name, setName] = useState("Priya");

  useEffect(() => {
    // This is the side effect: it changes the browser tab title.
    document.title = `Hello, ${name}`;
  });

  return (
    <input value={name} onChange={(e) => setName(e.target.value)} />
  );
}

Here the effect updates the page title whenever the component renders. React waits until the DOM is updated and the input is on screen, then runs the effect. Type in the input, and the tab title follows along.

That works, but notice we passed no second argument. That means the effect runs after every render — often more than you want. Controlling when it runs is the job of the dependency array.

The Dependency Array: Empty, Populated, or Omitted

The second argument to useEffect tells React which values the effect "depends on." After each render, React compares the new values in that array to the previous ones. If any of them changed, it runs the effect again. There are three cases to know.

// 1. OMITTED — runs after EVERY render
useEffect(() => {
  console.log("runs every render");
});

// 2. EMPTY ARRAY — runs ONCE, after the first render (on mount)
useEffect(() => {
  console.log("runs once");
}, []);

// 3. WITH DEPENDENCIES — runs after mount, then whenever `count` changes
useEffect(() => {
  console.log("count is now", count);
}, [count]);

Most effects fall into case 2 or 3. Use an empty array for setup that should happen a single time, like starting a timer or subscribing to an event. List dependencies when the effect uses props or state that can change — for example, re-fetching data when an id changes.

Dependency formRuns on mountRe-runs on updatesRuns every render
OmittedYesYesYes
Empty []YesNoNo
Populated [a, b]YesOnly when a or b changeNo

Important: the array must include every reactive value (props, state, or functions) that the effect actually uses. Leaving one out is the most common cause of stale-data bugs.

Cleanup: Stopping Timers and Subscriptions

Some side effects need to be undone. If you start an interval, you must stop it. If you add an event listener, you must remove it. Otherwise you leak memory and stack up duplicate listeners every time the effect re-runs.

To clean up, return a function from your effect. React calls that returned function before running the effect again, and once more when the component unmounts (leaves the screen).

import { useEffect, useState } from "react";

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

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);

    // Cleanup: stop the timer so it doesn't keep running.
    return () => clearInterval(id);
  }, []);

  return <p>Elapsed: {seconds}s</p>;
}

The same pattern applies to subscriptions like event listeners. Add the listener in the effect, and remove the exact same one in the cleanup:

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

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

Notice we pass the same handleResize reference to both addEventListener and removeEventListener. If you passed two different inline functions, the removal would fail silently.

A Real Data-Fetching Example

Fetching data is the most common reason beginners reach for useEffect. Let's build a component that loads a user's profile whenever the userId prop changes, while correctly handling loading and error states.

import { useEffect, useState } from "react";

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

  useEffect(() => {
    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`https://api.example.com/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error("Request failed");
        return res.json();
      })
      .then((data) => {
        if (!ignore) {
          setUser(data);
          setLoading(false);
        }
      })
      .catch((err) => {
        if (!ignore) {
          setError(err.message);
          setLoading(false);
        }
      });

    // Cleanup: ignore a response that arrives after userId changed.
    return () => {
      ignore = true;
    };
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Something went wrong: {error}</p>;
  return <h2>{user.name}</h2>;
}

Two details make this correct. First, userId is in the dependency array, so switching users re-fetches automatically. Second, the ignore flag prevents a race condition: if you quickly change from user 1 to user 2, the slower response for user 1 won't overwrite user 2's data, because the first effect's cleanup already set its ignore to true.

Want to go deeper on components, props, and state before wiring up effects? Our free React course walks through it step by step.

Avoiding the Infinite-Loop Trap

The classic useEffect bug is an infinite loop that freezes the browser. It happens when an effect updates state, and that state update triggers the effect again, forever.

// BAD — no dependency array, and it updates state on every run
useEffect(() => {
  setCount(count + 1); // re-render -> effect runs -> re-render -> forever
});

The fix is to give React a way to stop. Use an empty array so it runs once, and use the updater form of the setter so you don't need count as a dependency:

// GOOD — runs a single time after mount
useEffect(() => {
  setCount((c) => c + 1);
}, []);

A subtler version of the same trap uses an object or array as a dependency. Objects are compared by reference, and a new one is created on every render, so React always thinks it changed:

// BAD — a new object each render, so this runs on every render
useEffect(() => {
  loadData(options);
}, [options]); // options = { page } created inside the component

// BETTER — depend on the primitive value instead
useEffect(() => {
  loadData({ page });
}, [page]);

Whenever an effect runs more often than you expect, check your dependencies first. Depend on primitive values (strings, numbers, booleans) where you can, and be careful with objects, arrays, and functions.

Best Practices and When to Skip useEffect

useEffect is powerful, but it is also over-used. A quick checklist keeps your effects clean:

  • List every dependency the effect uses. Don't fight the linter by leaving values out — fix the code instead.
  • Always clean up timers and subscriptions. If your effect starts something ongoing, return a function that stops it.
  • Keep each effect focused. Prefer several small effects with clear purposes over one giant effect that does everything.
  • Don't make the effect callback async directly. Define an async function inside and call it, so the return value stays a cleanup function.

Just as important is knowing when not to use it. You don't need an effect to transform data for rendering — calculate it during render instead. You don't need one to respond to a user click — put that logic in the event handler. Effects are for synchronizing with systems outside React, not for reacting to every state change.

Recommendation: learn useEffect thoroughly — it teaches you how React's render cycle really works. But for serious data fetching in production apps, reach for a dedicated tool like React Query or your framework's data loader once you're comfortable. They handle caching, retries, and race conditions so you don't have to write that plumbing every time.

Frequently Asked Questions

When exactly does useEffect run?

React runs your effect after it has rendered the component and updated the DOM, not during rendering. How often it runs depends on the dependency array: with no array it runs after every render, with an empty array it runs once after the first render, and with dependencies it runs after mount and again whenever a listed value changes.

What happens if I forget the dependency array?

The effect runs after every single render. That's occasionally what you want, but it usually leads to wasted work or infinite loops if the effect also updates state. In most cases you should pass an array — empty for run-once setup, or populated with the values the effect actually reads.

Do I always need a cleanup function?

No. You only need cleanup when your effect starts something that keeps running or needs undoing — an interval, a timeout, an event listener, a WebSocket, or an in-flight request. Simple one-off effects like updating the document title don't require any cleanup.

Why is my useEffect causing an infinite loop?

Almost always because the effect updates a state value that is also one of its dependencies (or it has no dependency array at all). Each update triggers a re-render, which runs the effect, which updates state again. Fix it by using an empty dependency array, the functional updater form of your setter, or by depending on primitive values instead of freshly created objects.

Can I use async/await directly inside useEffect?

Not on the effect function itself, because an async function returns a promise and React expects the return value to be a cleanup function. Instead, declare an async function inside the effect and call it: useEffect(() => { async function load() { /* await here */ } load(); }, []);

Is useEffect the best place to fetch data?

It works and is great for learning how React handles side effects. For production apps, though, a dedicated data library such as React Query or your framework's built-in loader is usually better — they handle caching, retries, and race conditions that are tedious to get right by hand inside useEffect.