polvara.me

Making Sense of useEffect

Mar 6, 2019

During the past few weeks, I had the chance to introduce many of my colleagues to React Hooks.

The overall feedback is that they are an improvement over class components. Code written with Hooks looks more natural to follow and to reason about.

There is, however, one common complain whenever I explain the predefined Hooks: useEffect is complicated to understand.

Indeed its API is not as straightforward as other Hooks, although once one understands how it works, it is flexible and powerful.

In this post, I do not want to explain how it works—for that you can check the official docs. Instead, I want to go through common patterns and see how to convert them from a class-based implementation to one using useEffect.

Do Something When the Component Mounts

This is possibly the most common pattern in class components. We do this all the time, for example, to fetch some data.

Here's how it looks like in a class component:

class MyComponent extends React.Component {
  state = { foo: null, bar: null };

  componentDidMount() {
    this.fetchFoo();
    this.fetchBar();
  }

  async fetchFoo() {
    const foo = await fetch("/foo").then((res) => res.json());
    this.setState({ foo });
  }

  async fetchBar() {
    const bar = await fetch("/bar").then((res) => res.json());
    this.setState({ bar });
  }

  render() {
    return (
      <div>
        {foo}, {bar}
      </div>
    );
  }
}

In this example we're simply fetching two values, foo and bar, when the component mounts. If you are unfamiliar with async/await you can read more about it here.

The same code would look like this with React Hooks:

function MyComponent() {
  const [foo, setFoo] = useState(null);
  const [bar, setBar] = useState(null);

  async function fetchFoo() {
    const foo = await fetch("/foo").then((res) => res.json());
    setFoo(foo);
  }

  async function fetchBar() {
    const bar = await fetch("/bar").then((res) => res.json());
    setBar(bar);
  }

  useEffect(() => {
    fetchFoo();
    fetchBar();
  }, []);

  return (
    <div>
      {foo}, {bar}
    </div>
  );
}

It's hopefully easy to see that there are several similarities between the two implementations.

The important thing to notice is the second parameter we're passing to useEffect: an empty array. We do this to tell useEffect to run only once.

Although the code works, it has a problem. We're grouping the code that deals with foo with the one that handles bar. This is a necessity when we're working with class components because we have to put all our logic within the componentDidMount method.

With Hooks we can separate our code by concerns. Let's refactor the previous example to see this idea in action:

function MyComponent() {
  const [foo, setFoo] = useState(null);
  async function fetchFoo() {
    const foo = await fetch("/foo").then((res) => res.json());
    setFoo(foo);
  }
  useEffect(() => {
    fetchFoo();
  }, []);

  const [bar, setBar] = useState(null);
  async function fetchBar() {
    const bar = await fetch("/bar").then((res) => res.json());
    setBar(bar);
  }
  useEffect(() => {
    fetchBar();
  }, []);

  return (
    <div>
      {foo}, {bar}
    </div>
  );
}

We've reorganized the order of our declarations so that we end up with two visual blocks. The first one about foo and the second about bar.

Note how we can call useEffect more than once.

Do Something When the Component Unmounts

Most times after you set up some sort of listener when the component mounts you have to unregister it if the component unmounts. Failing to do so might result in memory leaks and other unintended behavior.

First, let's see the class version:

class MyComponent extends React.Component {
  componentDidMount() {
    document.addEventListener("click", this.handleClick);
  }

  componentWillUnmount() {
    document.removeEventListener("click", this.handleClick);
  }

  handlClick() {
    console.log("Clicked!");
  }

  render() {
    return <div />;
  }
}

With useEffect you can do the same by returning a function:

function MyComponent() {
  function handlClick() {
    console.log("Clicked!");
  }
  useEffect(() => {
    document.addEventListener("click", handleClick);

    // highlight-start
    return () => {
      document.removeEventListener("click", handleClick);
    };
    // highlight-end
  }, []);

  return <div />;
}

Do Something When One or More Props Change

Our last example is about reacting to prop changes. With a class you would have to use componentDidUpdate:

class MyComponent extends React.Component {
  state = { user: null };

  async componentDidMount() {
    this.fetchUser();
  }

  componentDidUpdate(prevProps) {
    if (prevProps.userId !== this.props.userId) {
      // We have to fetch the user again
      this.fetchUser();
    }
  }

  async fetchUser() {
    user = await getUser(this.props.userId);
    this.setState({ user });
  }

  render() {
    return user && user.name;
  }
}

With useEffect we can pass userId in the list of parameters to watch:

function MyComponent(props) {
  const [user, setUser] = useState(null);
  async function fetchUser() {
    user = await getUser(props.userId);
    setUser(user);
  }
  useEffect(() => {
    fetchUser();
  }, [props.userId]);

  return user && user.name;
}

You can pass as many values as you want in the array. They don't necessarily have to be props, in fact they can come from anywhere.