Closures en JavaScript: qué son y por qué importan

D
DanisCh
• 12 min de lectura
Closures en JavaScript: qué son y por qué importan
JavaScript

Los closures son uno de los conceptos más importantes de JavaScript y también uno de los que más confusión generan entre quienes aprenden el lenguaje. No son una característica exótica que uses raramente: los closures están en el centro de cómo funcionan los módulos, los callbacks, las funciones de orden superior y el encapsulamiento en JavaScript. Entenderlos bien marca la diferencia entre escribir código que simplemente funciona y escribir código que entiendes de verdad.

En esta guía vas a entender qué es un closure, por qué existe en JavaScript, cómo funciona por dentro y cuáles son sus aplicaciones más importantes en código real.

Primero: scope léxico

Para entender los closures hay que entender primero cómo JavaScript busca variables: mediante el scope léxico (también llamado ámbito estático). El scope de una variable se determina por dónde está escrita en el código fuente, no por dónde se llama la función.

// El scope se determina por la posición en el código
const mensaje = "hola desde el global";

function exterior() {
    const nombre = "Ana";

    function interior() {
        // interior puede ver 'nombre' porque está definida dentro de exterior
        // interior puede ver 'mensaje' porque está definida en el scope global
        console.log(`${mensaje}, ${nombre}`);
    }

    interior();
}

exterior();   // "hola desde el global, Ana"

// Una función NO puede ver las variables de funciones que estén a su mismo nivel
function otraFuncion() {
    // console.log(nombre);   // ReferenceError: nombre is not defined
}

JavaScript busca variables siguiendo la cadena de scope: primero en el scope local de la función, luego en el scope de la función que la contiene, luego en el siguiente nivel, y así hasta el scope global. A esta cadena se le llama scope chain.

Qué es un closure

Un closure ocurre cuando una función recuerda las variables de su scope exterior incluso después de que la función exterior haya terminado de ejecutarse.

function crearSaludo(nombre) {
    // 'nombre' es una variable local de crearSaludo
    const prefijo = "Hola";

    function saludar() {
        // saludar "cierra sobre" las variables del scope exterior:
        // recuerda 'nombre' y 'prefijo' incluso después de que crearSaludo termine
        console.log(`${prefijo}, ${nombre}!`);
    }

    return saludar;   // devolvemos la función, NO la llamamos
}

// crearSaludo termina de ejecutarse aquí...
const saludarAna = crearSaludo("Ana");

// ...pero la función devuelta recuerda 'nombre' y 'prefijo'
saludarAna();    // "Hola, Ana!"

const saludarCarlos = crearSaludo("Carlos");
saludarCarlos(); // "Hola, Carlos!"

// Cada llamada a crearSaludo crea un closure independiente
// con su propia copia del entorno léxico

Lo que ocurre internamente: cuando saludar se crea dentro de crearSaludo, JavaScript no solo guarda el código de la función. Guarda también una referencia al entorno léxico en el que fue creada: el conjunto de variables que estaban disponibles en ese momento. Ese paquete (función + entorno léxico) es el closure.

Cuando crearSaludo termina, sus variables locales normalmente serían recolectadas por el garbage collector. Pero como saludar tiene una referencia a ese entorno, las variables se mantienen vivas mientras saludarAna exista.

Los closures comparten el entorno, no copias

Este es el matiz más importante y la fuente de muchos bugs. Cuando dos funciones cierran sobre la misma variable, comparten la misma referencia, no copias independientes del valor.

function crearContador() {
    let cuenta = 0;

    // Estas tres funciones comparten la MISMA variable 'cuenta'
    const incrementar = () => ++cuenta;
    const decrementar = () => --cuenta;
    const obtener    = () => cuenta;

    return { incrementar, decrementar, obtener };
}

const contador = crearContador();
contador.incrementar();   // cuenta = 1
contador.incrementar();   // cuenta = 2
contador.incrementar();   // cuenta = 3
contador.decrementar();   // cuenta = 2
console.log(contador.obtener());   // 2

// Cada llamada a crearContador() produce un closure independiente
const otro = crearContador();
otro.incrementar();
console.log(otro.obtener());   // 1  (no afecta al contador anterior)
console.log(contador.obtener()); // 2 (sigue siendo 2)

El bug clásico de los closures en bucles

Este comportamiento es la causa del bug más famoso relacionado con closures: funciones creadas dentro de un bucle que todas capturan la misma variable.

// ❌ BUG CLÁSICO: todas las funciones capturan la misma variable 'i'
const funciones = [];
for (var i = 0; i < 3; i++) {
    funciones.push(function() {
        console.log(i);   // 'i' es la misma variable para todas
    });
}

funciones[0]();   // 3  ← esperábamos 0
funciones[1]();   // 3  ← esperábamos 1
funciones[2]();   // 3  ← esperábamos 2
// Cuando se ejecutan, 'i' ya vale 3 (el valor final del bucle)


// ✅ SOLUCIÓN 1: usar let en lugar de var
// let crea una variable nueva en cada iteración del bucle
const funcionesOk1 = [];
for (let i = 0; i < 3; i++) {
    funcionesOk1.push(function() {
        console.log(i);   // cada función tiene su propia 'i'
    });
}
funcionesOk1[0]();   // 0
funcionesOk1[1]();   // 1
funcionesOk1[2]();   // 2


// ✅ SOLUCIÓN 2: IIFE (función inmediatamente invocada)
// Captura el valor actual de i en un nuevo scope
const funcionesOk2 = [];
for (var i = 0; i < 3; i++) {
    funcionesOk2.push((function(indice) {
        return function() {
            console.log(indice);   // 'indice' es local a cada IIFE
        };
    })(i));   // pasamos 'i' como argumento, se copia el valor
}
funcionesOk2[0]();   // 0
funcionesOk2[1]();   // 1
funcionesOk2[2]();   // 2


// ✅ SOLUCIÓN 3: bind para fijar el valor
const funcionesOk3 = [];
for (var i = 0; i < 3; i++) {
    funcionesOk3.push((function(indice) {
        console.log(indice);
    }).bind(null, i));
}

Encapsulamiento y privacidad con closures

JavaScript no tiene modificadores de acceso nativos como private en Java o C#. Los closures son el mecanismo clásico para crear variables privadas: variables que solo son accesibles desde funciones específicas y no desde el exterior.

// Variables "privadas" mediante closures
function crearCuentaBancaria(saldoInicial) {
    // Esta variable es inaccesible desde fuera
    let saldo = saldoInicial;
    const historial = [];

    function registrar(operacion, cantidad) {
        historial.push({
            operacion,
            cantidad,
            saldo,
            fecha: new Date().toISOString()
        });
    }

    return {
        depositar(cantidad) {
            if (cantidad <= 0) throw new Error("El importe debe ser positivo");
            saldo += cantidad;
            registrar("deposito", cantidad);
            return this;   // permite encadenamiento
        },

        retirar(cantidad) {
            if (cantidad <= 0) throw new Error("El importe debe ser positivo");
            if (cantidad > saldo) throw new Error("Saldo insuficiente");
            saldo -= cantidad;
            registrar("retirada", cantidad);
            return this;
        },

        verSaldo() {
            return saldo;
        },

        verHistorial() {
            return [...historial];   // copia del array, no la referencia
        }
    };
}

const cuenta = crearCuentaBancaria(1000);
cuenta.depositar(500).depositar(200).retirar(300);

console.log(cuenta.verSaldo());   // 1400
console.log(cuenta.verHistorial());
// [{operacion: 'deposito', cantidad: 500, saldo: 1500, ...}, ...]

// 'saldo' no es accesible directamente
console.log(cuenta.saldo);   // undefined  ← encapsulado correctamente

// No se puede manipular sin pasar por los métodos
// cuenta.saldo = 999999;   // no hace nada
console.log(cuenta.verSaldo());   // sigue siendo 1400

Closures y funciones de orden superior

Las funciones de orden superior son funciones que reciben otras funciones como argumento o las devuelven. Los closures son lo que hace posible que estas funciones "configuren" el comportamiento de la función que devuelven.

// Función de orden superior que devuelve un función multiplicadora configurada
function multiplicadorPor(factor) {
    return (numero) => numero * factor;   // 'factor' queda en el closure
}

const doble   = multiplicadorPor(2);
const triple  = multiplicadorPor(3);
const mitad   = multiplicadorPor(0.5);

console.log(doble(5));    // 10
console.log(triple(5));   // 15
console.log(mitad(8));    // 4

// Array de operaciones
const operaciones = [doble, triple, mitad];
console.log(operaciones.map(fn => fn(10)));   // [20, 30, 5]


// Función que crea validadores configurables
function crearValidador(min, max) {
    return function validar(valor) {
        if (typeof valor !== "number") return { valido: false, error: "No es un número" };
        if (valor < min) return { valido: false, error: `Mínimo: ${min}` };
        if (valor > max) return { valido: false, error: `Máximo: ${max}` };
        return { valido: true };
    };
}

const validarEdad     = crearValidador(0, 120);
const validarPorcentaje = crearValidador(0, 100);
const validarTemperatura = crearValidador(-273.15, 1e6);

console.log(validarEdad(25));        // { valido: true }
console.log(validarEdad(200));       // { valido: false, error: 'Máximo: 120' }
console.log(validarPorcentaje(105)); // { valido: false, error: 'Máximo: 100' }


// Composición de funciones mediante closures
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe    = (...fns) => (x) => fns.reduce((acc, fn)       => fn(acc), x);

const procesarTexto = pipe(
    (s) => s.trim(),
    (s) => s.toLowerCase(),
    (s) => s.replace(/\s+/g, "-"),
    (s) => `slug: ${s}`
);

console.log(procesarTexto("  Hola Mundo  "));   // "slug: hola-mundo"

Memoización con closures

La memoización es una técnica de optimización que almacena en caché los resultados de llamadas a funciones costosas. Los closures son el mecanismo natural para implementarla: la caché vive en el scope de la función externa y persiste entre llamadas.

// Función de memoización genérica
function memoizar(fn) {
    const cache = new Map();   // la caché vive en el closure

    return function(...args) {
        const clave = JSON.stringify(args);

        if (cache.has(clave)) {
            console.log(`[cache hit] ${clave}`);
            return cache.get(clave);
        }

        console.log(`[calculando] ${clave}`);
        const resultado = fn.apply(this, args);
        cache.set(clave, resultado);
        return resultado;
    };
}

// Fibonacci sin memoización: O(2^n)
function fibLento(n) {
    if (n <= 1) return n;
    return fibLento(n - 1) + fibLento(n - 2);
}

// Fibonacci con memoización: O(n)
const fibRapido = memoizar(function fib(n) {
    if (n <= 1) return n;
    return fibRapido(n - 1) + fibRapido(n - 2);
});

console.time("sin memo");
fibLento(35);          // tarda ~100ms
console.timeEnd("sin memo");

console.time("con memo");
fibRapido(35);         // tarda <1ms
console.timeEnd("con memo");

fibRapido(35);         // [cache hit] [35]

Closures en callbacks y programación asíncrona

Los closures son fundamentales en el código asíncrono de JavaScript: los callbacks capturan el contexto en el que fueron creados, lo que permite que funcionen correctamente cuando se ejecutan más tarde.

// Los callbacks capturan el contexto mediante closures
function obtenerUsuario(id) {
    const timestamp = Date.now();   // capturado en el closure

    fetch(`/api/usuarios/${id}`)
        .then(res => res.json())
        .then(usuario => {
            // Este callback se ejecuta después, pero recuerda 'id' y 'timestamp'
            const duracion = Date.now() - timestamp;
            console.log(`Usuario ${id} obtenido en ${duracion}ms:`, usuario);
        });
}

// El mismo patrón con async/await (los closures siguen funcionando)
async function procesarPedido(pedidoId, usuarioId) {
    const inicio = Date.now();

    const [pedido, usuario] = await Promise.all([
        fetch(`/api/pedidos/${pedidoId}`).then(r => r.json()),
        fetch(`/api/usuarios/${usuarioId}`).then(r => r.json())
    ]);

    // 'inicio', 'pedidoId' y 'usuarioId' están disponibles aquí gracias a closures
    console.log(`Procesado en ${Date.now() - inicio}ms`);
    console.log(`Pedido ${pedidoId} para ${usuario.nombre}`);
    return { pedido, usuario };
}


// Event listeners: el closure captura el estado en el momento de registro
function configurarBoton(botonId, mensaje) {
    const boton = document.getElementById(botonId);
    let vecesClicado = 0;

    boton.addEventListener("click", function() {
        vecesClicado++;
        // 'mensaje' y 'vecesClicado' viven en el closure
        console.log(`${mensaje} (clic número ${vecesClicado})`);
    });
}

configurarBoton("btn-enviar", "Formulario enviado");
// Cada clic recuerda el mensaje y el contador de su propio closure

Patrón módulo con closures

Antes de que existieran los módulos ES6 (import/export), el patrón módulo usando closures era la forma estándar de organizar y encapsular código en JavaScript.

// Patrón módulo clásico con IIFE
const carrito = (function() {
    // Variables privadas: solo accesibles desde dentro
    const items = [];
    let descuento = 0;

    // Función privada
    function calcularSubtotal() {
        return items.reduce((sum, item) => sum + item.precio * item.cantidad, 0);
    }

    // API pública: solo lo que devolvemos es accesible
    return {
        agregar(producto, cantidad = 1) {
            const existente = items.find(i => i.id === producto.id);
            if (existente) {
                existente.cantidad += cantidad;
            } else {
                items.push({ ...producto, cantidad });
            }
            return this;
        },

        eliminar(productoId) {
            const index = items.findIndex(i => i.id === productoId);
            if (index !== -1) items.splice(index, 1);
            return this;
        },

        aplicarDescuento(porcentaje) {
            descuento = Math.min(100, Math.max(0, porcentaje));
            return this;
        },

        total() {
            const subtotal = calcularSubtotal();
            return subtotal * (1 - descuento / 100);
        },

        items() {
            return [...items];   // copia: no se puede modificar el array interno
        }
    };
})();

// La API pública funciona
carrito
    .agregar({ id: 1, nombre: "Laptop", precio: 999 })
    .agregar({ id: 2, nombre: "Ratón",  precio: 29 }, 2)
    .aplicarDescuento(10);

console.log(carrito.total());    // (999 + 58) * 0.9 = 952.1
console.log(carrito.items());    // [{id:1,...}, {id:2,...}]

// Las variables internas no son accesibles
console.log(carrito.items);      // undefined  ← 'items' interno está encapsulado
console.log(carrito.descuento);  // undefined

Closures y gestión de memoria

Los closures mantienen referencias a su entorno léxico, lo que significa que las variables capturadas no se liberan mientras el closure exista. En la mayoría de los casos esto es exactamente lo que quieres. Pero es importante ser consciente de ello para evitar retención de memoria no intencional.

// Potential memory leak: closures que capturan objetos grandes sin querer
function crearHandler(nodo) {
    // ❌ 'nodo' queda retenido en el closure aunque no se use directamente
    const id = nodo.id;   // ✅ mejor: extraer solo lo que necesitas

    return function handler(evento) {
        console.log(`Click en elemento ${id}`);
        // Si usáramos 'nodo' aquí, el DOM node completo quedaría en memoria
    };
}

// Limpiar event listeners cuando ya no se necesitan
function configurarModal(botonAbrir) {
    function abrirModal() {
        document.getElementById("modal").style.display = "block";
    }

    botonAbrir.addEventListener("click", abrirModal);

    // Devolver una función de limpieza
    return function limpiar() {
        botonAbrir.removeEventListener("click", abrirModal);
    };
}

const limpiar = configurarModal(document.getElementById("btn-abrir"));
// Cuando ya no necesites el listener:
limpiar();   // elimina el listener y permite que el closure sea recolectado


// En React: el equivalente con useEffect
useEffect(() => {
    function handleClick() { /* ... */ }
    window.addEventListener("click", handleClick);

    return () => window.removeEventListener("click", handleClick);   // cleanup
}, []);

Resumen

  • Un closure es la combinación de una función y el entorno léxico en el que fue creada. La función "recuerda" las variables de su scope exterior aunque la función exterior haya terminado.
  • El scope léxico determina qué variables puede ver una función según dónde está escrita en el código, no dónde se llama.
  • Los closures comparten referencias a las variables, no copias. Esto explica el bug clásico de los bucles con var: usa let para crear un nuevo binding en cada iteración.
  • Son el mecanismo de encapsulamiento clásico en JavaScript: variables declaradas en una función exterior son privadas para las funciones interiores.
  • Las funciones de orden superior (las que devuelven funciones) dependen fundamentalmente de los closures para "configurar" el comportamiento de la función devuelta.
  • Los callbacks asíncronos y los event listeners capturan su contexto mediante closures, lo que les permite acceder a las variables correctas cuando se ejecutan más tarde.
  • Los closures retienen memoria: limpia los event listeners y referencias que ya no necesites para evitar fugas de memoria.

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a