¡Al fin entendí qué es un closure!
Llevo varios años escribiendo js, y hace poco me puse a estudiar para una entrevista y apareció el tema de los closures. Empecé a ver ejemplos, pero no entendía qué gracia tenían, y pensé que quizá los ejemplos eran malos, porque mostraban una variable definida dentro de una función y después invocaban esa función y se mostraba la variable de nuevo, y yo me dije a mí mismo: “pero esto no tiene nada de novedoso”. Y sí es novedoso, miren este ejemplo:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// esperas: 0, 1, 2
// obtienes: 3, 3, 3
La primera vez que lo vi pensé que era un error del lenguaje, y no lo es. Con var hay una sola
variable i para toda la función, y el loop la deja en 3 y termina, y recién 100 milisegundos más
tarde corren los tres setTimeout, que van a mirar esa misma variable, la única que existe, y para
entonces ya vale 3.
Si cambian var por let sale lo que esperaban:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 0, 1, 2
Y ahí está la gracia, porque let crea una variable nueva en cada vuelta del loop y cada callback
se queda con la suya. El código es el mismo, el setTimeout es el mismo, y lo único que cambió es
cuántas variables hay dando vueltas.
Entonces qué es un closure
Es una función que se lleva puesto el lugar donde viven las variables que usa. Cuando escriben una función adentro de otra, la de adentro no copia los valores de afuera, se queda con la dirección del cajón donde están guardados, y si alguien cambia lo que hay adentro después, la función ve el cambio. Guarda dónde mirar y no qué había.
Por eso el primer ejemplo daba 3, 3, 3. Los tres callbacks tenían la dirección correcta, solo que
apuntaban todos al mismo cajón.
Dónde me mordió de verdad
En React, con un contador que debería subir de a uno por segundo:
function Contador() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}
Se queda pegado en 1 para siempre. El componente sí se vuelve a renderizar, pero el useEffect
con [] corrió una sola vez y el intervalo que quedó vivo se llevó el count de aquel primer
render, que valía 0, así que cada segundo calcula 0 + 1 y guarda 1.
Es el mismo 3, 3, 3 con otra ropa. Cada render de React crea funciones nuevas con sus propias
variables, y el intervalo quedó atrapado en las del render inicial. A eso le dicen stale closure,
y me costó bastante darme cuenta de que era lo mismo que el loop.
La solución no es agregar count a las dependencias, porque eso recrea el intervalo cada segundo,
sino dejar de leer la variable de afuera:
setCount((c) => c + 1);
Con la forma de función, React les pasa el valor actual como argumento, y si no hay ninguna variable externa involucrada tampoco hay un cajón viejo que consultar.
La regla que uso ahora
Cuando una función se va a ejecutar más tarde, sea un setTimeout, un intervalo, un listener o una
promesa, me pregunto qué variables de afuera está leyendo y quién más puede cambiarlas antes de que
llegue su turno. Si no tengo clara la respuesta, ahí hay un bug esperando.