In React
, we often need to add event handlers to components, such as handling form submissions and click events. Typically, in class components, we use the this
keyword to bind the context of event handlers so that we can access the component's instance properties and methods within the function. React Hooks
is a new feature introduced in React 16.8
, enabling functional components to have state and lifecycle methods. The advantage of Hooks
lies in the ability to reuse state logic and side effect code without writing class components. One common use case of Hooks
is event handling.
When using class components in React
, we can often be bothered by a lot of this
, such as this.props
, this.state
, and calling functions within the class. Additionally, when defining event handlers, it is typically necessary to use the bind
method to bind the context of the function to ensure that the component instance's properties and methods can be accessed correctly within the function. Although arrow functions can reduce the use of bind
, the use of this
syntax is still unavoidable.
However, when using Hooks
, there is no need to use the this
keyword in functional components. Hooks
organize component logic in the form of functions. We typically only need to define a regular function component and use useState
, useEffect
, and other Hooks
to manage the component's state and side effects. When handling event binding, we simply need to pass the defined event handling function into the JSX
, without requiring this
or bind
.
Now the question arises, is this issue really that simple? We often hear about the heavy mental burden of something like Hooks
. From the perspective of the event binding we are discussing, the mental burden mainly manifests in useEffect
, useCallback
, and dependency arrays. In comparison, class components are similar to the mental burden of introducing this
and bind
, and Hooks
solve the mental burden of class components while introducing a new mental burden. However, from another perspective, the so-called mental burden is simply new knowledge that needs to be accepted. We need to understand React
introducing new designs and new component models. Once we have mastered them, it will no longer be called a mental burden, but rather syntax. Of course, calling it a burden is not entirely unreasonable, as it is easy to inadvertently create hazards. Next, let's discuss the issues related to Hooks
and event binding, and all sample codes are available at https://codesandbox.io/s/react-ts-template-forked-z8o7sv
.
Using Hooks
for regular synthetic event binding is quite straightforward. In this example, we use the ordinary synthetic event onClick
to listen to the button click event and call the add
function to update the value of the count
state variable when the button is clicked. This way, every time the button is clicked, count
will increase by 1
.
This example looks very simple, so we won't explain it further. In fact, from another perspective, isn't this similar to the native DOM0
event flow model, where each object can only bind one DOM
event? Therefore, there is no need to maintain the original function reference for unmounting, as in the DOM2
event flow model. Otherwise, it cannot be uninstalled. If the reference address is not kept the same, it will cause an infinite binding, leading to memory leaks. With DOM0
, we simply need to override it without needing to maintain the previous function reference. In actuality, some of the mental burdens we are about to discuss are closely related to reference addresses.
Additionally, we need to clarify one point. When we click this count
button, what does React
do for us? For the current <CounterNormal />
component, when we click the button, it definitely needs to refresh the view. React's
strategy is to re-execute this function to obtain the returned JSX
, then go through processes like diff
, and finally render. The focus now is on the re-execution of this function component. Hooks
are essentially just functions. React
assigns special meaning to functions through built-in use
, allowing them to access Fiber
to bind data and nodes to each other. Therefore, it is a function, and it will be re-executed when setState
is called. Consequently, the address of the add
function before and after clicking the button is different because the function is actually redefined, although it has the same name. Thus, its generated static scope is different. This can lead to what is known as a closure trap. Next, we will continue to discuss related issues.
Although React
provides us with synthetic events, in actual development, for various reasons, we cannot avoid using native event binding. For example, with ReactDOM
portals, they follow the synthetic event flow rather than the DOM
event flow. For instance, when you directly mount a component under document.body
, the event may not follow the expected DOM
structure, which might not meet our expectations. In such cases, native event binding may be necessary. Additionally, many libraries may have event binding similar to addEventListener
, so it is essential to add and remove event bindings at the appropriate times.
Let's take a look at the example of native event binding below:
In this example, we have done native event binding for both ref1
and ref2
buttons. The event binding for ref1
occurs when the component is mounted, while the event binding for ref2
occurs when count
changes. It may seem like there's only a difference between the dependency arrays []
and [count]
in the code, but in reality, there is a significant difference in the effects. In the above CodeSandbox
example, if we first click the count++
button three times and then click the log count 1
and log count 2
buttons, the output will be as follows:
Here, we can see that even though the count
value on the page is 3
, when we click the log count 1
button, the output is 0
, and it is only when we click the log count 2
button that the output is 3
. Thus, the output from clicking the log count 1
button definitely does not meet our expectations.
So, why does this happen? Actually, this is the so-called "React Hooks closure trap." In fact, as we mentioned earlier, Hooks
are nothing more than functions. React
assigns special meaning to functions through the built-in use
, allowing them to access Fiber
and bind data and nodes to each other. Since it is a function, and it re-executes when setState
is called, the address of the add
function before and after clicking the button is different because the function is essentially redefined, albeit with the same name. As a result, the static scope created by the new function execution is different. Therefore, when the new function is executed, if we do not update the new function, i.e., do not update the function scope, it will retain the reference to the previous count
, leading to printing the data from the first binding.
So, similarly, useEffect
is also a function, and the function we defined to bind events is actually just the parameter of useEffect
. When the state
changes, this function is also redefined. However, due to the relationship with our second parameter, which is the dependency array, the values inside the array remain the same after two render
calls. Therefore, useEffect
will not trigger the execution of this side effect. In fact, in log count 1
, because the dependency array is empty []
, the values inside the array remain unchanged after two render
calls, so the side effect function will not be executed. In log count 2
, because the dependent array is [count]
, the values have changed after two render
calls, causing the previous side effect function to be executed, which in this case is the cleanup function removeEventListener
, followed by the execution of the newly passed side effect function addEventListener
.
It's also because React needs to return a cleanup function for the side effect, so the first function cannot be directly decorated with async
, otherwise, after executing the side effect, it will return a Promise
object instead of directly executable side effect cleanup function.
In the above scenario, we seem to have solved the problem by adding a dependency array to useEffect
, but consider a scenario where a function needs to be imported in multiple places, similar to our previous example of the handler
function. If we need to reference this function in multiple places, we cannot directly define it in the first parameter of useEffect
as we did in the previous example. If defined externally, this function will be redefined at each re-render
, causing the dependency array of useEffect
to change, thereby causing the side effect function to be re-executed, which is not what we expect. At this point, we need to keep the address of this function unique, and that's where the useCallback
Hook comes in. When using the useCallback Hook
in React, it returns a memoized callback function that is only recreated when its dependencies change, otherwise it is cached for reuse in subsequent renders. This approach can help optimize performance in React components because it prevents unnecessary re-renders. When passing this memoized callback function to a child component, it avoids recreating it on each render, improving performance and reducing memory usage.
Now, let's look at the example below, which uses useCallback
to bind events:
In this example, our logCount1
is not wrapped in a useCallback
, so it is redefined every time a re-render occurs. At the same time, useEffect
did not define an array, so it doesn't re-execute event binding during re-render. On the other hand, for logCount2
, we used useCallback
wrapping, so every time there's a re-render, because the dependency array is [count]
, when count
changes, the address of the function returned by useCallback
also changes. If there were many other states and they changed while count
stayed the same, logCount2
would not change. Since we only have the count
state here, when a re-render occurs, the dependency array of useEffect
changes and the event binding is re-executed.
In the CodeSandbox above, if we first click the count++
button three times and then click the log count 1
and log count 2
buttons respectively, the output will be as follows:
In fact, if we don't use the react-hooks/exhaustive-deps
rule, it will suggest using the 3.3
method to handle dependencies, which is the most standard solution. The other solutions either involve unnecessary function redefinition or the persistence of old function scope references.
Although these situations may seem complex, following the react-hooks/exhaustive-deps
rule would actually recommend the use of the 3.3
method to handle dependencies, which is the most standard solution. Other solutions either involve unnecessary function redefinition or the persistence of old function scope references. This suggests that the mental burden of React
is indeed significant, and useCallback
does not completely solve the problem.
Similarly, let's consider another example that might be relatively complex because it involves a long dependency chain, making it appear quite tricky. In this case, it's not that useCallback
is flawed, but rather it presents a significant mental burden.
In this example, our goal is to trigger the post
function only when dep
changes, in order to send the data. We've defined the functions according to the react-hooks/exhaustive-deps
rules. At first glance, everything seems fine, but in practice, if the text
state changes, it also triggers the execution of the post
function. This is a subtle issue that may not be immediately apparent, especially if the text
state changes infrequently. Moreover, the dependency chain in this case is already quite long, and if the function becomes more complex, the overall complexity will become increasingly difficult to maintain.
So, how do we solve this problem? One feasible solution is to define the function on useRef
, so we always get the latest function definition, just like directly defining a function call, without being constrained by the react-hooks/exhaustive-deps
rule. However, in practice, this doesn't reduce the complexity but simply shifts it to useRef
, adding additional mental burden to maintain the value of useRef
.
So since we can rely on useRef
to solve this problem, can we encapsulate it as a custom Hook
? And because in reality we can't prevent the creation of functions, we use two refs
, the first ref
ensures that it is always the same reference, that is, the returned function always points to the same function address. The second ref
is used to save the current incoming function, so that when a re-render
occurs, we update it every time a new function is created, which means that we will always call the latest function. In this way, with two refs
, we can ensure two things: first, no matter how many re-renders
occur, we will always return the same function address; second, no matter how many re-renders
occur, the function we are about to call is always the latest one. Therefore, let's see how ahooks
implements useMemoizedFn
.
So using it is very simple, you can see that when we use useMemoizedFn
, we do not need a dependency array. Although we defined the dependency of the post
function in useEffect
, since we ensured the first point above, the address of this dependency function will not change until the component is completely unmounted. Therefore, we can ensure that only changes in dep
will trigger useEffect
, and we ensure the second point, which allows us to get the latest function scope after re-render
, that is, textLen
and depLen
are guaranteed to be the latest and there is no problem of getting values from the old function scope.