Skip to content

Scope y closures ​

¿Por qué a veces una variable "no existe" aunque la acabas de crear? ¿Cómo hace un contador para recordar su valor entre clics sin usar variables globales? Las respuestas están en dos conceptos fundamentales: el scope (alcance) y los closures (clausuras). Dominarlos te separa de un principiante y te permite entender cómo funcionan muchas librerías por dentro.

Concepto ​

Scope: ¿dónde vive una variable? ​

El scope es la zona del código donde una variable existe y se puede usar. Piensa en una casa:

  • Lo que está en la calle (scope global) lo puede ver cualquiera.
  • Lo que está dentro de la casa (scope de función) sólo lo ven quienes están dentro.
  • Lo que está dentro de un cajón (scope de bloque) sólo se ve al abrir ese cajón.

Desde dentro de la casa puedes ver la calle por la ventana, pero desde la calle no puedes ver qué hay en los cajones. Lo de adentro puede ver hacia afuera, pero no al revés.

Closure: una función con memoria ​

Un closure es una función que recuerda las variables del lugar donde fue creada, incluso después de que ese lugar terminó de ejecutarse. Es como una mochila: cuando una función nace, se lleva en su mochila las variables que tenía a su alrededor, y las conserva siempre.

Los tres tipos de scope ​

1. Scope global ​

Variables declaradas fuera de cualquier función o bloque. Se pueden usar en todo el programa.

js
const nombreTienda = 'La Esquina'; // global

function mostrarTienda() {
  console.log(nombreTienda); // ✅ la función ve la variable global
}

mostrarTienda(); // "La Esquina"

2. Scope de función ​

Variables declaradas dentro de una función. Sólo existen ahí dentro.

js
function calcularTotal() {
  const iva = 0.16; // sólo existe dentro de calcularTotal
  return 100 * (1 + iva);
}

calcularTotal();
console.log(iva); // ❌ ReferenceError: iva is not defined

3. Scope de bloque ​

let y const declaradas dentro de llaves { } (un if, un for, etc.) sólo existen dentro de ese bloque.

js
const edad = 20;

if (edad >= 18) {
  const mensaje = 'Eres mayor de edad'; // scope de bloque
  console.log(mensaje); // ✅
}

console.log(mensaje); // ❌ ReferenceError: mensaje is not defined

for (let i = 0; i < 3; i++) {
  // i sólo existe aquí dentro
}
console.log(i); // ❌ ReferenceError

Error común: var ignora los bloques

var sólo respeta el scope de función, no el de bloque. Por eso "se escapa" de los if y for:

js
if (true) {
  var fuga = '¡Me escapé!';
  let segura = 'Yo me quedo aquí';
}
console.log(fuga);   // "¡Me escapé!" 😬
console.log(segura); // ❌ ReferenceError

// El clásico error con var en bucles
for (var j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 100);
}
// Imprime 3, 3, 3 (todas comparten la misma j)

for (let k = 0; k < 3; k++) {
  setTimeout(() => console.log(k), 100);
}
// Imprime 0, 1, 2 ✅ (cada vuelta tiene su propia k)

Otra razón más para usar siempre let y const.

La cadena de scopes ​

Cuando usas una variable, JavaScript la busca primero en el scope actual. Si no la encuentra, sube al scope que lo contiene, y así hasta llegar al global. Si tampoco está ahí, lanza un ReferenceError.

js
const moneda = 'MXN';              // nivel 1: global

function crearTicket() {
  const cliente = 'Ana';           // nivel 2: función

  function imprimirLinea() {
    const producto = 'Café';       // nivel 3: función interna
    // Busca producto (aquí), cliente (arriba), moneda (más arriba)
    console.log(`${cliente} compró ${producto} en ${moneda}`);
  }

  imprimirLinea();
}

crearTicket(); // "Ana compró Café en MXN"

Sombreado (shadowing) ​

Si declaras una variable con el mismo nombre en un scope interno, "tapa" a la de afuera:

js
const mensaje = 'global';

function prueba() {
  const mensaje = 'local'; // sombrea a la global
  console.log(mensaje);    // "local"
}

prueba();
console.log(mensaje);      // "global"

Hoisting de funciones ​

Como viste en Funciones, las declaraciones de función se elevan completas: puedes llamarlas antes de escribirlas. Las expresiones y flechas guardadas en const o let no.

js
saludar();     // ✅ "Hola"
// despedir(); // ❌ ReferenceError: Cannot access 'despedir' before initialization

function saludar() {
  console.log('Hola');
}

const despedir = () => console.log('Adiós');
Declaración¿Se puede usar antes?
function nombre() {}✅ Sí, completa
var x = 1⚠️ Existe, pero vale undefined
let / const❌ No (TDZ)
const f = () => {}❌ No (TDZ)

Closures en acción ​

Veamos un closure paso a paso:

js
function crearSaludo(saludo) {
  // saludo vive en el scope de crearSaludo

  return function (nombre) {
    // Esta función interna "recuerda" saludo
    return `${saludo}, ${nombre}`;
  };
}

const saludoFormal = crearSaludo('Buenos días');
const saludoCasual = crearSaludo('¿Qué onda');

// crearSaludo ya terminó, pero las funciones recuerdan su "saludo"
console.log(saludoFormal('Sra. López')); // "Buenos días, Sra. López"
console.log(saludoCasual('Beto'));       // "¿Qué onda, Beto"

Cada llamada a crearSaludo crea un nuevo scope con su propio saludo, y la función devuelta se lo lleva en su "mochila".

Caso real 1: contador con estado privado ​

Sin closures, un contador necesitaría una variable global que cualquiera podría modificar por error. Con un closure, la variable queda privada:

js
function crearContador() {
  let cuenta = 0; // privada: no se puede tocar desde fuera

  return {
    incrementar() {
      cuenta++;
      return cuenta;
    },
    decrementar() {
      cuenta--;
      return cuenta;
    },
    valor() {
      return cuenta;
    },
  };
}

const visitas = crearContador();
visitas.incrementar(); // 1
visitas.incrementar(); // 2
console.log(visitas.valor()); // 2

console.log(visitas.cuenta);  // undefined → no hay acceso directo 🔒

// Cada contador es independiente
const likes = crearContador();
likes.incrementar();          // 1
console.log(visitas.valor()); // 2 (no se afectó)

Pruébalo: cada tarjeta es un contador creado con una fábrica similar. Agrega más y verás que ninguno afecta a los demás:

Contadores con closures

El código clave de la demo:

js
function crearContador(paso = 1) {
  let cuenta = 0; // cada contador tiene la suya

  return {
    incrementar: () => (cuenta += paso),
    decrementar: () => (cuenta -= paso),
    reiniciar: () => (cuenta = 0),
    valor: () => cuenta,
  };
}

// Cada clic en "Nuevo contador" hace:
const contador = crearContador(paso); // un closure nuevo e independiente

Caso real 2: fábricas de funciones ​

Una fábrica es una función que crea otras funciones ya configuradas:

js
// Fábrica de calculadoras de descuento
function crearDescuento(porcentaje) {
  return (precio) => precio - precio * (porcentaje / 100);
}

const descuentoBuenFin = crearDescuento(30);
const descuentoEstudiante = crearDescuento(15);

console.log(descuentoBuenFin(1000));    // 700
console.log(descuentoEstudiante(1000)); // 850

// Fábrica de formateadores de moneda
function crearFormateador(moneda, locale) {
  const formato = new Intl.NumberFormat(locale, { style: 'currency', currency: moneda });
  return (cantidad) => formato.format(cantidad);
}

const pesos = crearFormateador('MXN', 'es-MX');
const dolares = crearFormateador('USD', 'en-US');

console.log(pesos(1499.5));   // "$1,499.50"
console.log(dolares(1499.5)); // "$1,499.50"

Fíjate que formato se crea una sola vez por fábrica y se reutiliza en cada llamada gracias al closure.

Caso real 3: debounce básico ​

Imagina un buscador que consulta una API cada vez que el usuario teclea. Si escribe "zapatos" serían 7 peticiones. Con debounce esperamos a que deje de escribir un momento y hacemos una sola petición.

js
function debounce(funcion, espera = 300) {
  let temporizador; // vive en el closure, compartido entre llamadas

  return (...args) => {
    clearTimeout(temporizador);           // cancelamos la espera anterior
    temporizador = setTimeout(() => {
      funcion(...args);                   // ejecutamos tras la pausa
    }, espera);
  };
}

// Uso: buscar sólo cuando el usuario deja de escribir 400 ms
const buscar = (texto) => console.log(`Buscando "${texto}"...`);
const buscarConDebounce = debounce(buscar, 400);

const input = document.querySelector('#buscador');
input.addEventListener('input', (e) => {
  buscarConDebounce(e.target.value);
});

¿Por qué funciona? Porque temporizador está en el closure: todas las llamadas a buscarConDebounce comparten la misma variable y pueden cancelar el temporizador anterior. Lo usarás en el proyecto del buscador.

Buena práctica

  • Declara las variables en el scope más pequeño posible: si sólo se usan dentro de un if, decláralas ahí.
  • Evita variables globales: cualquier parte del código (o un script de terceros) puede modificarlas.
  • Usa closures para encapsular estado privado en lugar de exponer variables sueltas.

Error común

Pensar que cada función creada en un closure comparte la misma variable cuando se llama a la fábrica varias veces. Cada llamada a la fábrica crea un scope nuevo: crearContador() dos veces = dos cuenta distintas. Pero dentro de una misma llamada, todas las funciones devueltas (incrementar, valor...) sí comparten la misma variable.

Ejercicios ​

  1. ¿Qué imprime este código y por qué?
js
let x = 'afuera';
function prueba() {
  let x = 'adentro';
  if (true) {
    let x = 'bloque';
  }
  console.log(x);
}
prueba();
console.log(x);
  1. Crea una fábrica crearMultiplicador(n) que devuelva una función que multiplique cualquier número por n. Crea triple y porDiez.
  2. Crea crearCuentaBancaria(saldoInicial) que devuelva los métodos depositar(monto), retirar(monto) (sin permitir saldo negativo) y consultar(). El saldo debe ser privado.
  3. Crea unaVez(fn): devuelve una función que ejecuta fn sólo la primera vez que se llama; las siguientes no hacen nada (útil para evitar doble clic en "Pagar").
Ver solución

1. Imprime "adentro" y luego "afuera". El x del bloque if sólo existe dentro de sus llaves; el console.log dentro de la función encuentra el x de la función, y el de afuera ve el global.

2.

js
function crearMultiplicador(n) {
  return (numero) => numero * n;
}

const triple = crearMultiplicador(3);
const porDiez = crearMultiplicador(10);

console.log(triple(5));  // 15
console.log(porDiez(7)); // 70

3.

js
function crearCuentaBancaria(saldoInicial = 0) {
  let saldo = saldoInicial;

  return {
    depositar(monto) {
      if (monto <= 0) return 'Monto inválido';
      saldo += monto;
      return saldo;
    },
    retirar(monto) {
      if (monto > saldo) return 'Fondos insuficientes';
      saldo -= monto;
      return saldo;
    },
    consultar() {
      return saldo;
    },
  };
}

const cuenta = crearCuentaBancaria(1000);
cuenta.depositar(500);         // 1500
cuenta.retirar(2000);          // "Fondos insuficientes"
console.log(cuenta.consultar()); // 1500
console.log(cuenta.saldo);       // undefined 🔒

4.

js
function unaVez(fn) {
  let yaSeEjecuto = false;

  return (...args) => {
    if (yaSeEjecuto) return;
    yaSeEjecuto = true;
    return fn(...args);
  };
}

const pagar = unaVez(() => console.log('💳 Procesando pago...'));
pagar(); // "💳 Procesando pago..."
pagar(); // (nada)
pagar(); // (nada)

Resumen ​

  • El scope define dónde existe una variable: global, de función o de bloque.
  • let y const respetan el scope de bloque; var sólo el de función (evítalo).
  • JavaScript busca las variables subiendo por la cadena de scopes: de adentro hacia afuera.
  • Las declaraciones de función tienen hoisting completo; las flechas en const no.
  • Un closure es una función que recuerda las variables del lugar donde se creó.
  • Usos reales: estado privado (contadores, cuentas), fábricas de funciones y debounce.

Siguiente: Arrays

Academia Web — aprende, copia y construye.