How to use useEffect in React

How to use useEffect in React

The useEffect hook is React’s way of handling side effects in function components. Think of it as the bridge between rendering and operations that don’t directly involve producing UI but are necessary—like data fetching, subscriptions, or manually changing the DOM.

At its core, useEffect runs after every render by default, which means you have to be deliberate about when you want it to fire to avoid performance hitches or bugs.

Here’s the simplified syntax:

useEffect(() => {
  // side effect code here
}, [dependencies]);

The array at the end is critical. It tells React to only rerun this effect if one of the dependencies changes. Without it, your effect runs after every render, which is often unnecessary.

For example, suppose you want to fetch user data only when the userId changes:

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    fetch(/api/user/${userId})
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  if (!user) return <div>Loading...</div>;

  return <div>{user.name}</div>;
}

Note that putting userId in the dependency array means the effect will rerun whenever userId changes, and only then. That is key to preventing stale or repetitive fetches.

But effects can be more than just API calls. They can update subscriptions or clean up resources. For instance, if you set up a subscription in useEffect, you should always return a cleanup function to unsubscribe when the component unmounts or before running the effect again.

useEffect(() => {
  const subscription = someAPI.subscribe(data => {
    // handle data
  });

  return () => {
    subscription.unsubscribe();
  };
}, []);

The empty dependency array here means this effect runs once when the component mounts and cleans up on unmount. This pattern prevents memory leaks and ensures your component behaves predictably.

One subtle point is that React runs effects after painting the screen, asynchronously. This can cause some lag between render and effect execution, which matters if your effect needs to happen immediately or blocks UI updates.

For those cases, there’s useLayoutEffect, which runs synchronously after DOM mutations but before the browser paints. Use it sparingly to avoid blocking user interactions.

Finally, beware that every value used within your effect that’s declared outside its scope should appear in the dependency array or be memoized. Otherwise, your effect risks using stale or inconsistent values, which leads to bugs.

For example, consider this common mistake:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(id);
  }, []); // dependency array is empty
}

This won’t behave as expected because the effect closes over the initial count value (0). The interval callback always refers to that 0, so count never increments beyond 1.

The fix is to add the dependency or, better, use the functional form of state update:

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);

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

Now the interval callback always gets fresh state from React, sidestepping stale closures.

In summary, the dependency array and cleanup functions are your best friends in steering useEffect towards correct, performant code. Ignore them at your peril, because subtle bugs can creep up fast when effects are mishandled.

Moving on to sharper pitfalls to watch out for and how to guard yourself against them—first you have the infinite loop trap caused by improper dependencies.

Common pitfalls and how to avoid them

One of the most frequent traps with useEffect is accidentally causing an infinite loop of renders. This usually happens when you specify a dependency that gets updated inside the effect itself without proper guards.

Consider this example where an effect updates state without checks:

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

  useEffect(() => {
    setSeconds(prev => prev + 1);
  }, [seconds]); // seconds changes every time, triggering effect repeatedly
}

Because the effect updates seconds, which is also declared as a dependency, every render triggers the effect again and again, causing a loop. React will warn you about this potential infinite update.

The correct approach is to either avoid putting the updated state in the dependency array when not necessary or restructure the logic. For timers, using a stable interval outside effects depending on changing state works better:

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

  return () => clearInterval(interval);
}, []); // no dependency on seconds

This way, the effect runs once, and updates use the functional form to always get fresh state.

Another pitfall arises with object or array dependencies. Since JavaScript objects and arrays are reference types, two identical arrays or objects will have different references unless memoized, leading React to rerun the effect unnecessarily due to perceived changes.

Example:

function SearchResults({ filters }) {
  useEffect(() => {
    fetch('/search', { method: 'POST', body: JSON.stringify(filters) })
      .then(res => res.json())
      .then(data => {/* set results */});
  }, [filters]); // if filters is recreated on every render, effect runs every time
}

If filters is constructed inline or replaced on every render, even if logically unchanged, React sees it as a new object and triggers refetch.

Use useMemo to stabilize object/array dependencies:

const stableFilters = useMemo(() => ({ query, category }), [query, category]);

useEffect(() => {
  fetch('/search', { method: 'POST', body: JSON.stringify(stableFilters) })
    .then(…);
}, [stableFilters]);

Memoization keeps dependencies referentially stable unless their contents truly change, minimizing redundant effect executions.

Another subtlety: including functions inside the dependency array can cause unexpected behavior since functions recreate on every render. Either memoize those functions with useCallback or move logic inside the effect.

For instance:

const fetchData = () => {
  // fetch logic here
};

useEffect(() => {
  fetchData();
}, [fetchData]); // effect runs every render unless fetchData is memoized

This leads to the effect running frequently even if the fetch logic hasn’t changed. Changing fetchData to:

const fetchData = useCallback(() => {
  // fetch logic here
}, [/* dependencies */]);

ensures the effect only reruns when necessary.

Finally, be wary of performing side effects that modify state or cause re-renders inside the cleanup function itself. The cleanup should undo side effects (such as removing event listeners or cancelling timers) but not trigger new renders, or you risk inconsistent component lifecycles and hooks behavior.

With these pitfalls in mind, practice careful dependency tracking, use memoization hooks, and prefer functional updates to keep your effects precise, deterministic, and performant.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *