Understanding useEffect hook

Luis KernLuis Kern
7 min read

Starting here…

I’ve always wanted to write, but I never took the first step because I thought that to write an article, I needed some genius, out-of-the-box idea to really bring value to the dev community.

Until one day, a friend told me: "Dude, just start. Use your own experiences, your struggles, and the most common questions people ask you. What matters is starting — any knowledge can be valuable to someone."

And so, here we are, my friends.

For this first article, I decided to talk about one of the most common questions I’ve gotten from juniors I’ve helped and mentored: How does our beloved (or not so beloved) useEffect hook actually work?

First things first...

Before we dive into the importance of useEffect, we need to understand one of React’s core principles: components should be pure.

Okay, buddy... but what does that even mean?

A pure component is one that, when given the same props and state, will always return the same JSX. It also doesn’t cause side effects during rendering. In the case of function components, that means no side effects inside the body of the component function.

Alright... but what the heck are side effects?

Side effects are basically operations that affect things outside the component’s scope. In other words, they’re interactions with external systems like APIs, local storage, or even the DOM.

Think about it: imagine a component that displays a counter, with two buttons to increase or decrease the count. Now, let’s say that every time the counter updates, we want to update the page title using document.title.

You agree that document.title is external to the component, right? It exists independently of React and isn’t part of the component’s internal scope.

If you said “yes,” congratulations, young padawan!

The document object exists long before the component is mounted, so any interaction with it from inside the component is considered a side effect, since we’re modifying something outside React’s control. Take a look at the following WRONG approach:

import { useState } from 'react';

function Counter() {
  const [counter, setCounter] = useState(0);

  // WRONG: Side effect directly in component's body
  document.title = `Count: ${counter}`;

  const handleIncreaseCount = () => setCounter(prev => prev + 1);  

  const handleDecreaseCount = () => setCounter(prev => prev - 1);

  return (
    <div>
      <p>Count: {counter}</p>
      <button onClick={handleIncreaseCount}>+</button>
      <button onClick={handleDecreaseCount}>-</button>
    </div>
  );
}

And this is where our dear useEffect comes into play!

import { useState } from 'react';

function Counter() {
  const [counter, setCounter] = useState(0);

  // BEAUTIFUL: Handling side effect inside useEffect hook
  useEffect(() => {
    document.title = `Count: ${counter}`;
  }, [counter]);

  const handleIncreaseCount = () => setCounter(prev => prev + 1);  

  const handleDecreaseCount = () => setCounter(prev => prev - 1);

  return (
    <div>
      <p>Count: {counter}</p>
      <button onClick={handleIncreaseCount}>+</button>
      <button onClick={handleDecreaseCount}>-</button>
    </div>
  );
}

(Hopefully now the word "Effect" makes a bit more sense — or at least i haven’t confused you even more)

Going a bit deeper (no pun intended 😅)

Alright, now that we supposedly understand why useEffect exists (or at least pretend we do), it's time to explore the different use cases of our big buddy and the various scenarios where it's applied within the component lifecycle — mounting, updating, and unmounting.

Before hooks, React used to manage side effects with specific lifecycle methods:

  • componentDidMount(): called once, right after the component is initially mounted.
  • componentDidUpdate(prevProps, prevState): called after props or state updates.
  • componentWillUnmount(): called right before the component is unmounted.

Going back to our example of the counter being shown in the document title — if we were to write it using class-based components, the code would look something like this:

import React, { Component } from 'react';

class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = {
      counter: 0
    };
  }

  // Executed on first mount
  componentDidMount() {
    // Update document title on first mount
    document.title = `Count: ${this.state.counter}`;
  }

  // Executed on component updates
  componentDidUpdate(prevProps, prevState) {
    // Update document title when our counter changes
    if (prevState.counter !== this.state.counter) {
      document.title = `Count: ${this.state.counter}`;
    }
  }

  handleIncreaseCount = () => {
    this.setState((prevState) => ({
      counter: prevState.counter + 1
    }));
  };

  handleDecreaseCount = () => {
    this.setState((prevState) => ({
      counter: prevState.counter - 1
    }));
  };

  render() {
    return (
      <div>
        <p>Count: {this.state.counter}</p>
        <button onClick={this.handleIncreaseCount}>+</button>
        <button onClick={this.handleDecreaseCount}>-</button>
      </div>
    );
  }
}

export default Counter;

To be honest, I kinda miss them (go ahead, hate me :'). But I have to admit they often led to tightly coupled logic with unrelated concerns all mashed together — which could become a mess.

Luckily, nowadays we solve all of that cleanly with our beloved one. And the code looks just like what we saw earlier:

import { useState } from 'react';

function Counter() {
  const [counter, setCounter] = useState(0);

  // The function inside useEffect will execute once after the component mounts,
  // working like componentDidMount in class components, and then every time
  // a dependency value changes
  useEffect(() => {
    document.title = `Count: ${counter}`;
  }, [counter]); // -> The second parameter is the dependency array
  // Since the dependency array contains "counter", the function inside useEffect
  // will run every time the "counter" value changes,
  // similar to how componentDidUpdate works in class components

  const handleIncreaseCount = () => setCounter(prev => prev + 1);  

  const handleDecreaseCount = () => setCounter(prev => prev - 1);

  return (
    <div>
      <p>Count: {counter}</p>
      <button onClick={handleIncreaseCount}>+</button>
      <button onClick={handleDecreaseCount}>-</button>
    </div>
  );
}

Much simpler, right?

Notice that in the previous example, the effect runs once after the component mounts, and then runs again whenever the user interacts and updates the counter, since it's listed as a dependency in the useEffect.

This means that our code will run once right after the component is mounted, behaving similarly to componentDidMount. Then, it will keep “listening” for changes to the counter value, which means every time the counter state is updated, the useEffect will run again — behavior equivalent to componentDidUpdate for that specific state.

And what if we only wanted the effect to run once, like componentDidMount?

Let’s say you only want to update the document title the first time the component loads.

In that case, you pass an empty array as the second argument to useEffect. That tells React: "run this effect only once, after the first render." Just remember — even if it’s empty, the array has to be there.

The code would look like this:

  useEffect(() => {
    document.title = "Counting things";
  }, []); // Empty dependency array. The function inside useEffect will only be executed once.

Easy as that.

Forgetting about componentWillUnmount?

Now imagine another scenario, where you need to set up a listener for the browser window’s width (like for responsiveness) and you need to clean up the listener before the component unmounts to avoid unexpected behavior or memory issues. This is where componentWillUnmount used to shine.

If we were writing this with class-based components, we’d use code like this:

import React from 'react';

class ResizeLogger extends React.Component {
  // Runs right after component mount
  componentDidMount() {
    this.handleResize = () => {
      console.log(window.innerWidth);
    };
    // Add event listener on component mount
    window.addEventListener('resize', this.handleResize);
  }

  // Runs right before component unmount
  componentWillUnmount() {
    // Remove event listener when component will unmount
    window.removeEventListener('resize', this.handleResize);
  }

  render() {
    return <div>Use console (Chrome: F12) to manipulate the browser window.</div>;
  }
}

export default ResizeLogger;

And… rewriting it:

function ResizeLogger() {
  // componentDidMount: called after first render
  useEffect(() => {    
      window.addEventListener('resize', handleResize);

      // componentWillUnmount: React will run this cleanup function when the component
      // unmounts — works like componentWillUnmount
      return () => {
        window.removeEventListener('resize', handleResize);
      };
  }, []); // without componentDidUpdate: our useEffect has no dependencies, which makes the function
          // run only once

  const handleResize = () => {
    console.log(window.innerWidth);
  }

  return (
    <div>Use console (Chrome: F12) to manipulate the browser window.</div>
  );  
}

export default ResizeLogger;

Easy peasy, right? 🍋 But here’s something important to remember:

If you pass dependencies into the array (the second argument of useEffect), the cleanup function will run before each re-execution of the effect — triggered by any change in those dependencies. In other words, every time the main effect function is about to re-run due to a dependency change, React will first run the cleanup from the previous one.

Extra

  • useLayoutEffect: Remember that useEffect runs after the render? Well, useLayoutEffect works similarly, but it runs before the browser paints the screen. It’s important to note that it should only be used in client-side rendered applications or components
  • useEffect with undefined instead of dependency array: it makes your component run useEffect’s inside function on every render. Just don’t do it. It’ll definitely explode your app.

Alright... first article done!

This was a little journey where I tried to share my perspective on how our beloved useEffect hook works. Throughout my career, I’ve definitely bumped into quite a few walls with this f*cking tricky hook — so I hope this small guide helps you avoid (or at least reduce) those headaches.

Thanks for reading, and God bless you!

1
Subscribe to my newsletter

Read articles from Luis Kern directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Luis Kern
Luis Kern

Frontend-focused Software Engineer with 6+ years of experience building web applications using React.js. Also experienced in backend development with .NET and Firebase.