Fábricas respaldadas por cierre: Estado privado, amortiguación, límites de tasa y deshacer
Vea cómo los cierres mantienen el estado privado activo en las fábricas de JavaScript, por qué causan valores obsoletos en los temporizadores de React, y cómo el debounce, la limitación de frecuencia y la función de deshacer dependen de ellos.
Las clausuras están detrás de una cantidad sorprendente de funcionalidades cotidianas en JavaScript: contadores, entradas con retardo, limitación de solicitudes, pilas de deshacer y casi todos los hooks de React. También están detrás de toda una serie de errores en los que una función de callback informa un valor que ya no es verdadero. Esta guía establece una definición funcional, explica qué implica para el estado de React y luego presenta cuatro patrones prácticos, incluyendo las limitaciones que debe conocer antes de usarlos en entornos de producción.
Una definición funcional
Una clausura es una función que se empaqueta junto con las variables que estaban en su ámbito de definición. La función mantiene el acceso a esas variables incluso después de que la función externa que las declaró haya terminado de ejecutarse. Una imagen útil es la de una función que sale de casa llevando una bolsa con las variables a las que hace referencia; dondequiera que sea llamada posteriormente, accede a esa misma bolsa.
El patrón de fábrica para contadores
La demostración clásica es una fábrica que devuelve una función que actualiza una variable que no le pertenece:
function makeCounter() {
let count = 0; // lives inside makeCounter
function increment() {
count = count + 1;
console.log(count);
}
return increment;
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3
Paso a paso, esto es lo que sucede:
makeCounter()se ejecuta una sola vez y declaracount.- Devuelve
increment, que hace referencia acount, y luegomakeCountertermina. - Cada llamada a
counter()lee y actualiza esa mismacount, por lo que el valor de salida aumenta de 1 a 3.
Por lo general, las variables locales de una función se vuelven inaccesibles cuando esta devuelve, y el motor puede recuperarlas libremente. Aquí, increment sigue haciendo referencia a count, por lo que la variable permanece activa mientras increment siga funcionando. Si deseas conocer las reglas subyacentes que determinan cómo el motor encuentra count en primer lugar, el artículo sobre cómo la cadena de ámbito resuelve las variables explica ese mecanismo.
Por qué el estado de React se vuelve obsoleto dentro de los temporizadores
React no agrega nada especial sobre las clausuras; simplemente depende de ellas en todas partes, especialmente en los hooks. Esa dependencia explica una de las sorpresas más comunes en el código de componentes:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setTimeout(() => {
console.log("Count is: " + count);
}, 3000);
}
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Add</button>
<button onClick={handleClick}>Log count in 3 seconds</button>
</div>
);
}
Comience con count en el valor 0. Haga clic en “Registrar conteo en 3 segundos” y luego presione “Agregar” tres veces antes de que se active el temporizador. La consola mostrará 0, no 3.
La razón es que cada renderizado de Counter representa una nueva llamada a la función. Cada llamada obtiene su propia constante count y define su propio método handleClick. La función de callback de setTimeout se creó durante el renderizado en el que count era 0, y ese es el único valor de count que podrá ver nunca.
useEffect: indica a React qué valores capturados utiliza el efecto, para que React pueda volver a ejecutarlo con valores actualizados cuando estos cambien. Cuando realmente necesitas el valor más reciente dentro de una función de callback diferida, las opciones habituales son una actualización funcional como setCount(c => c + 1) para escrituras, o un ref que mantienes actualizado para lecturas. Las ventajas y desventajas de estas opciones se analizan en snapshot versus valores en tiempo real en callbacks diferidas.
Cuatro patrones de fábrica que encontrarás en código real
Estado encapsulado sin clases
Antes de que los campos con el prefijo # formaran parte del lenguaje, las clausuras eran la forma estándar de ocultar datos, y siguen siendo una opción ligera hoy en día. Cualquier cosa declarada en la función de fábrica solo es accesible a través de las funciones que devuelve:
function createBankAccount(balance) {
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
balance -= amount;
return balance;
},
getBalance() {
return balance;
}
};
}
const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — can't touch it directly
balance no es una propiedad del objeto devuelto, por lo que account.balance es undefined. La única forma de modificarlo es a través de deposit y withdraw.
Atenuación de entradas de búsqueda
La atenuación retrasa una acción hasta que la entrada haya permanecido inactiva durante un tiempo determinado. Es lo que evita que un cuadro de búsqueda envíe una solicitud con cada tecla pulsada:
function debounce(func, delay) {
let timer; // remembered between calls
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => func(...args), delay);
};
}
const searchInput = document.getElementById("search");
const handleSearch = debounce((e) => {
fetch(`/api/search?q=${e.target.value}`);
}, 300);
searchInput.addEventListener("input", handleSearch);
El detalle clave está en dónde se declara timer: una sola vez, dentro de debounce, fuera de la función devuelta. Cada pulsación de tecla llama a la función devuelta, la cual cancela el tiempo de espera pendiente y programa uno nuevo, de modo que una solicitud solo se envía después de 300 ms sin actividad de escritura. Si timer se declarara dentro de la función devuelta, cada llamada obtendría una variable nueva, el tiempo de espera anterior nunca podría cancelarse y cada pulsación de tecla seguiría generando una solicitud.
En un componente React se aplica la misma lógica, pero con un paso adicional: la función desacelerada debe crearse una sola vez y mantenerse estable (por ejemplo, con useMemo o un ref), de lo contrario cada renderizado creará un nuevo cierre con un timer distinto y el desaceleramiento dejará de funcionar silenciosamente.
Un limitador de solicitudes sencillo
La misma técnica puede limitar la frecuencia con la que se sirve un endpoint:
function createRateLimiter(maxRequests, windowMs) {
let requestCount = 0;
setInterval(() => (requestCount = 0), windowMs);
return function () {
requestCount++;
return requestCount <= maxRequests;
};
}
const rateLimiter = createRateLimiter(5, 60000); // 5 requests per minute
app.get("/api/data", (req, res) => {
if (!rateLimiter()) {
return res.status(429).send("Too many requests");
}
res.send("Here's your data");
});
requestCount es compartido de forma privada por cada llamada a rateLimiter, y un intervalo lo reinicia en cada ventana. Esta versión es un contador de ventana fija almacenado en memoria, por lo que hay que tener en cuenta sus limitaciones:
- conta a todos los solicitantes juntos, por lo que un cliente puede agotar el presupuesto destinado a todos; los limitadores reales registran la cuenta por usuario, token o IP
- el estado se encuentra en un solo proceso, por lo que varias instancias del servidor permiten alcanzar el límite total
- las ventanas fijas permiten picos en los límites, hasta el doble del límite en un breve período
setIntervalnunca se elimina, lo cual es importante en pruebas o cuando los limitadores se crean dinámicamente
Historial de deshacer y rehacer
Un gestor de historial mantiene su pila y cursor de forma privada para cada instancia:
function createHistoryManager() {
let history = [];
let currentIndex = -1;
return {
save(state) {
history = history.slice(0, currentIndex + 1);
history.push(state);
currentIndex++;
},
undo() {
if (currentIndex > 0) currentIndex--;
return history[currentIndex];
},
redo() {
if (currentIndex < history.length - 1) currentIndex++;
return history[currentIndex];
}
};
}
save descarta primero todo lo que está después de la posición actual, por lo que al registrar un nuevo cambio después de una acción de deshacer se elimina la rama de rehacer, lo cual coincide con el comportamiento de la mayoría de los editores. undo y redo simplemente mueven currentIndex dentro de los límites permitidos y devuelven el estado en ese momento. Tenga en cuenta que los estados almacenados deben ser instantáneas inmutables; si guarda un objeto y luego lo modifica, cada entrada de la historia apuntará a los mismos datos modificados.
Heurísticas rápidas para detectar cierres
- Los elementos internos usan a los externos. Una función definida dentro de otra función que hace referencia a las variables de la función externa es un cierre.
count en la fábrica de contadores, el cierre ve el nuevo valor.La trampa de var en bucles
Las variables de bucle muestran la regla de enlace de la manera más clara:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000);
}
// Logs: 3, 3, 3 — var is shared across the whole loop
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000);
}
// Logs: 0, 1, 2 — let creates a new i on every iteration
Con var, existe un único i para toda la función, y para cuando se ejecutan los tiempos de espera el bucle ya lo ha establecido en 3, por lo que las tres funciones de callback imprimen 3. Con let, cada iteración obtiene su propia asignación de i, y cada función de callback conserva una diferente, imprimiendo 0, 1 y 2.
Conclusión
Los cierres son el mecanismo detrás del estado privado, las entradas con demora, los limitadores de solicitudes, las pilas de deshacer y los ganchos personalizados, así como muchos errores relacionados con estados obsoletos. Dos preguntas resuelven la mayor parte de la confusión: ¿qué variable capturó esta función y ha sido reemplazada esa variable por otra más reciente desde entonces? En las fábricas de JavaScript simples, la variable capturada se comparte y se actualiza en el mismo lugar. En React, cada renderización trae variables nuevas, por lo que cualquier función de callback que sobreviva a su renderización verá un mundo más antiguo, a menos que se le proporcione intencionalmente una forma de leer el estado actual.
Lectura relacionada
- React Keys como identidad de componente: preservación y restablecimiento del estado — Aprenda por qué las claves de React deciden qué instancia de componente sobrevive a una renderización, cómo las claves de índice hacen que el estado se filtre entre filas y cómo usar una clave para restablecer un formulario.