Rethinking the component model with Hooks

siddharthkp

Sid

Posted on February 11, 2019

Rethinking the component model with Hooks

This post was sent to my newsletter last week.

Thanks to Dan Abramov for reviewing it and suggesting improvements.

If you're a fan of React, you might have already heard that the release with Hooks (v16.8) is here.

I've been playing with the alpha version for a few weeks now and I really like it. The adoption hasn't been all rainbows and unicorns though.

Learning useState and useReducer was pretty straightforward and has improved how I handle state.

I wrote about useState in an earlier post. Here's the short version:

function Counter() {
  /*
    create a new state pair with useState,
    you can specify the initial value
    as an argument
  */
  const [count, setCount] = useState(0)

  /*
    create a function to increase this count
    you have access to the current count as it
    is a local variable.

    Calling setCount will trigger a re-render
    just like setState would.
  */
  function increase() {
    setCount(count + 1)
  }

  return (
    <div>
      {count}
      <button onClick={increase}>Increase</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

 

However, I really struggled with the useEffect hook.

The Effect Hook lets you perform side effects in function components.

Side effects can mean anything from updating the document title to making an API request. Anything that happens outside your React render tree is a side effect for the component.

With classes, you would typically do this in componentDidMount. With hooks, it looks like this:

import React, { useState, useEffect } from 'react'

// username is passed in props
render(<UserProfile username="siddharthkp" />)

function UserProfile(props) {
  // create a new state pair with empty object as default
  const [user, setUser] = useState({})

  // create a pair for loading state
  const [loading, setLoading] = useState(false)

  // Similar to componentDidMount
  useEffect(function() {
    // set loading to true at start
    setLoading(true)

    // fetch the user's details
    // username is passed in props
    fetch('/get-user?username=' + props.username)
      .then(response => response.json())
      .then(user => {
        setUser(user) // set user in state
        setLoading(false) // set loading to false
      })
  })

  if (loading) return <div>Fetching user... </div>
  else return <div>Hi {user.name}</div>
}
Enter fullscreen mode Exit fullscreen mode

This feels familiar. It looks like componentDidMount in a different suit.

Well, it doesn't have the same way. The above code has a bug!

Look at this preview, it's on an infinite loop of fetching user and re-rending it (and not just because it's a gif!)

fetch user

componentDidMount is called after the component has mounted. It fires just once.

On the other hand, the effect inside useEffect is applied on every render by default.

This is a subtle shift in the mental model, we need to change how we think about the component lifecycle - instead of mount and update, we need to think in terms of renders and effects

useEffect lets us pass an optional argument - an array of dependencies that informs React when should the effect be re-applied. If none of the dependencies change, the effect will not be re-applied.

useEffect(function effect() {}, [dependencies])
Enter fullscreen mode Exit fullscreen mode

Some folks find this annoying - it feels like something that was simple is now complex with no benefit.

The benefit of useEffect is that it replaces three different API methods (componentDidMount, componentDidUpdate and componentWillUnmount) and hence makes you think about all those scenarios from the start - first render, update or re-render and unmount.

In the above component, the component should fetch user details again when we want to show a different user's profile, i.e. when props.username changes.

With a class component, you would handle this with componentDidUpdate or getDerivedStateFromProps. This usually comes as an after thought and until then the component shows stale data.

With useEffect, you are forced to think about these use cases early on. We can pass props.username as the additional argument to useEffect.

useEffect(
  function() {
    setLoading(true) // set loading to true

    // fetch the user's details
    fetch('/get-user?username=' + props.username)
      .then(response => response.json())
      .then(user => {
        setUser(user) // set user in state
        setLoading(false) // set loading to false
      })
  },
  [props.username]
)
Enter fullscreen mode Exit fullscreen mode

React will now keep track of props.username and re-apply the effect when it changes.

 

Let's talk about another kind of side effect: Event listeners.

I was trying to build a utility that shows you which keyboard button is pressed. Adding a listener on window to listen to keyboard events is a side effect.

key debugger

Step 1: Add event listener in effect

function KeyDebugger(props) {
  const [key, setKey] = useState(null)

  function handleKeyDown(event) {
    setKey(event.key) // set key in state
  }

  useEffect(function() {
    // attach event listener
    window.addEventListener('keydown', handleKeyDown)
  })

  return <div>Last key hit was: {key}</div>
}
Enter fullscreen mode Exit fullscreen mode

This looks similar to the previous example.

This effect will be applied on every render and we will end up with multiple event listeners that fire on the same event. This can lead to unexpected behavior and eventually a memory leak!

Step 2: Clean up phase

useEffect gives us a way of cleaning up our listeners.

If we return a function from the effect, React will run it before re-applying the effect.

function KeyDebugger(props) {
  const [key, setKey] = useState(null)

  function handleKeyDown(event) {
    setKey(event.key)
  }

  useEffect(function() {
    window.addEventListener('keydown', handleKeyDown)

    return function cleanup() {
      // remove the event listener we had attached
      window.removeEventListener('keydown', handleKeyDown)
    }
  })

  return <div>Last key hit was: {key}</div>
}
Enter fullscreen mode Exit fullscreen mode
Note: In addition to running before re-applying an effect, the cleanup function is also called when the component unmounts.

Much better. We can make one more optimisation.

Step 3: Add dependencies for re-applying effect

Remember: If we don't pass dependencies, it will run on every render.

In this case, we only need to apply the effect once, i.e. attach event listener on window once.

Unless the listener itself changes, of course! We should add the listener handleKeyDown as the only dependency here.

function KeyDebugger(props) {
  const [key, setKey] = useState(null)

  function handleKeyDown(event) {
    setKey(event.key)
  }

  useEffect(
    function() {
      window.addEventListener('keydown', handleKeyDown)

      return function cleanup() {
        window.removeEventListener('keydown', handleKeyDown)
      }
    },
    [handleKeyDown]
  )

  return <div>Last key hit was: {key}</div>
}
Enter fullscreen mode Exit fullscreen mode

The dependencies are a powerful hint.

  • no dependencies: apply the effect on every render
  • []: only apply on first render
  • [props.username]: apply when the variable changes

 

We can even abstract this effect out into a custom hook with cleanup baked in. This makes our component worry about one less thing.

function KeyDebugger(props) {
  const [key, setKey] = useState(null)

  function handleKeyDown(event) {
    setKey(event.key)
  }

  useEventListener('keydown', handleKeyDown)

  return <div>Last key hit was: {key}</div>
}

// re-usable event listener hook with cleanup
function useEventListener(eventName, callback) {
  useEffect(function() {
    window.addEventListener(eventName, callback)

    return function cleanup() {
      window.removeEventListener(eventName, callback)
    }
  }, [])
}
Enter fullscreen mode Exit fullscreen mode
Note: useEventListener as defined above works for our example, but is not the complete implementation. If you're curious what a robust version would look like, see this repo.

 

Let's add one more feature to our KeyDebugger. After a second, the key should disappear until another key is pressed.

key debugger

That's just a setTimeout, should be easy right?

In handleKeyDown, we can unset the key after a delay of a second. And as responsible developers, we will also clear the timeout in the cleanup function.

function KeyDebugger(props) {
  const [key, setKey] = useState(null)
  let timeout

  function handleKeyDown(event) {
    setKey(event.key)

    timeout = setTimeout(function() {
      setKey(null) // reset key
    }, 1000)
  }

  useEffect(function() {
    window.addEventListener('keydown', handleKeyDown)

    return function cleanup() {
      window.removeEventListener('keydown', handleKeyDown)
      clearTimeout(timeout) // additional cleanup task
    }
  }, [])

  return <div>Last key hit was: {key}</div>
}
Enter fullscreen mode Exit fullscreen mode

This code has become a little more complex than before, thanks to the two side effects happening in the same effect - setTimeout nested within a keydown listener. This makes the changes harder to keep track of.

Because the two effects are nested, we couldn't reap the benefits of our custom hook as well. One way to simplify this code is to separate them into their own respective hooks.

Sidenote: There is a very subtle bug in the above code which is difficult to surface - Because timeout is not cleared when key changes, old callbacks will continue to be called which can lead to bugs.
function KeyDebugger(props) {
  const [key, setKey] = useState(null)

  function handleKeyDown(event) {
    setKey(event.key)
  }

  // keyboard event effect
  useEventListener('keydown', handleKeyDown)

  // timeout effect
  useEffect(
    function() {
      let timeout = setTimeout(function() {
        setKey(null)
      }, 1000)

      return function cleanup() {
        clearTimeout(timeout)
      }
    },
    [key]
  )

  return <div>Last key hit was: {key}</div>
}
Enter fullscreen mode Exit fullscreen mode

By creating two different effects, we are able to keep the logic separate (easier to track) and define different dependencies for each effect. If we want, we can extract the timeout effect into a custom hook as well - useTimeout.

Sidenote: Because this component runs cleanup on every key change, it does not have the sidenote bug from before.

I know it sounds difficult at first, but I promise it will become easy with a little practice.

Hope that was useful in your journey.

Sid

P.S. I'm working on a React Hooks course - Learn React Hooks by building a game. I really believe it is going to be amazing.

react.games preview

Visit react.games to watch a preview of the course and drop your email to get a discount when it launches (March 15).


newsletter

💖 💪 🙅 🚩
siddharthkp
Sid

Posted on February 11, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related