Closures en JavaScript: qué son, cómo funcionan y cuándo importan
Un recorrido práctico por los closures de JavaScript — desde cómo el motor los crea internamente hasta patrones reales que probablemente ya estás usando sin darte cuenta.
Si llevas un tiempo escribiendo JavaScript, ya usaste closures. Puede que no los hayas llamado así, pero están por todos lados: en cada callback, en cada handler, en cada función que retorna otra función.
El problema es que muchos tutoriales los explican como algo místico o avanzado, cuando en realidad son una consecuencia natural de cómo JavaScript maneja el scope. Vamos a desarmar eso.
Antes de hablar de closures: scope léxico
Para entender closures, primero necesitás entender una regla fundamental del lenguaje: el scope de una variable se determina por dónde se escribe el código, no por dónde se ejecuta.
Esto se llama lexical scoping (o scope léxico). Cuando una función es creada, JavaScript guarda una referencia al entorno en el que nació — las variables que existían en ese momento.
const equipo = "hexadevs";
function saludar() {
console.log(`Hola desde ${equipo}`);
}
saludar(); // "Hola desde hexadevs"
saludar puede leer equipo porque fue definida en el mismo scope. Nada raro hasta acá. Pero ¿qué pasa cuando esa función se ejecuta después de que su scope padre dejó de existir?
Entonces, ¿qué es un closure?
Un closure es una función que “recuerda” las variables del scope donde fue creada, incluso cuando ese scope ya terminó de ejecutarse. No es una feature especial que activás, es el comportamiento por defecto del lenguaje.
function crearContador() {
let contador = 0;
return function incrementar() {
contador++;
return contador;
};
}
const contar = crearContador();
console.log(contar()); // 1
console.log(contar()); // 2
console.log(contar()); // 3
Cuando crearContador() termina de ejecutarse, en teoría contador debería desaparecer — ya no hay nada en el call stack que lo necesite. Pero la función incrementar todavía tiene una referencia a esa variable. JavaScript la mantiene viva. Eso es un closure.
Lo interesante es que cada llamada a crearContador() crea un closure nuevo con su propia variable contador. No se comparten entre sí:
const contarA = crearContador();
const contarB = crearContador();
console.log(contarA()); // 1
console.log(contarA()); // 2
console.log(contarB()); // 1 — su propio contador
Patrones donde los closures brillan
Ahora que sabemos qué son, veamos dónde aparecen en código del día a día.
Encapsulamiento sin clases
Antes de que class existiera en JavaScript, los closures eran la forma de crear estado privado. Y hoy siguen siendo una alternativa válida cuando no querés toda la ceremonia de una clase:
function crearCuenta(saldoInicial) {
let saldo = saldoInicial;
return {
depositar(monto) {
if (monto <= 0) throw new Error("El monto debe ser positivo");
saldo += monto;
},
retirar(monto) {
if (monto > saldo) throw new Error("Saldo insuficiente");
saldo -= monto;
},
getSaldo() {
return saldo;
},
};
}
const cuenta = crearCuenta(1000);
cuenta.depositar(500);
cuenta.retirar(200);
console.log(cuenta.getSaldo()); // 1300
saldo no es accesible desde afuera. No hay cuenta.saldo. Eso no es un truco — es simplemente que las funciones retornadas tienen un closure sobre saldo, y nada más lo tiene.
El trade-off: perdés instanceof, no tenés herencia con extends, y si necesitás muchas instancias, puede ser menos eficiente en memoria que una clase (cada instancia crea sus propias funciones en vez de compartirlas vía prototype). Pero para objetos simples con estado interno, es elegante y suficiente.
Funciones de fábrica (factory functions)
Este es un patrón que usamos mucho en la práctica:
function crearLogger(prefijo) {
return function log(mensaje) {
console.log(`[${prefijo}] ${mensaje}`);
};
}
const logDB = crearLogger("DB");
const logHTTP = crearLogger("HTTP");
logDB("Conexión establecida"); // [DB] Conexión establecida
logHTTP("GET /api/users → 200"); // [HTTP] GET /api/users → 200
Cada logger tiene su propio prefijo capturado en el closure. Es un patrón simple, legible y muy útil para configurar funciones sin repetir parámetros.
Otro caso concreto: crear multiplicadores o transformadores configurables:
function multiplicadorPor(factor) {
return (n) => n * factor;
}
const doble = multiplicadorPor(2);
const triple = multiplicadorPor(3);
[1, 2, 3].map(doble); // [2, 4, 6]
[1, 2, 3].map(triple); // [3, 6, 9]
Event handlers con contexto
En el mundo del DOM, cada vez que pasás un callback a addEventListener, probablemente estés creando un closure sin pensarlo:
function crearBoton(etiqueta, accion) {
const btn = document.createElement("button");
btn.textContent = etiqueta;
btn.addEventListener("click", () => {
// 'etiqueta' y 'accion' viven en el closure
console.log(`Click en "${etiqueta}"`);
accion();
});
return btn;
}
Cada botón tiene su propia copia de etiqueta y accion. No hay variables globales, no hay IDs mágicos.
El bug clásico: closures en bucles con var
Este es probablemente el error más conocido relacionado con closures, y que todavía aparece en entrevistas y en código legacy:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Imprime: 3, 3, 3 😬
¿Por qué? Porque var no crea un scope nuevo por iteración. Las tres funciones del setTimeout comparten la misma variable i, y cuando se ejecutan (100ms después), i ya vale 3.
La solución moderna es directa:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Imprime: 0, 1, 2 ✓
let crea un scope de bloque por iteración. Cada closure captura su propia i. Antes de ES6, la solución era wrappear con una IIFE — agradecé que ya no hace falta.
Closures y memoria: un trade-off real
Los closures no son gratis. La función retiene una referencia al scope padre, y eso significa que las variables de ese scope no pueden ser recolectadas por el garbage collector mientras el closure exista.
En la mayoría de los casos esto es irrelevante. Pero si estás capturando datos grandes en un closure que vive mucho tiempo (como un event handler que nunca se remueve), puede convertirse en un memory leak silencioso:
function adjuntarHandler() {
const datos = new Array(1_000_000).fill("dato-pesado");
document.getElementById("btn").addEventListener("click", () => {
console.log(datos.length); // 'datos' nunca se libera
});
}
Mientras ese handler exista, el array de un millón de elementos va a seguir en memoria. La solución es capturar solo lo que necesitás:
function adjuntarHandler() {
const datos = new Array(1_000_000).fill("dato-pesado");
const total = datos.length; // solo guardamos el número
document.getElementById("btn").addEventListener("click", () => {
console.log(total); // 'datos' puede ser recolectado
});
}
Otro patrón que ayuda: remover event listeners cuando ya no se necesitan, especialmente en componentes de frameworks como React (donde esto se hace en el cleanup de useEffect).
Dónde aparecen closures sin que te des cuenta
Si usás React, cada componente funcional es básicamente una fábrica de closures. Cada render crea nuevas funciones que capturan el estado actual:
function Contador() {
const [count, setCount] = useState(0);
function incrementar() {
// Esta función tiene un closure sobre 'count' y 'setCount'
setCount(count + 1);
}
return <button onClick={incrementar}>{count}</button>;
}
Esto explica bugs como el “stale closure” — cuando una función captura un valor viejo del estado porque se creó en un render anterior. Si alguna vez te pasó que un setInterval dentro de un useEffect siempre leía el mismo valor, ahora sabés por qué.
Los closures también están detrás de:
- Middleware de Express:
(req, res, next) => { ... }tiene un closure sobre las variables del middleware exterior. - Currying:
const add = (a) => (b) => a + b— el closure más compacto que existe. - Debounce/throttle: el timer ID se guarda en el closure entre invocaciones.
- Iteradores:
function* generador() { ... }mantiene estado entre llamadas gracias a closures internos.
Para llevar
Los closures no son un concepto exótico que necesitás “aprender aparte” — son una consecuencia directa de cómo funciona el scope en JavaScript. Entenderlos te ayuda a:
- Razonar sobre qué variables están disponibles en qué momento
- Detectar memory leaks potenciales antes de que se vuelvan un problema
- Escribir código más modular con estado encapsulado, sin necesidad de clases
- Debuggear problemas de “stale closures” en React y otros frameworks
La próxima vez que veas una función que retorna otra función, o un callback que accede a una variable “de afuera”, ya sabés exactamente qué está pasando por debajo. No es magia — es scope léxico haciendo su trabajo.