DEV Community

Cover image for The useEffect Conversations we Shouldn't be Having Anymore
Ghaleb
Ghaleb

Posted on • Originally published at blog.ghaleb.dev

The useEffect Conversations we Shouldn't be Having Anymore

Many of the best practices and pitfalls of useEffect have been discussed in depth in several great articles. However, its relationship with the component's state, and how limited that should be, is probably discussed to a lesser degree. More precisely, the exact purpose of useEffect seems to allure beginners still.

For instance, in a post listing reasons not to use React, a Reddit user made this argument:

State is immutable in react. Meaning you’ll have to juggle your way around useEffect

The way useEffect is brought into this statement tells me that the person's pain with React is largely self-inflicted.

Therefore, at the risk of being redundant, considering how well the React docs cover useEffect, this post aims to be a comprehensive guide discussing the hook in detail—covering topics such as its fundamental purpose, how it works, and when to (and not to) use it.

TL;DR

Jump down to the summary 🙂


The Purpose of useEffect

In React, useEffect is a double-edged sword.

It is a powerful tool in the world of functional components, but it will also nick the wielder who does not understand how functional components work, and when an effect is needed—which is rarely.

Let's start with this:

useEffect is not React's answer to reactivity.

In other words, its purpose is not to monitor some state elements through the dependency array to alter other state elements.

An effect is typically something you want to do after rendering takes place, and often involves interaction with the outside world. Like an event handler, it operates outside the main render flow. However, unlike an event handler, it isn't explicitly triggered; it simply runs after a component renders (more on this later).

Effects can include a variety of tasks such as fetching data, subscribing to some event (notice: subscribing, not handling), manipulating the DOM directly, setting up timers, or cleaning up after certain actions.

You can't do any of these things in the rendering code directly for two main reasons:

  1. The rendering code is responsible only for computing the values required to render.

  2. You cannot guarantee how many times a component re-renders. Imagine adding an event listener or making an API call every time a component re-renders.

So, what is the primary purpose of useEffect?

It is the escape hatch we need to perform a side effect that should not be part of the rendering logic and is not related to an event.

That's all it is, and that's all it should be used for. Excluding some exceptional edge cases, any other form of reliance on useEffect signals bad design.


How useEffect Works

Now that we've established what useEffect is meant for, let's delve into the specifics of how it works.

When does an effect run?

First, let's take a quick look at the various phases a component goes through before being displayed:

  1. A render is triggered

  2. The component is rendered and diffed with the DOM

  3. The changes are committed to the DOM

These phases are always the same, and in that order, regardless of whether the component just mounted or was updated.

So where do effects come in?

Effects run at the end of a commit, after the screen updates.

This ensures that the effect always has access to the most up-to-date state and props. It also makes sense, because effects shouldn't typically be involved in the rendering logic.

function EmptyComponent() {
  useEffect(() => {
    console.log('This line is logged second');
  });
  console.log('This line is logged first');
  return null;
}
Enter fullscreen mode Exit fullscreen mode

With that in mind, let's update the component's phases:

  1. A render is triggered

  2. The component is rendered and diffed with the DOM

  3. The changes are committed to the DOM

  4. Effects run

The Dependency Array

If you only pass the callback argument to useEffect, then that effect will run after every component render.

However, you can - and in most cases you should - control when an effect runs by passing a second argument to the hook, known as the dependency array. With this array, the effect will run when the component first mounts, and on any subsequent re-render where the elements in the array change.

useEffect(() => {
    console.log('Runs after every render');
});

useEffect(() => {
    console.log('Runs once after the initial render');
}, []);

useEffect(() => {
    console.log('Runs after every render, provided that `count` changes');
}, [count]);
Enter fullscreen mode Exit fullscreen mode

Be careful here, though. It is easy to fall into the trap of assuming the effect runs because the dependency array changed, which is false. Without triggering a render, the effect doesn't magically run.

function Component() {
  const counterRef = useRef(0);

  // This effect will only run once, 
  // no matter how many times counterRef.current is incremented
  // because updating a ref value does not trigger a render
  useEffect(() => {
    console.log({ counter: counterRef.current });
  }, [counterRef.current]);

  return (
    <div>
      <button onClick={() => counterRef.current++}>
        Increment Counter
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With that, let's update the component's phases again:

  1. A render is triggered

  2. The component is rendered and diffed with the DOM

  3. The changes are committed to the DOM

  4. Effects run (subject to dependency array change)

The Cleanup Callback

Because useEffect can run many times, we need a mechanism to "clean up" some code that is outside the component's control.

Consider this example:

function useTimeLogger(pageName) {
  const timeRef = useRef(0);

  useEffect(() => {
    setInterval(() => {
      console.log(`Time since loading ${pageName}: ${timeRef.current++}s`);
    }, 1000);
  }, [pageName]);
}
Enter fullscreen mode Exit fullscreen mode

Let's assume we have this custom hook being called from various pages in our multi-route app. You load the app in Page A and the logger begins working as expected. What happens when you go from Page A to Page B?

If you guessed that you'll start getting mixed logs for both pages even though you left Page A, you would be correct. Even though the component of Page A unmounted, we never canceled its interval.

To cancel an effect, we use the cleanup callback

function useTimeLogger(pageName) {
  const timeRef = useRef(0);

  useEffect(() => {
    const interval = setInterval(() => {
      console.log(`Time since loading ${pageName}: ${timeRef.current++}s`);
    }, 1000);

    return () => {
      clearInterval(interval)
      timeRef.current = 0;
    }
  }, [pageName]);
}
Enter fullscreen mode Exit fullscreen mode

React will call your cleanup function after committing the rendered changes and before the effect runs next time.

So, now the component's phases become:

  1. A render is triggered

  2. The component is rendered and diffed with the DOM

  3. The changes are committed to the DOM

  4. Previously registered cleanup callbacks run (subject to dependency array change)

  5. Effects run (subject to dependency array change)

  6. Cleanup callbacks are registered

Note that the cleanup function is also subject to the dependency array. Not all the registered cleanup functions run after a render; only those of which the dependency array changed.

Also, just like all effects run when the component first mounts, all cleanup functions run when the component unmounts.

To see this in action, consider this example:

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

  useEffect(() => {
    console.log("effect", count);
    return () => {
      console.log("cleanup", count);
    };
  }, [count]);

  console.log("render", count);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

When the component first mounts, the logs show:

render 0
effect 0
Enter fullscreen mode Exit fullscreen mode

When the counter button is clicked:

render 1
cleanup 0
effect 1
Enter fullscreen mode Exit fullscreen mode

If the component is unmounted at that point, we get:

cleanup 1
Enter fullscreen mode Exit fullscreen mode

Effects and their cleanups should always be together

In some rare cases, you might be tempted to have a useEffect that only returns a cleanup function. Be careful if you do that because one of two things are very likely to have occurred:

  1. You don't have an effect

  2. You are cleaning up an effect that lives in another useEffect

In the first case, you likely don't need useEffect and you should reconsider what that cleanup callback is doing.

The second case is generally a bad idea, which in edge cases can lead to unexpected behavior—especially if the dependency arrays are different. Remember that the cleanup callback will run before the next effect. If your cleanup has different dependencies than your effect, you may get into problems.

In all cases, this is bad practice. I can't picture a scenario in which one might need to do this, except when converting class components to functional components. Sometimes when converting class components, we attempt to mimic the lifecycle hooks with useEffect so componentWillUnmount maps to a useEffect with an empty array and a cleanup callback only.

I once worked on a project that had such code, and it ended up being a bad call in every single instance, which brings us to our next point.


What useEffect is NOT meant for

1. It is NOT a lifecycle hook

Functional components are fundamentally different from class components. A functional component is designed to fully run on every render. In contrast, in class components, the instance persists until the component unmounts, and only certain methods are called more than once depending on the component's current lifecycle stage.

Accordingly, mapping class components to functional components should be done on a fundamental level as well. We should not be looking at the class's lifecycle methods and thinking about which version of useEffect to use for mapping it. Nor should we necessarily map all the class's state object directly for that matter. Instead, the component as a whole should be rethought within the bounds of the functional components paradigm.

Always remember: Converting components is rarely a one-to-one mapping operation.

2. It is NOT part of the rendering logic

This is an intriguingly common misuse of the hook. If you have read React code, or gone through some tutorials, you have probably seen something like this before:

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isFormValid, setIsFormValid] = useState(false);

  // Update `isFormValid` based on `email` and `password`
  useEffect(() => {
    const isEmailValid = email.includes('@');
    const isPassValid = password.length > 8;
    setIsFormValid(isEmailValid && isPassValid);
  }, [email, password])

  return (
    <form>
      <input 
        type="email" 
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input 
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button 
        disabled={!isFormValid}
        onClick={() => login(email, password)}
      >
        Login
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

This works, but it's bad practice for three main reasons:

1. Readability-wise, this doesn't scale

We are detaching the event-triggered effect from the event handler. We have no way to go directly from the event handler to the effect, without reading the entire code. This becomes a much bigger problem with more complex components.

This also leads to inconsistent patterns: sometimes you apply a visual change from the event handler, and sometimes the change comes from some useEffect callback.

2. Performance-wise, this is bad

Recall that an effect runs after the render is committed. Let's walk this through:

  • Our event handlers trigger a component re-render every time we type

  • The changes are diffed and committed to the DOM

  • The effect runs, and it updates the state

  • The updated state triggers another re-render

So this code commits to the DOM twice after a single trigger. You can imagine how 'twice per trigger' could start being an issue if, say, you had a large table with editable fields.

3. We are managing more state than necessary

As a general rule, the more state the component manages, the more complex it becomes. Accepting this pattern above leads to the bad habit of adding unnecessary state.

This case does not need useEffect because it is not running an effect in the first place. Once we start dedicating useEffect to its intended purpose, we start thinking in a different pattern, and we end up with code that is more predictable and more performant.

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  // isFormValid becomes a derived value, not a state
  const isEmailValid = email.includes('@');
  const isPassValid = password.length > 8;
  const isFormValid = isEmailValid && isPassValid;

  return (
    <form>
      <input 
        type="email" 
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input 
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button 
        disabled={!isFormValid}
        onClick={() => login(email, password)}
      >
        Login
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now, if the computation of a derived value is expensive, the solution wouldn't be the dependency array of usEffect. The solution is useMemo!

const isFormValid = useMemo(() => {
  return someComplexValidator(email, password);
}, [email, password, someComplexValidator])
Enter fullscreen mode Exit fullscreen mode

3. It is NOT an event listener

Assume the code above wasn't validating the form, but was instead saving the form data to localStorage.

⚠️WARNING: This example is only to illustrate a misuse of useEffect. Please don't store passwords in localStorage.

You might argue that this is an effect because it is not part of the rendering logic, and decide to use useEffect for it.

useEffect(() => {
  localStorage.setItem("email", email);
  localStorage.setItem("password", password);
}, [email, password])
Enter fullscreen mode Exit fullscreen mode

The logic above is indeed a side effect. However, this effect is triggered by the onChange events of the email and password input fields.

While relying on useEffect here does not entail adding state or rendering twice, it does detach the event-triggered-effect from the event handler. Despite resulting in a few more lines of code, it is far cleaner to keep all event-handling logic in the event handler.


Summary

The useEffect hook is a powerful tool in a React developer's toolkit, but it is not a Swiss army knife designed for a multitude of tasks. Nor is it a tool to group event-triggered effects of multiple elements in one place.

It is mainly an escape hatch for side effects that are not triggered by events. Your component will be much more predictable and maintainable if you understand its core purpose and stick to it.

As a rule of thumb, only use useEffect when two conditions apply:

  1. The logic is a genuine side effect

  2. The logic is not triggered by an event

If your useEffect callback does not involve a side effect and involves render-related logic, then you likely need a computed value. If the computation is expensive and you only want it reacting to a specific state change, use useMemo.

The phases a component goes through are:

  1. A render is triggered

  2. The component is rendered and diffed with the DOM

  3. The changes are committed to the DOM

  4. Previously registered cleanup callbacks run (subject to dependency array change)

  5. Effects run (subject to dependency array change)

  6. Cleanup callbacks are registered

Just because the hook uses a dependency array, doesn't mean it is listening for changes. An effect runs after a render is triggered, not because a dependency value changed.

Finally, do not treat useEffect as a lifecycle hook. Convert class components by rethinking them in the functional paradigm instead of just mapping their state and lifecycle methods.

Top comments (0)