DEV Community

loading...
Cover image for Lessons learned working with React Hooks and Closures
IT Minds

Lessons learned working with React Hooks and Closures

laukondrup profile image Lau Kondrup ・5 min read

In this post, I will:

  • Give a quick introduction to hooks
  • Share some pitfalls, lessons learned working with hooks
  • Share awesome resources for diving deeper

If you want to play with the code samples then open https://codesandbox.io/s/new and paste them in as you go.

What are hooks?

A hook is a function provided by React that lets you hook into React features from your function components - Dan Abramov

React hooks make components simpler, smaller, and more re-usable without using mixins.

React Hooks were released with React 16.8, February 2019 which in tech makes them quite old at this point 😊 Hooks have made a radical shift in how components are being developed. Before, the recommendation was to use Class components and Pure functional components, components without state only props.

This meant you may have started writing a Pure component, only to find out you needed state or lifecycle methods, so you had to refactor it into a class.

Introduce hooks. Hooks allow functional components to use all of React's features. But what is even more powerful is it allows components to separate visual render logic and "business" logic.

Your first hook - useState

useState allows a functional component to... well... use state 😄

Let's see an example:

function Counter() {
  const [count, setCount] = useState(0)
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

But how can a function keep track of state?

If you're sharp, then you may ask yourself straightaway "How can a function keep track of state?". How does a simple variable in a function replace a class field?

Remember, when using classes React only has to call the render() function but with a function component it calls the entire function again, so how is state kept between renders?

Here's a class component as a refresher:

class Counter extends Component {
  constructor() {
    this.state = { count: 0 }
  }
  render() {
    return (
      <button
        onClick={this.setState({
          count: count + 1,
        })}
      >
        {count}
      </button>
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

Hooks have to be run in the same order every time, this means no hooks inside of if statements! Conceptually, you can think of hooks as being stored in an array where every hook has its own index as a key. So the value of our count variable above would be hookArray[countHookIndex].

Without help, this would be an easy mistake to make which is why React has published a couple of ESLint rules to help us.

Let's dive into where most mistakes happen, the useEffect hook.

Side effects with hooks - useEffect

What do I mean by side effects? Things such as:

  • Fetching data on mount
  • Setting up event listeners
  • Cleaning up listeners on dismount

Here's an example of setting up an event listener "on mount":

useEffect(() => {
  const handleKeyUp = e => {
    if (e.key === 'j') {
      alert('You pressed j')
    }
  }
  document.addEventListener(
    'keyup',
    handleKeyUp
  )
  return () => {
    document.removeEventListener(
      'keyup',
      handleKeyUp
    )
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

Why is unmount in quotes? Because there are no hooks matching the lifecycle methods such as componentDidMount() there's an entirely new way of thinking with useEffect.

The second parameter of useEffect is what is called a dependency array. Since I've added an empty array, the code is run once (on mount), because the dependencies never change.

If I omitted the argument, the code would run on every render and update.

The React team noticed that setting up and removing listeners is part of the same abstraction and thus the code should be co-located therefore when an effect returns a function it will be run in the cleanup phase, that is, between renders.

While confusing at first, this is extremely powerful. You can add state variables to the dependency array! Essentially allowing you to "watch" state variables.

Dependency array pitfalls

Luckily, most of the pitfalls can be caught by using the ESLint rules from earlier. But it is good to understand why, such that, when you encounter a problem the plugin did not account for, you can solve it yourself.

I should also mention there are a few other hooks that also use dependency arrays: useMemo and useCallback but I won't cover these here.

What I failed to think about for a long time was that you're passing a function to useEffect and that function has a closure over your functional component's state and other variables. I will explain closures in a bit, but let's first see an example of how this goes wrong:

function Counter() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const timer = setInterval(
      () => console.log('count', count),
      1000
    )
    return () => clearInterval(timer)
  }, []) // ESLint warns us we're missing count
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now, this is a really contrived example but the above example will log count 0 every second regardless of how many times the user presses the button. This is because arrow function passed to useEffect is created on mount and never again thus count will remain 0 because it is a closure over the first "instance" of the function. We have a stale closure.

What is a closure?

The simplest definition I've heard is a closure allows a function to keep private variables.

Let's see an example:

function createCounter() {
  let count = 0
  return () => {
    count += 1
    return count
  }
}

const counter = createCounter()
counter() // 1
counter() // 2
Enter fullscreen mode Exit fullscreen mode

Now if you want to create just one counter you can wrap it in a module, an IIFE - immediately invoked function expression:

const counter = (() => {
  let count = 0
  return () => {
    count += 1
    return count
  }
})()

counter() // 1
counter() // 2
Enter fullscreen mode Exit fullscreen mode

That's a lot of functions 🤯 Let's break it down:

  • createCounter() was a function that returned a function.
  • So we let createCounter call itself straight away, creating the closure for the count. That hides count from the outer scope.

If you were a developer during the jQuery days this will seem very familiar to you. IIFE's were the go-to way of creating modules, avoiding having everything in the global scope, since CommonJS (require and module.exports) and ECMAScript Modules, ESM (import/export) were not created yet.

Dive deeper

I hope you enjoyed this quick introduction to hooks. If you have any questions, feel free to comment below!

Discussion (0)

pic
Editor guide