React Hooks
is a new feature introduced in React 16.8
, allowing functional components in React
to have state and lifecycle methods. Its advantage lies in enabling us to reuse state logic and side-effect code with finer granularity without writing class components. However, it also brings additional mental burden, and one of the pitfalls is the closure trap.
From the name "React closure trap", it is evident that our problem is related to closures. Thus, closures are the issue we must explore. A closure is formed by binding a function and its lexical environment, constituting a bridge that allows inner functions to access the outer function's scope. In JavaScript
, functions create closures each time they are created. Essentially, a closure is a bridge connecting the interior and exterior of a function. Generally, not all the names used in a program code are always valid or accessible, and the code scope that delimits the availability of a name is the name's scope. When a method or member is declared, it possesses the current execution context environment. Within a specific value context, expressions are visible and can be referenced. If a variable or expression is not in the current scope, it cannot be used. Scopes can be hierarchically layered based on the code's structure, enabling child scopes to access parent scopes. Usually, this involves traversing a chain of scope to locate variables and references in parent scopes, which cannot be accessed from a child scope.
To define a closure, we first need a function to encase an anonymous function. Closures are intended to make use of local variables; using global variables would negate the purpose of using closures. The outermost defined function can establish a local scope to define local variables, which cannot be directly accessed from outside the function. From the example below, we can observe that the name
variable defined inside the function is not destroyed, and we can still access this local variable from the outside using the function. With closures, local variables can remain in memory, helping to avoid the use of global variables, as the pollution caused by global variables can lead to unpredictability in the application and every module being callable is sure to lead to a disaster.
There are numerous scenarios in practical development where closures are utilized, such as the frequently used callback function. A callback function is a typical example of a closure, as it can access variables in the parent function's scope without needing to pass the variables as parameters to the callback function, thereby reducing parameter passing and enhancing code readability. In the following example, we can see that the local
variable is a local one, and the lexical scope used by setTimeout
is the global scope. In theory, the local variable local
cannot be accessed, but using closure allows us to create a function that can access the internal local variable. Thus, the value of this variable can be printed normally. Although passing parameters directly, similar to the second setTimeout
, is feasible, if a lot of logic is encapsulated here, the parameter passing would become more complicated. Depending on the actual situation, using closure may be more appropriate.
Let's consider another example: when working with Node
, we may encounter a scenario where calling third-party service interfaces is restricted by a frequency limit, such as making a maximum of 3
requests within 1s
. In such a case, there are typically two solutions. One is to control the frequency of requests directly when initiating the request, where the restricted requests need to be queued. The other solution is not to limit the frequency of initiating requests, but to use a retry mechanism. When the request result is limited by frequency, a delay is set before making the request again. Exponential backoff algorithms can be used to control the retry time. In practice, Ethernet uses this approach in congested situations, where devices calculate the wait time based on the exponential backoff algorithm, gradually increasing the wait time to reduce the probability of another collision.
Here we need to focus on how to retry in the second scenario. When making a request, we usually carry a lot of information, such as url
, token
, body
, etc., for querying. If we need to retry, we definitely need to store these data somewhere for the next request. So, where should we store these variables? Of course, we can construct a global object in global/window
to store them, but as previously mentioned, global variable pollution can lead to unpredictability in the application. Therefore, here we prefer to use closures for storage. In the example below, we use a closure to store some information when a request is made. It ensures that this information remains as initially defined when retrying, so we don't need to pollute global variables. For easier business use, we can wrap the retry mechanism in requestWithLimit
internally, resolving the promise only after the internal request is completed.
Js
has static scope, but the this
object is an exception. The this
binding issue is similar to dynamic scope. It doesn't care about how functions and scopes are declared or where they are declared, it only cares about where they are called from. The this
binding is not determined when the function is defined, but only when the function is executed. In actuality, this
ultimately refers to the calling object. The design of this
is mainly to be able to obtain the current running environment context
inside the function, because in Js
's memory design, Function
is independent of and not directly related to Object
, so it needs to be bound to a running environment.
As mentioned earlier, lexical scope is determined at the time of definition, so lexical scope can also be called static scope. Let's take a look at the example below. Doesn't it look like how we define components using React Hooks
? After running this example, we can see that even though the function execution looks completely the same, the value obtained when printed is from the previous scope. Now we need to focus on the fn
function. When we say "determined at the time of definition," it specifically refers to when this function is declared and defined, or rather when the lexical scope is determined when the function address is generated. From this example, it seems that there's nothing wrong, it should be this way originally. So why bring up this example? In fact, the point here is that if we accidentally retain the address of the previous fn
function when writing the code, although we hope to get the index
to be 5
, the actual index
obtained is 1
, which is what we call a closure trap. We can also use this example to understand the visual model of React
when discussing React
.
Talking about this trap, it reminds me of a saying: "Fool me once, shame on you; fool me twice, shame on me." When developing, it can be said that we easily fall into traps unwittingly. So, is this trap completely caused by closures? Definitely not, it's just a language feature of Js
. Is this trap entirely caused by React
? Of course not. So next, let's take a look at why combining closures with React
results in this trap.
First, let's consider the mechanism for rendering views in React
. We can think about how React
doesn't have templates, unlike the template
part in Vue
. This means that it's quite challenging to obtain the view we want to render, let alone performing analysis. So, how can we retrieve the view and then update the DOM
structure in Hooks
? Obviously, we just need to execute this Hooks
once. No matter how many branches or conditions you define, I just need to execute it once to obtain the returned value and get the view. It's also because React
's view rendering is very flexible, thus we have to do it this way. Vue
is not as flexible, but because of the existence of templates, it can make more optimizations. In fact, this is still a matter of trade-offs. However, that's not the main point of our discussion. Since we now understand the rendering mechanism of React
, and we have given an example of a function being executed multiple times, let's give an example here of a component being executed multiple times:
First, click the count++
button three times. At this point, the content on our view is 3
. However, when we click the log >> collect
button, we find that the content printed in the console is 0 1 2 3
. This is actually the same problem as the previous example, caused by the multiple executions of the closure +
function. This is because in reality Hooks
is just a function, and React
gives it a special meaning through the built-in use
, which allows it to access Fiber
and bind data to nodes. So, since it is a function and will be re-executed when setState
is called, the address of the add
function before clicking the button and after clicking the button is different, because this function is actually redefined, only with the same name. Consequently, its generated static scope is different, potentially causing what's known as a closure trap.
Actually, most of the issues related to closure traps are due to delayed dependency updates, such as inappropriate definitions of dependencies in useEffect
and useCallback
, resulting in the function maintaining the scope defined during the previous component refresh, causing problems. For example, in the following example, the event dependency bound by our useEffect
is count
, but when we click count++
, the function that useEffect
should execute is not updated. Therefore, its internal function still retains the scope of the previous time, leading to issues.
When we click the count++
button multiple times and then click the log count 1
button, we find that the console still outputs 0
. This is because our useEffect
keeps the old function scope, and the count
within that function scope is still 0
, so the printed value is obviously 0
. Similarly, useCallback
may encounter similar issues. A simple solution to this problem is to include the count
variable in the dependency array. When count
changes, the useEffect
will be re-executed, thus updating the function scope. However, this approach may not solve all problems. Excessive dependency on side effects may lead to a very lengthy function dependency, which could make the entire project increasingly difficult to maintain. For a discussion on event binding, you can refer to the article "Hooks and Event Binding."
So, is there a good way to solve this problem? We need our old friend useRef
. useRef
is a versatile tool for addressing closure issues and can store an immutable reference value. Imagine if we are only having problems because we are reading content from the old scope. If we could have an object that allows us to maintain a reference to the same object regardless of how many times the scope is updated, then simply retrieving this value after updates would solve the problem. In React
, we can achieve this using useRef
by maintaining the reference to an object to solve the aforementioned problem.
Similarly, when we click the count++
button multiple times and then click the log count 1
button, we find that the console outputs the most up-to-date count
value rather than continuously maintaining 0
, as shown in the example above. This is achieved by maintaining the reference to the same object within Hooks
. With useRef
, we can encapsulate the custom Hooks
to achieve related implementations. For example, if necessary, we can implement a useRefState
to return both the state
and ref
together for specific usage. Furthermore, as shown in the ahooks
implementation of useMemoizedFn
below, the first ref
ensures that we always reference the same function address, meaning the returned function always points to the same function. The second ref
is used to save the currently passed function. Therefore, when a re-render
occurs, we update the function each time, ensuring that we are always calling the most up-to-date function. Through these two ref
objects, we can ensure two points: firstly, no matter how many times a re-render
occurs, we always return the same function address; secondly, no matter how many times a re-render
occurs, the function we are about to call is always the latest.