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:
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..