How to create a stale closure like the one in React's useEffect hook without using the actual useEffect hook?

Viewed 1014

I know how closures work but I'm failed to understand how a stale closure gets create in a React's useEffect in the absence of an exhaustive dependencies array. To do so, I'm trying to replicate a stale closure just like in React's useEffect without using the useEffect, but I'm unable to create it. My code doesn't create a stale closure and instead logs a correct value on every interval. Could you please take a look at the snippet below and tell me:

  1. What am I doing wrong? What should I do to create a stale closure like the one we get in React's useEffect when we don't provide the complete dependencies array? (reference code is at the end of the post)

  2. Why does a stale closure is created when we don't give exhaustive dependencies in a useEffect? Why doesn't the code in a useEffect hook's callback just use the lexical scope, like a normal function would, and print the actual value?

function createIncrement(incBy) {
  let value = 0;

  function increment() {
    value += incBy;
    console.log(value);
  }
  
  function useEffect(fun) {
    fun()
  }
  
  useEffect(function() {
    setInterval(function log() {
          // what should I do to create a stale closure here?
          // So that if I change the value it should show me the old value
          // as it does when using React's useEffect without exhaustive dependencies array

          console.log(`Count is: ${value}`); // prints correct value each time
        }, 2000);
  });
  
  setTimeout(() => {
    increment(); // increments to 5
    increment(); // increments to 6
  }, 5000);
  
  return [increment];
}

const [increment] = createIncrement(1);
increment(); // increments to 1
increment(); // increments to 2
increment(); // increments to 3
increment(); // increments to 4

For completeness sake, below is a code snippet using React's useEffect where we don't provide an exhaustive dependency array to React's useEffect and hence a stale closure is created:

import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

function WatchCount() {
  const [count, setCount] = useState(0);

  useEffect(function () {
    setInterval(function log() {
      // No matter how many times you increase the counter 
      // by pressing the button below,
      // this will always log count as 0
      console.log(`Count is: ${count}`);
    }, 2000);
  }, []);

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<WatchCount />, rootElement);
1 Answers

What am I doing wrong? What should I do to create a stale closure like the one we get in React's useEffect when we don't provide the complete dependencies array?

You don't get a stale closure because you call the createIncrement function only once.

In order to re-render a functional react component, react calls the component function again. This creates a new scope which has no link to the scope created in the previous call to the function component.

To get a stale closure, you need to call createIncrement more than once.

function createIncrement(incBy, functionCallIdentifier, intervalDelay) {
  let value = 0;

  function increment() {
    value += incBy;
    console.log("inside call: " + functionCallIdentifier + " - value: " + value);
  }

  increment();
  
  function useEffect(fun) {
    fun();
  }

  useEffect(function () {
    setInterval(function log() {
      console.log("inside call: " + functionCallIdentifier + " - count: " + value);
    }, intervalDelay);
  });
}

createIncrement(1, "first call", 2000);

// calling again but 'setInterval' set in the first call will continue
// to log the latest value of variable "value" that it closed over, i.e. 1 
createIncrement(2, "second call", 4000);

Why does a stale closure is created when we don't give exhaustive dependencies in a useEffect? Why doesn't the code in a useEffect hook's callback just use the lexical scope, like a normal function would, and print the actual value?

The above code example should give you an idea of why the stale closure gets created. useEffect's callback function indeed uses the lexical scope but the difference is that calling the function component again creates a new scope but the old callback function of the useEffect hook set in the previous render of the component will continue to see the values from the scope in which it was created.

This behavior is not specific to react - this is how every function in javascript behaves: each function closes over the scope in which it is created.

Until the previous callback is cleared before setting the new callback in the useEffect hook, old callback will continue to log the values from the scope that it closed over.

Following code example uses a clean up function to clear the interval set in the first call to the createIncrement function.

function createIncrement(incBy, functionCallIdentifier, useEffectCleanupFn) {
  if (useEffectCleanupFn) {
    useEffectCleanupFn();
  }

  let value = 0;

  function increment() {
    value += incBy;
    console.log("inside call: " + functionCallIdentifier + " - value: " + value);
  }

  increment();
  
  let cleanupFn;

  function useEffect(fun) {
    cleanupFn = fun();
  }

  useEffect(function () {
    let id = setInterval(function log() {
      console.log("inside call: " + functionCallIdentifier + " - count: " + value);
    }, 2000);

    return () => clearInterval(id);
  });

  return cleanupFn;
}

let cleanupFn1 = createIncrement(1, "first call");

setTimeout(() => {
  createIncrement(2, "second call", cleanupFn1);
}, 4000);

Related