Translated fromnetlifyIn the blogAn article.
Hooks are a fundamentally simpler way to encapsulate stateful behavior and side effects in the user interface. They were first introduced by React and have been widely adopted by other front-end frameworks such as Vue, Svelte, and even general-purpose JS functional programming frameworks. However, their functional design requires a good understanding of closures in JS.
In this article, we reintroduce closures by writing a small clone of React Hooks. There are two main purposes — to demonstrate effective use cases for closures and to show you how to write a set of Hooks in just 29 lines of readable JS code. Finally, we show how custom Hooks come naturally.
⚠️ Note: You don’t have to write this code. Practicing writing this code will probably help your JS base. Don’t worry, it’s not that hard!
What is a closure?
One of the many selling points of using Hooks is that they avoid the complexity of class components and higher-order components. Some people, however, using Hooks, may feel that they are falling from one hole to another. We don’t have to worry about binding contexts anymore, but we now have closures to worry about. As Mark Dalgleish memorably sums it up:
A Star Wars spoof about React Hooks and closures
Closures are a fundamental concept in JS. Nonetheless, it is notoriously difficult to understand for novices. You Don’t Know JS author Kyle Simpson has a famous definition of closures:
Closures are the ability to remember and use a function’s lexical scope when it executes outside of its lexical scope.
They are obviously closely related to the concept of lexical scope. MDN is defined as “how the parser finds where variable names are defined when functions are nested together”. Let’s take a practical example to better illustrate:
0 function useState(initialValue) {var _val = initialValue // _val is a local variable defined in the useState function State is an internal function, Function setState(newVal) {// also _val = newVal // sets the value of _val, _val} return [state, setState] // Exposes functions for external use} var [foo, SetFoo] = useState(0) // Array destruct console.log(foo()) // print 0 - we give the initial value setFoo(1) // set useState scope _val console.log(foo()) // Prints 1 - the new value, even if the same method is calledCopy the code
Here we write a simple imitation of React useState hook. In our function, we have two internal functions, state and setState. State returns a local variable defined above, _val, and setState sets the value of the passed argument to this local variable (i.E.newval).
The state we’re implementing here is a getter function, which is not ideal, but we’ll fix it later. The point is that with foo and setFoo, we can use and modify (A.K.A. “close over”) the internal variable _val. They retain references to the useState scope, called closures. In React and other front-end frameworks, this looks like state, but it really is state.
If you want to explore closures in depth, I recommend reading MDN, YDKJS, and DailyJS on this topic, but if you understand the code sample above, it should be enough.
Usage in function components
Let’s use our new useState in a way that looks a little more familiar. Let’s write a Counter component!
Function Counter() {const [count, setCount] = useState(0) () => setCount(count() + 1), render: () => console.log('render:', { count: count() }) } } const C = Counter() C.render() // render: { count: 0 } C.click() C.render() // render: { count: 1 }Copy the code
Here we choose to just console.log out our state instead of rendering to the DOM. We also exposed a set of apis for our Counter component that can be called in scripts without binding an event handler. With this design, we can simulate component rendering and reaction to user behavior.
Although the program works, the real React. UseState doesn’t call the getter to get the state. Let’s change that.
Outdated closures
If we wanted to be close to the real React API, we would have to change state from a function to a variable. If we simply expose _val instead of the function enclosing the variable _val, we run into a bug:
// Sample 0, check again - this is buggy! Function useState(initialValue) {var _val = initialValue} Return [_val, setState] _val} var [foo, SetFoo] = useState(0) console.log(foo) // Print 0 without calling setFoo(1) // set _val console.log(foo) in useState scope // print 0 - wow!!Copy the code
This is a representation of an outdated closure. When we deconstruct foo from the return value of useState, its value is equal to _val when useState was originally called, and it never changes! That’s not what we want; We usually need our component state as a variable, not as a function, to reflect the current state! The two goals seem to be diametrically opposed.
Closures in modules
We can solve our useState problem by moving our closure inside another closure. (Yo Dawg I hear you like closures…)
Return {render(Component) {const Comp = Component(); // render(Component) {const Comp = Component(); Comp.render() return Comp }, UseState (the initialValue) {_val = _val | | the initialValue / / each run to assignment function setState (newVal) {_val = newVal} return [_val, setState] } } })()Copy the code
Here we chose to use module mode to refactor our React clone. Like React, it tracks component state (in our case, it tracks only one component with _val, which holds the state). This design mode enables MyReact to “render” your function component, and with the correct closure it assigns an internal _val each time it is run:
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 }Copy the code
Now this looks like React with Hooks!
You can read more about module patterns and closures in YDKJS.
copyuseEffect
So far, we’ve covered the basics of the React HookuseState. Another very important hook is the useEffect. Unlike setState, useEffect is executed asynchronously, which means closure problems are more likely to occur.
We can extend MyReact already written like this:
Const MyReact = (function() {let _val, 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, Function Counter() {const [count, const [count, const] 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}Copy the code
To track dependency changes (because useEffect is executed again when the dependency changes), we introduce another variable, _deps.
No magic, just arrays
We have a pretty good clone of useState and useEffect, but both are poorly implemented singletons (only one of each can exist, otherwise there will be bugs). To make things interesting (and to illustrate the last example of obsolete closures), we need to make them available to any number of states and side effects. Fortunately, as Rudi Yardley writes, React Hooks are not magic, they are arrays. So we define an hooks array. We also used this opportunity to put _val and _deps into the hooks array:
Const MyReact = (function() {let hooks = [], currentHook = 0. Return {render(Component) {const Comp = Component() // comp.render () currentHook = 0 // Reset the hooks array subscripts for the 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++ / / the end of this hook run}, UseState (the initialValue) {hooks [currentHook] = hooks [currentHook] | | the initialValue / / type: Any const setStateHookIndex = currentHook // Closure for setState! const setState = newState => (hooks[setStateHookIndex] = newState) return [hooks[currentHook++], setState] } } })()Copy the code
Notice the use of setStateHookIndex here. It looks like it does nothing, but it’s used to keep setState from becoming a closure of currentHook! If you remove it, setState will not work because the currentHook value closed by it is obsolete. (Try it!)
Function Counter() {const [count, setCount] = myreact.usestate (0) const [text, function Counter() {const [count, setCount] = myreact.usestate (0) const [text, SetText] = myreact.usestate ('foo') // second 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'}Copy the code
So as a basic intuition, we should declare a hooks array and an element index. The element index is incremented each time a hook is called and reset each time a component is rendered.
You also get custom hooks for free:
Function Component() {const [text, setText] = useSplitURL('www.netlify.com') return {type: 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' ] }}Copy the code
This is really the rationale behind the “not magic” hooks — custom hooks simply evolve from the basic features provided by the framework — whether it’s React or the clone we just wrote.
Derive the rules for Hooks
Note that you can understand the first rule of Hooks from here: Hooks can only be called at the top level. We’ve clearly modeled the React dependence on the sequence of Hooks calls with the currentHook variable. You can take our code implementation and read a full explanation of the Hooks rule to fully understand what is happening.
Also note the second rule, “only call Hooks from the React function”. While not required in our code implementation, following this rule allows you to clearly distinguish stateful parts of your code, which is good practice. (As a nice side effect, it also makes it easier to write tools to make sure you follow the first rule. You won’t accidentally shoot yourself in the foot by using stateful functions named like normal JavaScript functions in loops and conditional judgments. Follow rule 2 to help you follow rule 1.
conclusion
By this point we have probably expanded the exercise to the greatest extent possible. You can try implementing useRef in one line of code, or having the render function actually render elements to the DOM using JSX syntax, or fixing some other important details that we missed in these 28 React Hooks clones. Hopefully you’ve gained some experience with using closures in context, and an effective way of thinking about how React Hooks work.
I’d like to thankDan AbramovandDivya SasidharanReviewed the draft of this article and refined it with their valuable comments. Any other mistakes are on me..