When we started writing functional components, I was too excited about leaving this and lifecycle methods and prototype chaining inheritance that for some time, I didn't think much about closures. Closures existed before too, but we didn't feel them directly because class methods relied on this binding instead of lexical scope. But there were times where I'd get stuck and the bug was of my very own creations.
Closures are everywhere in React. We work with them every day, yet we forget about them constantly. Let's first see what Closures are.
Whenever you have a function inside another function, you have closure.
That's it really. Look at this:
const userFn = () => {
  let user = { age: 20 };
  const updateUserAge = (newAge) => {
    user = { age: newAge };
  };
  const logUser = () => {
    const age = user.age;
    console.log(`Age: ${age}`);
  };
  return { logUser, updateUserAge };
};
const { logUser, updateUserAge } = userFn();
logUser(); // Age: 20
updateUserAge(30);
logUser(); // Age: 30
Isn't that cool? Haven't you seen something like this somewhere else? like setUser ? I'm getting ahead of myself.
Right now, our user variable is forever stored inside userFn. We can not get rid of it and we can't access it directly. This is called Encapsulation and to me that's very cool. I can call that update function as many times as I like and I'll get the user's age correctly, and I can never, ever touch that user variable again. It's enclosed(encapsulated) inside a function where I can't reach it, except for calling the updateUserAge function(It's reassigning the variable to a new object, not mutating the same one — which is fine here).
This is closures in a nutshell. Now let's recreate a sneaky bug and fix it. Let's add another logger function to it that looks a bit different. Like this:
const userFn = () => {
  let user = { age: 20 };
  const updateUserAge = (newAge) => {
    user = { age: newAge };
  };
  const logUser = () => {
    const age = user.age;
    console.log(`Age: ${age}`);
  };
  const loggerFn = () => {
    const age = user.age;
    return () => console.log(`Age: ${age}`);
  };
  return { logUser, updateUserAge, loggerFn };
};
const { logUser, updateUserAge, loggerFn } = userFn();
Again, pretty straight forward, right? we created a loggerFn and it's exactly like our logUser except it returns a function. The exact use case here is irrelevant we just want to understand closures. Now let's call these functions and update our age value.
logUser(); // Age: 20
const myLoggerFn = loggerFn();
myLoggerFn(); // Age: 20
updateUserAge(30);
logUser(); // Age: 30
myLoggerFn(); // Age: 20
What just happened? The logger function is keeping the old value and no matter how many times you call it, it will stay the same old value. This is called a Stale closure.
What happens is, when we create myLoggerFn it stores a snapshot of our age, the function gives us the age right the second it was asked from our function and it holds on to it forever. If we want the updated value, we must do extra work and declare a variable again and call it so the new value inside our function is received. But there's an easier way.
Right now, we're reading a snapshot value from our object. To fix the stale closure, we need to make sure the function reads from a live reference instead of a stored value. We all know how function work, right? Functions don't hold data themselves, they close over variables in their lexical scope. Those variables might point to values or objects in memory, and when those references update, the closure can see the latest data. So let's do that. With a tiny change, our stale closure problem is over:
const loggerFn = () => {
  return () => console.log(`Age: ${user.age}`);
};
Now we're not reading from a value inside our function, we're reading from a reference and when the reference updates, our logger is pointing to its reference in the memory and updates itself. Now we see:
logUser(); // Age: 20
const myLoggerFn = loggerFn();
myLoggerFn(); // Age: 20
updateUserAge(30);
logUser(); // Age: 30
myLoggerFn(); // Age: 30
Now let's practice what we learned in React.
Closures in React
In React, we have closures all over the place. Look at this:
const MyComponent = () => {
  const handleClick = () => {
    ...
  }
}
And we're in closure territory. I use this one for demonstration purposes, the use case is not relevant:
function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      console.log("Count: ", count);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
}
This is a very simple component that logs the count on an interval. When you open your app and watch the result in the console, You'll see that no matter what happens with the count variable, the console always prints Count: 0. This is a stale closure.
The fix is, as you might have guessed (cause it's really not that sneaky of a bug), to add the correct dependency. Sometimes we forget how important linters are and we ignore them. So if we just add count to the dependency array, it will be fixed. How dependency arrays work, is React's job to explain (each render creates a fresh closure with new variables then dependency array tells React when to replace the old closure used by this effect). You can read more here. And our fixed component:
function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      console.log("Count: ", count);
    }, 1000);
    return () => clearInterval(id);
  }, [count]); // Add the correct dependency
  return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
}
We have many more examples like this one in React. Everything that has a callback, makes closures and the deeper you go, the more you have to watch out for these. Some examples are useCallback and useMemo. Every time we declare these, we must provide the correct dependencies or they'll be stale and despite the fact that our linter is probably screaming at us, we'll go look for it some other place.
Now Let's see another example which doesn't include dependency arrays:
function Clicker() {
  const [count, setCount] = useState(0);
  const handleAlert = () => {
    setTimeout(() => {
      alert(`Count is: ${count}`);
    }, 3000);
  };
  return (
    <>
      <p>{count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increase</button>
      <button onClick={handleAlert}>Alert in 3s</button>
    </>
  );
}
There's a problem here. If you click on the button with handleAlert and then click on the count and increase its value, The alert shows you the old value after about 3 seconds. I hope when you see this, you understand that here, we have a case of stale closure. The alert has the value when it's called and doesn't update and it won't be fixed by useCallback and its dependency array (because the identity of the callback inside alert doesn't change).
So what can we do? What tools do we have to store a value that doesn't cause much trouble. I'm sure there are many ways you guys can resolve this issue, but for this demo I like to use useRef. Let's see the resolved version and talk about it:
function Clicker() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  useEffect(() => {
    countRef.current = count;
  }, [count]);
  const handleAlert = () => {
    setTimeout(() => {
      alert(`Count is: ${countRef.current}`);
    }, 3000);
  };
  return (
    <>
      <p>{count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increase</button>
      <button onClick={handleAlert}>Alert in 3s</button>
    </>
  );
}
When you click on alert and increase the count, in about 3 seconds you see the increased count value. This is exactly like before. We are not using a value, we're using a reference and when we update that reference, we get the updated value we want.
That's it. I hope you now understand closures a bit more deeply. How they can create sneaky bugs, how to prevent them, how to do fun stuff with them and how much we rely on them every day in React's functional world.
 
 
              
 
    
Top comments (0)