DEV Community

Cover image for 🧠 Understanding useEffect in React
Rishabh Joshi
Rishabh Joshi

Posted on

🧠 Understanding useEffect in React

Why useEffect Exists

React's rendering must be pure—meaning it shouldn't modify the DOM, fetch data, or perform side effects during render. Instead, useEffect lets us handle side effects after the component has been painted.


What are Side Effects?

Side effects are operations that interact with outside elements (API calls, event listeners, timers, etc.). Without useEffect, we'd struggle to perform these tasks in function components.

Core Idea

  • useEffect runs after the render is committed to the DOM.
  • It helps manage side effects in React.
  • We can control when the effect runs using dependencies.

1️⃣ Basic Syntax of useEffect

useEffect(() => {
  // Side effect logic (e.g., API call, event listener)

  return () => {
    // Cleanup function (optional)
  };
}, [dependencies]); // Dependency array
Enter fullscreen mode Exit fullscreen mode

Breakdown

✅ First argument → Function containing the effect.
✅ Second argument → Dependency array (controls when it runs).
✅ Return function → Cleanup function (runs before next effect execution or unmounting).


2️⃣ Different Ways to Use useEffect

1️⃣ No Dependency Array (Runs on Every Render)

useEffect(() => {
  console.log("Runs after every render");
});
Enter fullscreen mode Exit fullscreen mode
  • Re-runs after every render (not recommended in most cases).
  • Can cause infinite loops if it modifies state inside.

2️⃣ Empty Dependency Array (Runs Only Once)

useEffect(() => {
  console.log("Runs only on mount!");
}, []); // Empty dependency array
Enter fullscreen mode Exit fullscreen mode
  • Runs only once when the component mounts.
  • Similar to componentDidMount in class components.
  • Best for fetching data or setting up subscriptions.

3️⃣ Dependency Array (Runs on State or Prop Changes)

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

useEffect(() => {
  console.log(`Count changed: ${count}`);
}, [count]); // Runs whenever `count` changes
Enter fullscreen mode Exit fullscreen mode
  • The effect re-runs whenever any dependency in the array changes.
  • Essential for reactive behaviors based on state/props.

4️⃣ Cleanup Function (For Unmounting & Re-running Effects)

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

    fetch("https://url", { signal: controller.signal })
      .then((res) => res.json())

    return () => controller.abort(); // Cleanup: Abort fetch on unmount
  }, []);
Enter fullscreen mode Exit fullscreen mode

3️⃣ Cleanup Matters

Cleanup function runs:

  1. Before running the effect again (if dependencies change).
    • Whenever the dependencies in the dependency array change, React first cleans up the previous effect before running the new one.
  2. When the component unmounts (to prevent memory leaks).
    • If an effect sets up an event listener or subscribes to a service, it should clean up when the component unmounts, or it could cause memory leaks.

If an effect isn't cleaned up properly, React may:
❌ Leak memory (e.g., event listeners that are never removed).
❌ Cause race conditions (outdated API requests could still resolve, updating unmounted components and causing errors.).
❌ Trigger unexpected behavior when dependencies change.


4️⃣ ⚠️ Common Mistakes & Gotchas

❌ Async Functions Directly in useEffect
React expects useEffect to return either:

  • Nothing (undefined)
  • A cleanup function

But if you use async directly, it always returns a Promise → React doesn’t expect that.

// ❌ Incorrect way
useEffect(async () => {
  const data = await fetchData();
}, []);
Enter fullscreen mode Exit fullscreen mode

🚨 This will cause a warning:

"Effect callbacks are synchronous to prevent race conditions. Put the async function inside the effect."

✅ Proper Way
Wrap the async function inside useEffect:

useEffect(() => {
  const fetchData = async () => {
    const data = await fetch(...);
    setData(data);
  };

  fetchData();
}, []);
Enter fullscreen mode Exit fullscreen mode

5️⃣ Rules of useEffect (and all Hooks) 📌

  1. Always call hooks at the top level of your component.
  2. Do NOT call hooks inside loops, conditions, or nested functions.
  3. Every variable used inside useEffect must be in the dependency array (or use useRef to persist values without re-running effects).
  4. Effects should be as specific as possible (avoid unnecessary re-renders).

🔥 Final Takeaway

🚀 useEffect is one of the most powerful hooks in React.
📌 It helps manage side effects (API calls, subscriptions, DOM interactions).
⚡ Control when it runs using the dependency array.
🧹 Always clean up effects to avoid memory leaks.

💬 Have questions or tips on using useEffect? Drop them in the comments below! 👇

Top comments (5)

Collapse
 
khalil_benmeziane profile image
Khalil Benmeziane

Great article

Collapse
 
lxchurbakov profile image
Aleksandr Churbakov • Edited

Every variable used inside useEffect must be in the dependency array (or use useRef to persist values without re-running effects).

That's actually not true. useEffect (unlike useCallback) will pick up all the fresh values for you without the need to add them to dependency list. If you add everything there, you can end up having more effect calls than you intended to

Collapse
 
joshi16 profile image
Rishabh Joshi • Edited

hi, Every variable used inside useEffect should be in the dependency array to ensure that your effect runs correctly and behaves predictably.

  1. Ensuring Correct Dependency Tracking
  • useEffect runs when its dependencies change. If you use a variable inside the effect but don’t include it in the dependency array, React won’t know that it should re-run the effect when that variable updates.

  • This can lead to stale closures, where the effect captures an outdated value from a previous render, causing unexpected behavior.

Using every variable inside useEffect in the dependency array is a best practice recommended by React to ensure correct behavior and avoid stale closures.

Collapse
 
lxchurbakov profile image
Aleksandr Churbakov • Edited

You quote the exact right thing! And it does not recommend that every variable used inside useEffect should be in it's dependencies array.

Set of variables that should trigger effect is not equal to set of variables are used inside effect. Think of an effect like this:

  const MaskInput = ({ value, onChange }) => {
    const [text, setText] = React.useState(format(value));

    useEffect(() => {
      if (filter(text) !== value) {
        onChange(filter(text));
      }
    }, [text]);

   return <input value={text} onChange={setText} />;
  }
Enter fullscreen mode Exit fullscreen mode

exhaustive-deps will complain about onChange (and filter) not being a dependency, however if you put it there you'll be in danger when someone uses your component like <MaskInput onChange={() => ... } />.

Thread Thread
 
lxchurbakov profile image
Aleksandr Churbakov

Or kind of the opposite thing:

const RevealInTenSecond = ({ children }) => {
  const [visible, setVisible] = React.useState(false);
  const count = useMetrika();

  const reveal = React.useCallback(() => {
    count('revealed');
    setVisible(true);
  }, [count, setVisible]);

  useEffect(() => {
   const k = setTimeout(reveal, 10_000);
   return () => clearTimeout(k);
   // would you add reveal to deps?
  }, []);

  if (!visible) {
    return null;
  }

  return children;
};
Enter fullscreen mode Exit fullscreen mode

Imagine how hard would it be to debug this if it turns out that count updates and causes reveal to update and effect to rerun and clear timeout and restart timer. Man I have flashbacks