Opinions & Insights

Deep dive: How do React hooks really work?

Opinions & Insights

Deep dive: How do React hooks really work?

Author's Note: This article has been turned into a talk, with more context. Also, this article does not mention the React scheduler or how state is actually stored in React.

Hooks are a fundamentally simpler way to encapsulate stateful behavior and side effects in user interfaces. They were first introduced in React and have been broadly embraced by other frameworks like Vue, Svelte, and even adapted for general functional JS. However, their functional design requires a good understanding of closures in JavaScript.

In this article, we reintroduce closures by building a tiny clone of React Hooks. This will serve two purposes – to demonstrate the effective use of closures, and to show how you can build a Hooks clone in just 29 lines of readable JS. Finally, we arrive at how Custom Hooks naturally arise.

⚠️ Note: You don't need to do any of this in order to understand Hooks. It might just help your JS fundamentals if you go through this exercise. Don’t worry, it’s not that hard!

What are Closures?

One of the many selling points of using hooks is to avoid the complexity of classes and higher order components altogether. However, with hooks, some feel we may have swapped one problem for another. Instead of worrying about bound context, we now have to worry about closures. As Mark Dalgleish memorably summarized:

A Star Wars meme about React Hooks and closures

Closures are a fundamental concept in JS. In spite of this, they are notorious for being confusing to many especially newer developers. Kyle Simpson of You Don’t Know JS fame defines closures as such:

Closure is when a function is able to remember and access its lexical scope even when that function is executing outside its lexical scope.

They’re obviously closely tied to the concept of lexical scoping, which MDN defines as "how a parser resolves variable names when functions are nested”. Let’s look at a practical example to better illustrate this:

// Example 0
function useState(initialValue) {
  var _val = initialValue // _val is a local variable created by useState
  function state() {
    // state is an inner function, a closure
    return _val // state() uses _val, declared by parent funciton
  }
  function setState(newVal) {
    // same
    _val = newVal // setting _val without exposing _val
  }
  return [state, setState] // exposing functions for external use
}
var [foo, setFoo] = useState(0) // using array destructuring
console.log(foo()) // logs 0 - the initialValue we gave
setFoo(1) // sets _val inside useState's scope
console.log(foo()) // logs 1 - new initialValue, despite exact same call

Here, we’re creating a primitive clone of React’s useState hook. In our function, there are 2 inner functions, state and setState. state returns a local variable _val defined above and setState sets the local variable to the parameter passed into it (i.e. newVal).

Our implementation of state here is a getter function, which isn’t ideal, but we’ll fix that in a bit. What’s important is that with foo and setFoo, we are able to access and manipulate (a.k.a. “close over”) the internal variable _val. They retain access to useState ‘s scope, and that reference is called closure. In the context of React and other frameworks, this looks like state, and that’s exactly what it is.

If you’d like deeper dives on closure, I recommend reading MDN, YDKJS, and DailyJS on the topic, but if you understood the code sample above, you have everything you need.

Usage in Function Components

Let’s apply our newly minted useState clone in a familiar looking setting. We’ll make a Counter component!

// Example 1
function Counter() {
  const [count, setCount] = useState(0) // same useState as above
  return {
    click: () => setCount(count() + 1),
    render: () => console.log('render:', { count: count() })
  }
}
const C = Counter()
C.render() // render: { count: 0 }
C.click()
C.render() // render: { count: 1 }

Here, instead of rendering to the DOM, we’ve opted to just console.log out our state. We’re also exposing a programmatic API for our Counter so we can run it in a script instead of attaching an event handler. With this design we are able to simulate our component rendering and reacting to user actions.

While this works, calling a getter to access state isn’t quite the API for the real React.useState hook. Let’s fix that.

Stale Closure

If we want to match the real React API, our state has to be a variable instead of a function. If we were to simply expose _val instead of wrapping it in a function, we’d encounter a bug:

// Example 0, revisited - this is BUGGY!
function useState(initialValue) {
  var _val = initialValue
  // no state() function
  function setState(newVal) {
    _val = newVal
  }
  return [_val, setState] // directly exposing _val
}
var [foo, setFoo] = useState(0)
console.log(foo) // logs 0 without needing function call
setFoo(1) // sets _val inside useState's scope
console.log(foo) // logs 0 - oops!!

This is one form of the Stale Closure problem. When we destructured foo from the output of useState, it refers to the _val as of the initial useState call… and never changes again! This is not what we want; we generally need our component state to reflect the current state, while being just a variable instead of a function call! The two goals seem diametrically opposed.

Closure in Modules

We can solve our useState conundrum by… moving our closure inside another closure! (Yo dawg I heard you like closures…)

// Example 2
const MyReact = (function() {
  let _val // hold our state in module scope
  return {
    render(Component) {
      const Comp = Component()
      Comp.render()
      return Comp
    },
    useState(initialValue) {
      _val = _val || initialValue // assign anew every run
      function setState(newVal) {
        _val = newVal
      }
      return [_val, setState]
    }
  }
})()

Here we have opted to use the Module pattern to make our tiny React clone. Like React, it keeps track of component state (in our example, it only tracks one component, with a state in _val). This design allows MyReact to “render” your function component, which allows it to assign the internal _val value every time with the correct closure:

// Example 2 continued
function Counter() {
  const [count, setCount] = MyReact.useState(0)
  return {
    click: () => setCount(count + 1),
    render: () => console.log('render:', { count })
  }
}
let App
App = MyReact.render(Counter) // render: { count: 0 }
App.click()
App = MyReact.render(Counter) // render: { count: 1 }

Now this looks a lot more like React with Hooks!

You can read more about the Module pattern and closures in YDKJS.

Replicating useEffect

So far, we’ve covered useState, which is the first basic React Hook. The next most important hook is useEffect. Unlike setState , useEffect executes asynchronously, which means more opportunity for running into closure problems.

We can extend the tiny model of React we have built up so far to include this:

// Example 3
const MyReact = (function() {
  let _val, _deps // hold our state and dependencies in scope
  return {
    render(Component) {
      const Comp = Component()
      Comp.render()
      return Comp
    },
    useEffect(callback, depArray) {
      const hasNoDeps = !depArray
      const hasChangedDeps = _deps ? !depArray.every((el, i) => el === _deps[i]) : true
      if (hasNoDeps || hasChangedDeps) {
        callback()
        _deps = depArray
      }
    },
    useState(initialValue) {
      _val = _val || initialValue
      function setState(newVal) {
        _val = newVal
      }
      return [_val, setState]
    }
  }
})()

// usage
function Counter() {
  const [count, setCount] = MyReact.useState(0)
  MyReact.useEffect(() => {
    console.log('effect', count)
  }, [count])
  return {
    click: () => setCount(count + 1),
    noop: () => setCount(count),
    render: () => console.log('render', { count })
  }
}
let App
App = MyReact.render(Counter)
// effect 0
// render {count: 0}
App.click()
App = MyReact.render(Counter)
// effect 1
// render {count: 1}
App.noop()
App = MyReact.render(Counter)
// // no effect run
// render {count: 1}
App.click()
App = MyReact.render(Counter)
// effect 2
// render {count: 2}

To track dependencies (since useEffect reruns when dependencies change), we introduce another variable to track _deps.

Not Magic, just Arrays

We have a pretty good clone of the useState and useEffect functionality, but both are badly implemented singletons (only one of each can exist or bugs happen). To do anything interesting (and to make the final stale closure example possible), we need to generalize them to take arbitrary numbers of state and effects. Fortunately, as Rudi Yardley has written, React Hooks are not magic, just arrays. So we’ll have a hooks array. We’ll also take the opportunity to collapse both _val and _deps into our hooks array since they never overlap:

// Example 4
const MyReact = (function() {
  let hooks = [],
    currentHook = 0 // array of hooks, and an iterator!
  return {
    render(Component) {
      const Comp = Component() // run effects
      Comp.render()
      currentHook = 0 // reset for next render
      return Comp
    },
    useEffect(callback, depArray) {
      const hasNoDeps = !depArray
      const deps = hooks[currentHook] // type: array | undefined
      const hasChangedDeps = deps ? !depArray.every((el, i) => el === deps[i]) : true
      if (hasNoDeps || hasChangedDeps) {
        callback()
        hooks[currentHook] = depArray
      }
      currentHook++ // done with this hook
    },
    useState(initialValue) {
      hooks[currentHook] = hooks[currentHook] || initialValue // type: any
      const setStateHookIndex = currentHook // for setState's closure!
      const setState = newState => (hooks[setStateHookIndex] = newState)
      return [hooks[currentHook++], setState]
    }
  }
})()

Note our usage of setStateHookIndex here, which doesn’t seem to do anything, but is used to prevent setState from closing over the currentHook variable! If you take that out, setState again stops working because the closed-over currentHook is stale. (Try it!)

// Example 4 continued - in usage
function Counter() {
  const [count, setCount] = MyReact.useState(0)
  const [text, setText] = MyReact.useState('foo') // 2nd state hook!
  MyReact.useEffect(() => {
    console.log('effect', count, text)
  }, [count, text])
  return {
    click: () => setCount(count + 1),
    type: txt => setText(txt),
    noop: () => setCount(count),
    render: () => console.log('render', { count, text })
  }
}
let App
App = MyReact.render(Counter)
// effect 0 foo
// render {count: 0, text: 'foo'}
App.click()
App = MyReact.render(Counter)
// effect 1 foo
// render {count: 1, text: 'foo'}
App.type('bar')
App = MyReact.render(Counter)
// effect 1 bar
// render {count: 1, text: 'bar'}
App.noop()
App = MyReact.render(Counter)
// // no effect run
// render {count: 1, text: 'bar'}
App.click()
App = MyReact.render(Counter)
// effect 2 bar
// render {count: 2, text: 'bar'}

So the basic intuition is having an array of hooks and an index that just increments as each hook is called and reset as the component is rendered.

You also get custom hooks for free:

// Example 4, revisited
function Component() {
  const [text, setText] = useSplitURL('www.netlify.com')
  return {
    type: txt => setText(txt),
    render: () => console.log({ text })
  }
}
function useSplitURL(str) {
  const [text, setText] = MyReact.useState(str)
  const masked = text.split('.')
  return [masked, setText]
}
let App
App = MyReact.render(Component)
// { text: [ 'www', 'netlify', 'com' ] }
App.type('www.reactjs.org')
App = MyReact.render(Component)
// { text: [ 'www', 'reactjs', 'org' ] }}

This truly underlies how “not magic” hooks are – Custom Hooks simply fall out of the primitives provided by the framework – whether it is React, or the tiny clone we’ve been building.

Deriving the Rules of Hooks

Note that from here you can trivially understand the first of the Rules of Hooks: Only Call Hooks at the Top Level. We have explicitly modeled React’s reliance on call order with our currentHook variable. You can read through the entirety of the rule’s explanation with our implementation in mind and fully understand everything going on.

Notice also that the second rule, “Only Call Hooks from React Functions”, isn’t a necessary result of our implementation either, but it is certainly good practice to explicitly demarcate what parts of your code rely on stateful logic. (As a nice side effect, it also makes it easier to write tooling to make sure you follow the first Rule. You can’t accidentally shoot yourself in the foot by wrapping stateful functions named like regular JavaScript functions inside of loops and conditions. Following Rule 2 helps you follow Rule 1.)

Conclusion

At this point we have probably stretched the exercise as far as it can go. You can try implementing useRef as a one-liner, or making the render function actually take JSX and mount to the DOM, or a million other important details we have omitted in this tiny 28-line React Hooks clone. But hopefully you have gained some experience using closures in context, and gained a useful mental model demystifying how React Hooks work.

I'd like to thank Dan Abramov and Divya Sasidharan for reviewing early drafts of this eassay and improving it with their valuable feedback. All remaining errors are mine..

Keep reading

Recent posts

Book cover with the title Deliver web project 10 times faster with Jamstack enterprise

Deliver web projects 10× faster

Get the whitepaper