The useEffect Conversations we Shouldn't be Having Anymore
Tags: react, beginners, frontend, webdev
Published: 2023-09-17 09:00:00

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](https://www.reddit.com/r/reactjs/comments/15t4ql9/why_would_anyone_use_react/) 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](#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.

```jsx
function EmptyComponent() {
  useEffect(() => {
    console.log('This line is logged second');
  });
  console.log('This line is logged first');
  return null;
}
```

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.

```jsx
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]);
```

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.

```jsx
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>
  );
}
```

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:

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

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

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

```jsx
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]);
}
```

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:

```jsx
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>
  );
}
```

When the component first mounts, the logs show:

```plaintext
render 0
effect 0
```

When the counter button is clicked:

```plaintext
render 1
cleanup 0
effect 1
```

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

```plaintext
cleanup 1
```

### 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:

```jsx
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>
  )
}
```

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.

```jsx
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>
  )
}
```

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

```jsx
const isFormValid = useMemo(() => {
  return someComplexValidator(email, password);
}, [email, password, someComplexValidator])
```

### 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.

```jsx
useEffect(() => {
  localStorage.setItem("email", email);
  localStorage.setItem("password", password);
}, [email, password])
```

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.